Compare commits

..

3 Commits

79 changed files with 1756 additions and 3487 deletions

View File

@ -1,17 +1,9 @@
from flask_restx import Resource
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from fields.agent_fields import (
AgentAppComposerResponse,
AgentComposerCandidatesResponse,
AgentComposerImpactResponse,
AgentComposerValidateResponse,
WorkflowAgentComposerResponse,
)
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
@ -19,40 +11,23 @@ from services.agent.composer_validator import ComposerConfigValidator
from services.entities.agent_entities import ComposerSavePayload
register_schema_models(console_ns, ComposerSavePayload)
register_response_schema_models(
console_ns,
AgentAppComposerResponse,
AgentComposerCandidatesResponse,
AgentComposerImpactResponse,
AgentComposerValidateResponse,
WorkflowAgentComposerResponse,
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
class WorkflowAgentComposerApi(Resource):
@console_ns.response(
200, "Workflow agent composer state", console_ns.models[WorkflowAgentComposerResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model: App, node_id: str):
_, tenant_id = current_account_with_tenant()
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
),
return AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Workflow agent composer saved", console_ns.models[WorkflowAgentComposerResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -61,24 +36,18 @@ class WorkflowAgentComposerApi(Resource):
def put(self, app_model: App, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
),
return AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/validate")
class WorkflowAgentComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Workflow agent composer validation result", console_ns.models[AgentComposerValidateResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -86,29 +55,21 @@ class WorkflowAgentComposerValidateApi(Resource):
def post(self, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
return {"result": "success", "errors": []}
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
class WorkflowAgentComposerCandidatesApi(Resource):
@console_ns.response(
200, "Workflow agent composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model: App, node_id: str):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_workflow_candidates(app_id=app_model.id),
)
return AgentComposerService.get_workflow_candidates(app_id=app_model.id)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/impact")
class WorkflowAgentComposerImpactApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(200, "Workflow agent composer impact", console_ns.models[AgentComposerImpactResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -118,21 +79,13 @@ class WorkflowAgentComposerImpactApi(Resource):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None
if not current_snapshot_id:
return dump_response(
AgentComposerImpactResponse, {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
)
return dump_response(
AgentComposerImpactResponse,
AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=current_snapshot_id),
)
return {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
return AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=current_snapshot_id)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/save-to-roster")
class WorkflowAgentComposerSaveToRosterApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Workflow agent composer saved to roster", console_ns.models[WorkflowAgentComposerResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -141,34 +94,26 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
def post(self, app_model: App, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
),
return AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
)
@console_ns.route("/apps/<uuid:app_id>/agent-composer")
class AgentAppComposerApi(Resource):
@console_ns.response(200, "Agent app composer state", console_ns.models[AgentAppComposerResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model: App):
_, tenant_id = current_account_with_tenant()
return dump_response(
AgentAppComposerResponse,
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id),
)
return AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(200, "Agent app composer saved", console_ns.models[AgentAppComposerResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -177,23 +122,17 @@ class AgentAppComposerApi(Resource):
def put(self, app_model: App):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return dump_response(
AgentAppComposerResponse,
AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
app_id=app_model.id,
account_id=account.id,
payload=payload,
),
return AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
app_id=app_model.id,
account_id=account.id,
payload=payload,
)
@console_ns.route("/apps/<uuid:app_id>/agent-composer/validate")
class AgentAppComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Agent app composer validation result", console_ns.models[AgentComposerValidateResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -201,20 +140,14 @@ class AgentAppComposerValidateApi(Resource):
def post(self, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
return {"result": "success", "errors": []}
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
class AgentAppComposerCandidatesApi(Resource):
@console_ns.response(
200, "Agent app composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model: App):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
)
return AgentComposerService.get_agent_app_candidates(app_id=app_model.id)

View File

@ -4,18 +4,10 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from extensions.ext_database import db
from fields.agent_fields import (
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentRosterListResponse,
AgentRosterResponse,
)
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from services.agent.roster_service import AgentRosterService
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload, RosterListQuery
@ -37,14 +29,6 @@ register_schema_models(
RosterAgentUpdatePayload,
RosterListQuery,
)
register_response_schema_models(
console_ns,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentRosterListResponse,
AgentRosterResponse,
)
def _agent_roster_service() -> AgentRosterService:
@ -53,23 +37,17 @@ def _agent_roster_service() -> AgentRosterService:
@console_ns.route("/agents")
class AgentRosterListApi(Resource):
@console_ns.doc(params=query_params_from_model(RosterListQuery))
@console_ns.response(200, "Agent roster list", console_ns.models[AgentRosterListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self):
_, tenant_id = current_account_with_tenant()
query = RosterListQuery.model_validate(request.args.to_dict(flat=True))
return dump_response(
AgentRosterListResponse,
_agent_roster_service().list_roster_agents(
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
),
return _agent_roster_service().list_roster_agents(
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
)
@console_ns.expect(console_ns.models[RosterAgentCreatePayload.__name__])
@console_ns.response(201, "Agent created", console_ns.models[AgentRosterResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -79,49 +57,36 @@ class AgentRosterListApi(Resource):
payload = RosterAgentCreatePayload.model_validate(console_ns.payload or {})
service = _agent_roster_service()
agent = service.create_roster_agent(tenant_id=tenant_id, account_id=account.id, payload=payload)
return dump_response(
AgentRosterResponse,
service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id),
), 201
return service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id), 201
@console_ns.route("/agents/invite-options")
class AgentInviteOptionsApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery))
@console_ns.response(200, "Agent invite options", console_ns.models[AgentInviteOptionsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self):
_, tenant_id = current_account_with_tenant()
query = AgentInviteOptionsQuery.model_validate(request.args.to_dict(flat=True))
return dump_response(
AgentInviteOptionsResponse,
_agent_roster_service().list_invite_options(
tenant_id=tenant_id,
page=query.page,
limit=query.limit,
keyword=query.keyword,
app_id=query.app_id,
),
return _agent_roster_service().list_invite_options(
tenant_id=tenant_id,
page=query.page,
limit=query.limit,
keyword=query.keyword,
app_id=query.app_id,
)
@console_ns.route("/agents/<uuid:agent_id>")
class AgentRosterDetailApi(Resource):
@console_ns.response(200, "Agent detail", console_ns.models[AgentRosterResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id: UUID):
_, tenant_id = current_account_with_tenant()
return dump_response(
AgentRosterResponse,
_agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id)),
)
return _agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id))
@console_ns.expect(console_ns.models[RosterAgentUpdatePayload.__name__])
@console_ns.response(200, "Agent updated", console_ns.models[AgentRosterResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -129,14 +94,10 @@ class AgentRosterDetailApi(Resource):
def patch(self, agent_id: UUID):
account, tenant_id = current_account_with_tenant()
payload = RosterAgentUpdatePayload.model_validate(console_ns.payload or {})
return dump_response(
AgentRosterResponse,
_agent_roster_service().update_roster_agent(
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload
),
return _agent_roster_service().update_roster_agent(
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload
)
@console_ns.response(204, "Agent archived")
@setup_required
@login_required
@account_initialization_required
@ -149,31 +110,23 @@ class AgentRosterDetailApi(Resource):
@console_ns.route("/agents/<uuid:agent_id>/versions")
class AgentRosterVersionsApi(Resource):
@console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id: UUID):
_, tenant_id = current_account_with_tenant()
return dump_response(
AgentConfigSnapshotListResponse,
{"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))},
)
return {"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))}
@console_ns.route("/agents/<uuid:agent_id>/versions/<uuid:version_id>")
class AgentRosterVersionDetailApi(Resource):
@console_ns.response(200, "Agent version detail", console_ns.models[AgentConfigSnapshotDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id: UUID, version_id: UUID):
_, tenant_id = current_account_with_tenant()
return dump_response(
AgentConfigSnapshotDetailResponse,
_agent_roster_service().get_agent_version_detail(
tenant_id=tenant_id,
agent_id=str(agent_id),
version_id=str(version_id),
),
return _agent_roster_service().get_agent_version_detail(
tenant_id=tenant_id,
agent_id=str(agent_id),
version_id=str(version_id),
)

View File

@ -1,192 +0,0 @@
from typing import Any, Literal
from pydantic import Field
from fields.base import ResponseModel
from models.agent import (
AgentConfigRevisionOperation,
AgentIconType,
AgentKind,
AgentScope,
AgentSource,
AgentStatus,
WorkflowAgentBindingType,
)
from models.agent_config_entities import (
AgentSoulConfig,
DeclaredOutputConfig,
DeclaredOutputType,
WorkflowNodeJobConfig,
)
from services.entities.agent_entities import (
ComposerCandidateCapabilities,
ComposerSaveStrategy,
ComposerVariant,
)
class AgentConfigSnapshotSummaryResponse(ResponseModel):
id: str
agent_id: str | None = None
version: int
summary: str | None = None
version_note: str | None = None
created_by: str | None = None
created_at: str | None = None
class AgentRosterResponse(ResponseModel):
id: str
name: str
description: str
icon_type: AgentIconType | None = None
icon: str | None = None
icon_background: str | None = None
agent_kind: AgentKind
scope: AgentScope
source: AgentSource
app_id: str | None = None
workflow_id: str | None = None
workflow_node_id: str | None = None
active_config_snapshot_id: str | None = None
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
status: AgentStatus
created_by: str | None = None
updated_by: str | None = None
archived_by: str | None = None
archived_at: str | None = None
created_at: str | None = None
updated_at: str | None = None
class AgentInviteOptionResponse(AgentRosterResponse):
is_in_current_workflow: bool = False
in_current_workflow_count: int = 0
existing_node_ids: list[str] = Field(default_factory=list)
class AgentRosterListResponse(ResponseModel):
data: list[AgentRosterResponse]
page: int
limit: int
total: int
has_more: bool
class AgentInviteOptionsResponse(ResponseModel):
data: list[AgentInviteOptionResponse]
page: int
limit: int
total: int
has_more: bool
class AgentConfigRevisionResponse(ResponseModel):
id: str
previous_snapshot_id: str | None = None
current_snapshot_id: str
revision: int
operation: AgentConfigRevisionOperation
summary: str | None = None
version_note: str | None = None
created_by: str | None = None
created_at: str | None = None
class AgentConfigSnapshotDetailResponse(AgentConfigSnapshotSummaryResponse):
config_snapshot: AgentSoulConfig
revisions: list[AgentConfigRevisionResponse] = Field(default_factory=list)
class AgentConfigSnapshotListResponse(ResponseModel):
data: list[AgentConfigSnapshotSummaryResponse]
class AgentComposerAgentResponse(ResponseModel):
id: str
name: str
description: str
scope: AgentScope
status: AgentStatus
active_config_snapshot_id: str | None = None
class AgentComposerBindingResponse(ResponseModel):
id: str
binding_type: WorkflowAgentBindingType
agent_id: str | None = None
current_snapshot_id: str | None = None
workflow_id: str
node_id: str
class AgentComposerSoulLockResponse(ResponseModel):
locked: bool
can_unlock: bool = False
reason: str | None = None
class AgentComposerImpactBindingResponse(ResponseModel):
app_id: str
workflow_id: str
node_id: str
class AgentComposerImpactResponse(ResponseModel):
current_snapshot_id: str | None = None
workflow_node_count: int
bindings: list[AgentComposerImpactBindingResponse] = Field(default_factory=list)
class WorkflowAgentComposerResponse(ResponseModel):
variant: Literal[ComposerVariant.WORKFLOW]
agent: AgentComposerAgentResponse | None = None
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
binding: AgentComposerBindingResponse | None = None
soul_lock: AgentComposerSoulLockResponse
agent_soul: AgentSoulConfig
node_job: WorkflowNodeJobConfig
effective_declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list)
save_options: list[ComposerSaveStrategy]
impact_summary: AgentComposerImpactResponse | None = None
app_id: str | None = None
workflow_id: str | None = None
node_id: str | None = None
class AgentAppComposerResponse(ResponseModel):
variant: Literal[ComposerVariant.AGENT_APP]
agent: AgentComposerAgentResponse
active_config_snapshot: AgentConfigSnapshotSummaryResponse
agent_soul: AgentSoulConfig
save_options: list[ComposerSaveStrategy]
class AgentComposerValidateResponse(ResponseModel):
result: Literal["success"]
errors: list[str] = Field(default_factory=list)
class AgentComposerNodeJobCandidatesResponse(ResponseModel):
previous_node_outputs: list[dict[str, Any]] = Field(default_factory=list)
declare_output_types: list[DeclaredOutputType] = Field(default_factory=list)
human_contacts: list[dict[str, Any]] = Field(default_factory=list)
class AgentComposerSoulCandidatesResponse(ResponseModel):
skills_files: list[dict[str, Any]] = Field(default_factory=list)
dify_tools: list[dict[str, Any]] = Field(default_factory=list)
cli_tools: list[dict[str, Any]] = Field(default_factory=list)
knowledge_datasets: list[dict[str, Any]] = Field(default_factory=list)
human_contacts: list[dict[str, Any]] = Field(default_factory=list)
class AgentComposerCandidatesResponse(ResponseModel):
variant: ComposerVariant
allowed_node_job_candidates: AgentComposerNodeJobCandidatesResponse = Field(
default_factory=AgentComposerNodeJobCandidatesResponse
)
allowed_soul_candidates: AgentComposerSoulCandidatesResponse = Field(
default_factory=AgentComposerSoulCandidatesResponse
)
capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities)

View File

@ -343,19 +343,11 @@ Check if activation token is valid
### /agents
#### GET
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| keyword | query | | No | string |
| limit | query | | No | integer |
| page | query | | No | integer |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent roster list | [AgentRosterListResponse](#agentrosterlistresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
#### POST
##### Parameters
@ -366,27 +358,18 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Agent created | [AgentRosterResponse](#agentrosterresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /agents/invite-options
#### GET
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | query | Workflow app id for in-current-workflow markers | No | string |
| keyword | query | | No | string |
| limit | query | | No | integer |
| page | query | | No | integer |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent invite options | [AgentInviteOptionsResponse](#agentinviteoptionsresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /agents/{agent_id}
@ -401,7 +384,7 @@ Check if activation token is valid
| Code | Description |
| ---- | ----------- |
| 204 | Agent archived |
| 200 | Success |
#### GET
##### Parameters
@ -412,9 +395,9 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent detail | [AgentRosterResponse](#agentrosterresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
#### PATCH
##### Parameters
@ -426,9 +409,9 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent updated | [AgentRosterResponse](#agentrosterresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /agents/{agent_id}/versions
@ -441,9 +424,9 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent versions | [AgentConfigSnapshotListResponse](#agentconfigsnapshotlistresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /agents/{agent_id}/versions/{version_id}
@ -457,9 +440,9 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent version detail | [AgentConfigSnapshotDetailResponse](#agentconfigsnapshotdetailresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /all-workspaces
@ -995,9 +978,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer state | [AgentAppComposerResponse](#agentappcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
#### PUT
##### Parameters
@ -1009,9 +992,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer saved | [AgentAppComposerResponse](#agentappcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/agent-composer/candidates
@ -1024,9 +1007,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/agent-composer/validate
@ -1040,9 +1023,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/agent/logs
@ -3241,9 +3224,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer state | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
#### PUT
##### Parameters
@ -3256,9 +3239,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer saved | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates
@ -3272,9 +3255,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact
@ -3285,13 +3268,12 @@ Run draft workflow loop node
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| node_id | path | | Yes | string |
| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer impact | [AgentComposerImpactResponse](#agentcomposerimpactresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster
@ -3306,9 +3288,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer saved to roster | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/validate
@ -3323,9 +3305,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run
@ -10669,150 +10651,6 @@ Get banner list
| model_mode | string | Model mode | Yes |
| model_name | string | Model name | Yes |
#### AgentAppComposerResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | Yes |
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| variant | string | | Yes |
#### AgentComposerAgentResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot_id | string | | No |
| description | string | | Yes |
| id | string | | Yes |
| name | string | | Yes |
| scope | [AgentScope](#agentscope) | | Yes |
| status | [AgentStatus](#agentstatus) | | Yes |
#### AgentComposerBindingResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | No |
| binding_type | [WorkflowAgentBindingType](#workflowagentbindingtype) | | Yes |
| current_snapshot_id | string | | No |
| id | string | | Yes |
| node_id | string | | Yes |
| workflow_id | string | | Yes |
#### AgentComposerCandidatesResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| allowed_node_job_candidates | [AgentComposerNodeJobCandidatesResponse](#agentcomposernodejobcandidatesresponse) | | No |
| allowed_soul_candidates | [AgentComposerSoulCandidatesResponse](#agentcomposersoulcandidatesresponse) | | No |
| capabilities | [ComposerCandidateCapabilities](#composercandidatecapabilities) | | No |
| variant | [ComposerVariant](#composervariant) | | Yes |
#### AgentComposerImpactBindingResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | | Yes |
| node_id | string | | Yes |
| workflow_id | string | | Yes |
#### AgentComposerImpactResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| bindings | [ [AgentComposerImpactBindingResponse](#agentcomposerimpactbindingresponse) ] | | No |
| current_snapshot_id | string | | No |
| workflow_node_count | integer | | Yes |
#### AgentComposerNodeJobCandidatesResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| declare_output_types | [ [DeclaredOutputType](#declaredoutputtype) ] | | No |
| human_contacts | [ object ] | | No |
| previous_node_outputs | [ object ] | | No |
#### AgentComposerSoulCandidatesResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| cli_tools | [ object ] | | No |
| dify_tools | [ object ] | | No |
| human_contacts | [ object ] | | No |
| knowledge_datasets | [ object ] | | No |
| skills_files | [ object ] | | No |
#### AgentComposerSoulLockResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| can_unlock | boolean | | No |
| locked | boolean | | Yes |
| reason | string | | No |
#### AgentComposerValidateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| errors | [ string ] | | No |
| result | string | | Yes |
#### AgentConfigRevisionOperation
Audit operation recorded for Agent Soul version/revision changes.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentConfigRevisionOperation | string | Audit operation recorded for Agent Soul version/revision changes. | |
#### AgentConfigRevisionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | string | | No |
| created_by | string | | No |
| current_snapshot_id | string | | Yes |
| id | string | | Yes |
| operation | [AgentConfigRevisionOperation](#agentconfigrevisionoperation) | | Yes |
| previous_snapshot_id | string | | No |
| revision | integer | | Yes |
| summary | string | | No |
| version_note | string | | No |
#### AgentConfigSnapshotDetailResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | No |
| config_snapshot | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| created_at | string | | No |
| created_by | string | | No |
| id | string | | Yes |
| revisions | [ [AgentConfigRevisionResponse](#agentconfigrevisionresponse) ] | | No |
| summary | string | | No |
| version | integer | | Yes |
| version_note | string | | No |
#### AgentConfigSnapshotListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) ] | | Yes |
#### AgentConfigSnapshotSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | No |
| created_at | string | | No |
| created_by | string | | No |
| id | string | | Yes |
| summary | string | | No |
| version | integer | | Yes |
| version_note | string | | No |
#### AgentIconType
Supported icon storage formats for Agent roster entries.
@ -10827,35 +10665,6 @@ Supported icon storage formats for Agent roster entries.
| ---- | ---- | ----------- | -------- |
| agent_id | string | | Yes |
#### AgentInviteOptionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| active_config_snapshot_id | string | | No |
| agent_kind | [AgentKind](#agentkind) | | Yes |
| app_id | string | | No |
| archived_at | string | | No |
| archived_by | string | | No |
| created_at | string | | No |
| created_by | string | | No |
| description | string | | Yes |
| existing_node_ids | [ string ] | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | [AgentIconType](#agenticontype) | | No |
| id | string | | Yes |
| in_current_workflow_count | integer | | No |
| is_in_current_workflow | boolean | | No |
| name | string | | Yes |
| scope | [AgentScope](#agentscope) | | Yes |
| source | [AgentSource](#agentsource) | | Yes |
| status | [AgentStatus](#agentstatus) | | Yes |
| updated_at | string | | No |
| updated_by | string | | No |
| workflow_id | string | | No |
| workflow_node_id | string | | No |
#### AgentInviteOptionsQuery
| Name | Type | Description | Required |
@ -10865,27 +10674,6 @@ Supported icon storage formats for Agent roster entries.
| limit | integer | | No |
| page | integer | | No |
#### AgentInviteOptionsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [AgentInviteOptionResponse](#agentinviteoptionresponse) ] | | Yes |
| has_more | boolean | | Yes |
| limit | integer | | Yes |
| page | integer | | Yes |
| total | integer | | Yes |
#### AgentKind
Agent implementation family.
This leaves room for future non-Dify agent implementations while keeping
the current roster/workflow APIs scoped to Dify Agent.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentKind | string | Agent implementation family. This leaves room for future non-Dify agent implementations while keeping the current roster/workflow APIs scoped to Dify Agent. | |
#### AgentKnowledgeQueryMode
| Name | Type | Description | Required |
@ -10899,50 +10687,6 @@ the current roster/workflow APIs scoped to Dify Agent.
| conversation_id | string | Conversation UUID | Yes |
| message_id | string | Message UUID | Yes |
#### AgentRosterListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [AgentRosterResponse](#agentrosterresponse) ] | | Yes |
| has_more | boolean | | Yes |
| limit | integer | | Yes |
| page | integer | | Yes |
| total | integer | | Yes |
#### AgentRosterResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| active_config_snapshot_id | string | | No |
| agent_kind | [AgentKind](#agentkind) | | Yes |
| app_id | string | | No |
| archived_at | string | | No |
| archived_by | string | | No |
| created_at | string | | No |
| created_by | string | | No |
| description | string | | Yes |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | [AgentIconType](#agenticontype) | | No |
| id | string | | Yes |
| name | string | | Yes |
| scope | [AgentScope](#agentscope) | | Yes |
| source | [AgentSource](#agentsource) | | Yes |
| status | [AgentStatus](#agentstatus) | | Yes |
| updated_at | string | | No |
| updated_by | string | | No |
| workflow_id | string | | No |
| workflow_node_id | string | | No |
#### AgentScope
Visibility and lifecycle scope of an Agent record.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentScope | string | Visibility and lifecycle scope of an Agent record. | |
#### AgentSoulConfig
| Name | Type | Description | Required |
@ -11077,22 +10821,6 @@ Reference to model credentials resolved only at runtime.
| cli_tools | [ object ] | | No |
| dify_tools | [ [AgentSoulDifyToolConfig](#agentsouldifytoolconfig) ] | | No |
#### AgentSource
Origin that created or imported the Agent.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentSource | string | Origin that created or imported the Agent. | |
#### AgentStatus
Soft lifecycle state for Agent records.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentStatus | string | Soft lifecycle state for Agent records. | |
#### AgentThought
| Name | Type | Description | Required |
@ -11800,12 +11528,6 @@ Button styles for user actions.
| binding_type | string | *Enum:* `"inline_agent"`, `"roster_agent"` | Yes |
| current_snapshot_id | string | | No |
#### ComposerCandidateCapabilities
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| human_roster_available | boolean | | No |
#### ComposerSavePayload
| Name | Type | Description | Required |
@ -15647,32 +15369,6 @@ in form definiton, or a variable while the workflow is running.
| embedding_provider_name | string | | Yes |
| vector_weight | number | | Yes |
#### WorkflowAgentBindingType
How a workflow node is bound to an Agent.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| WorkflowAgentBindingType | string | How a workflow node is bound to an Agent. | |
#### WorkflowAgentComposerResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | No |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| app_id | string | | No |
| binding | [AgentComposerBindingResponse](#agentcomposerbindingresponse) | | No |
| effective_declared_outputs | [ [DeclaredOutputConfig](#declaredoutputconfig) ] | | No |
| impact_summary | [AgentComposerImpactResponse](#agentcomposerimpactresponse) | | No |
| node_id | string | | No |
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| soul_lock | [AgentComposerSoulLockResponse](#agentcomposersoullockresponse) | | Yes |
| variant | string | | Yes |
| workflow_id | string | | No |
#### WorkflowAppLogPaginationResponse
| Name | Type | Description | Required |

View File

@ -505,7 +505,7 @@ def _truncate_container_database(app: Flask) -> None:
session_factory-created sessions. Truncating after each test gives the suite
a central DB isolation contract that does not depend on which session a test used.
This only covers SQLAlchemy application tables in db.metadata for now;
object storage and custom ad hoc metadata still need their own cleanup.
Redis, object storage, and custom ad hoc metadata still need their own cleanup.
"""
with app.app_context():
db.session.remove()
@ -524,27 +524,13 @@ def _truncate_container_database(app: Flask) -> None:
db.session.remove()
def _flush_container_redis(app: Flask) -> None:
"""
Reset Redis after a container integration test.
Tests in this package share one Redis container for performance. Application
code stores temporary tokens, rate-limit counters, locks, and cache entries
there, so flushing after each test gives Redis-backed state the same
isolation contract as the PostgreSQL container.
"""
with app.app_context():
app.extensions["redis"].flushdb()
@pytest.fixture(autouse=True)
def isolate_container_database(request: pytest.FixtureRequest) -> Generator[None, None, None]:
"""
Clean DB and Redis state after tests that use the containerized Flask app.
Clean DB state after tests that use the containerized Flask app.
This fixture intentionally does not depend on flask_app_with_containers so
tests under this package do not start the full app/container stack just to
run state cleanup.
non-DB tests under this package do not start the full app/container stack.
"""
yield
@ -552,10 +538,7 @@ def isolate_container_database(request: pytest.FixtureRequest) -> Generator[None
return
app = request.getfixturevalue("flask_app_with_containers")
try:
_truncate_container_database(app)
finally:
_flush_container_redis(app)
_truncate_container_database(app)
@pytest.fixture(scope="package", autouse=True)

View File

@ -1,182 +0,0 @@
preset = "strict"
project-includes = ["."]
search-path = ["../.."]
# Verify project-excludes from the repo root:
# tmp_config=$(mktemp --tmpdir=api/tests/test_containers_integration_tests pyrefly-no-excludes.XXXXXX.toml)
# awk 'BEGIN {skip=0} /^project-excludes = \[/ {skip=1; next} skip && /^\]/ {skip=0; next} !skip {print}' api/tests/test_containers_integration_tests/pyrefly.toml > "$tmp_config"
# tmp_name=$(basename "$tmp_config")
# comm -3 <(sed -n 's/^ "\(.*\)",$/\1/p' api/tests/test_containers_integration_tests/pyrefly.toml | sort) <(uv --directory api run pyrefly check --config "tests/test_containers_integration_tests/$tmp_name" --summary=none --output-format=min-text 2>/dev/null | rg '^ERROR ' | sed -E 's#^ERROR (tests/test_containers_integration_tests/[^:]+):.*#\1#' | sed 's#^tests/test_containers_integration_tests/##' | sort -u)
# rm --force "$tmp_config"
project-excludes = [
"commands/test_legacy_model_type_migration.py",
"conftest.py",
"controllers/console/app/test_app_apis.py",
"controllers/console/app/test_app_import_api.py",
"controllers/console/app/test_chat_conversation_status_count_api.py",
"controllers/console/app/test_conversation_read_timestamp.py",
"controllers/console/app/test_workflow_draft_variable.py",
"controllers/console/auth/test_email_register.py",
"controllers/console/auth/test_forgot_password.py",
"controllers/console/auth/test_oauth.py",
"controllers/console/auth/test_password_reset.py",
"controllers/console/datasets/rag_pipeline/test_rag_pipeline.py",
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py",
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py",
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py",
"controllers/console/datasets/test_data_source.py",
"controllers/console/explore/test_conversation.py",
"controllers/console/test_api_based_extension.py",
"controllers/console/test_apikey.py",
"controllers/console/workspace/test_members.py",
"controllers/console/workspace/test_tool_provider.py",
"controllers/console/workspace/test_trigger_providers.py",
"controllers/console/workspace/test_workspace_wraps.py",
"controllers/mcp/test_mcp.py",
"controllers/service_api/dataset/test_dataset.py",
"controllers/service_api/test_site.py",
"controllers/web/test_conversation.py",
"controllers/web/test_site.py",
"controllers/web/test_web_forgot_password.py",
"controllers/web/test_wraps.py",
"core/app/layers/test_pause_state_persist_layer.py",
"core/rag/pipeline/test_queue_integration.py",
"core/rag/retrieval/test_dataset_retrieval_integration.py",
"core/workflow/test_human_input_resume_node_execution.py",
"factories/test_storage_key_loader.py",
"helpers/__init__.py",
"helpers/execution_extra_content.py",
"libs/broadcast_channel/redis/test_channel.py",
"libs/broadcast_channel/redis/test_sharded_channel.py",
"libs/broadcast_channel/redis/test_streams_channel.py",
"libs/test_auto_renew_redis_lock_integration.py",
"libs/test_rate_limiter_integration.py",
"models/test_account.py",
"models/test_conversation_message_inputs.py",
"models/test_types_enum_text.py",
"repositories/test_sqlalchemy_api_workflow_node_execution_repository.py",
"repositories/test_sqlalchemy_api_workflow_run_repository.py",
"repositories/test_sqlalchemy_execution_extra_content_repository.py",
"repositories/test_sqlalchemy_workflow_node_execution_repository.py",
"repositories/test_workflow_run_repository.py",
"services/auth/test_api_key_auth_service.py",
"services/auth/test_auth_integration.py",
"services/dataset_collection_binding.py",
"services/dataset_service_update_delete.py",
"services/document_service_status.py",
"services/enterprise/test_account_deletion_sync.py",
"services/plugin/test_plugin_parameter_service.py",
"services/plugin/test_plugin_permission_service.py",
"services/plugin/test_plugin_service.py",
"services/rag_pipeline/test_rag_pipeline_service_db.py",
"services/recommend_app/test_database_retrieval.py",
"services/test_account_service.py",
"services/test_advanced_prompt_template_service.py",
"services/test_agent_service.py",
"services/test_annotation_service.py",
"services/test_api_based_extension_service.py",
"services/test_api_token_service.py",
"services/test_app_dsl_service.py",
"services/test_app_generate_service.py",
"services/test_app_service.py",
"services/test_attachment_service.py",
"services/test_audio_service_db.py",
"services/test_billing_service.py",
"services/test_conversation_service.py",
"services/test_conversation_service_variables.py",
"services/test_conversation_variable_updater.py",
"services/test_credit_pool_service.py",
"services/test_dataset_permission_service.py",
"services/test_dataset_service.py",
"services/test_dataset_service_batch_update_document_status.py",
"services/test_dataset_service_create_dataset.py",
"services/test_dataset_service_delete_dataset.py",
"services/test_dataset_service_document.py",
"services/test_dataset_service_get_segments.py",
"services/test_dataset_service_permissions.py",
"services/test_dataset_service_retrieval.py",
"services/test_dataset_service_update_dataset.py",
"services/test_delete_archived_workflow_run.py",
"services/test_document_service_display_status.py",
"services/test_document_service_rename_document.py",
"services/test_end_user_service.py",
"services/test_feature_service.py",
"services/test_feedback_service.py",
"services/test_file_service.py",
"services/test_hit_testing_service.py",
"services/test_human_input_delivery_test.py",
"services/test_human_input_delivery_test_service.py",
"services/test_message_export_service.py",
"services/test_message_service.py",
"services/test_message_service_execution_extra_content.py",
"services/test_message_service_extra_contents.py",
"services/test_messages_clean_service.py",
"services/test_metadata_partial_update.py",
"services/test_metadata_service.py",
"services/test_model_load_balancing_service.py",
"services/test_model_provider_service.py",
"services/test_oauth_server_service.py",
"services/test_ops_service.py",
"services/test_recommended_app_service.py",
"services/test_restore_archived_workflow_run.py",
"services/test_saved_message_service.py",
"services/test_schedule_service.py",
"services/test_tag_service.py",
"services/test_trigger_provider_service.py",
"services/test_web_conversation_service.py",
"services/test_webapp_auth_service.py",
"services/test_webhook_service.py",
"services/test_webhook_service_relationships.py",
"services/test_workflow_app_service.py",
"services/test_workflow_draft_variable_service.py",
"services/test_workflow_run_service.py",
"services/test_workflow_service.py",
"services/test_workspace_service.py",
"services/tools/test_api_tools_manage_service.py",
"services/tools/test_mcp_tools_manage_service.py",
"services/tools/test_tools_transform_service.py",
"services/tools/test_workflow_tools_manage_service.py",
"services/workflow/test_workflow_converter.py",
"services/workflow/test_workflow_deletion.py",
"services/workflow/test_workflow_node_execution_service_repository.py",
"tasks/test_add_document_to_index_task.py",
"tasks/test_batch_clean_document_task.py",
"tasks/test_batch_create_segment_to_index_task.py",
"tasks/test_clean_dataset_task.py",
"tasks/test_clean_notion_document_task.py",
"tasks/test_create_segment_to_index_task.py",
"tasks/test_dataset_indexing_task.py",
"tasks/test_deal_dataset_vector_index_task.py",
"tasks/test_delete_account_task.py",
"tasks/test_delete_segment_from_index_task.py",
"tasks/test_disable_segment_from_index_task.py",
"tasks/test_disable_segments_from_index_task.py",
"tasks/test_document_indexing_sync_task.py",
"tasks/test_document_indexing_task.py",
"tasks/test_document_indexing_update_task.py",
"tasks/test_duplicate_document_indexing_task.py",
"tasks/test_enable_segments_to_index_task.py",
"tasks/test_mail_account_deletion_task.py",
"tasks/test_mail_change_mail_task.py",
"tasks/test_mail_email_code_login_task.py",
"tasks/test_mail_human_input_delivery_task.py",
"tasks/test_mail_inner_task.py",
"tasks/test_mail_invite_member_task.py",
"tasks/test_mail_owner_transfer_task.py",
"tasks/test_mail_register_task.py",
"tasks/test_rag_pipeline_run_tasks.py",
"tasks/test_remove_app_and_related_data_task.py",
"test_container_state_isolation.py",
"test_opendal_fs_default_root.py",
"test_workflow_pause_integration.py",
"trigger/conftest.py",
"trigger/test_trigger_e2e.py",
"workflow/nodes/code_executor/test_code_executor.py",
"workflow/nodes/code_executor/test_code_javascript.py",
"workflow/nodes/code_executor/test_code_jinja2.py",
"workflow/nodes/code_executor/test_code_python3.py",
"workflow/nodes/code_executor/test_utils.py",
]
[errors]
unannotated-return = true

View File

@ -1,39 +0,0 @@
from __future__ import annotations
from uuid import uuid4
from extensions.ext_redis import redis_client
from models.account import Account
ACCOUNT_EMAIL = f"container-state-isolation-{uuid4()}@example.com"
REDIS_KEY = f"container-state-isolation:{uuid4()}"
def test_1_container_state_can_be_written(
flask_app_with_containers,
db_session_with_containers,
) -> None:
account = Account(
name="Container State Isolation",
email=ACCOUNT_EMAIL,
password="hashed-password",
password_salt="salt",
interface_language="en-US",
timezone="UTC",
)
db_session_with_containers.add(account)
db_session_with_containers.commit()
with flask_app_with_containers.app_context():
redis_client.set(REDIS_KEY, "leaked")
assert redis_client.get(REDIS_KEY) == b"leaked"
def test_2_container_state_is_flushed_between_tests(
flask_app_with_containers,
db_session_with_containers,
) -> None:
assert db_session_with_containers.query(Account).filter_by(email=ACCOUNT_EMAIL).one_or_none() is None
with flask_app_with_containers.app_context():
assert redis_client.get(REDIS_KEY) is None

View File

@ -30,90 +30,6 @@ def _unwrap(method):
return method
def _agent_response(agent_id: str = "agent-1") -> dict:
return {
"id": agent_id,
"name": "Analyst",
"description": "",
"icon_type": None,
"icon": None,
"icon_background": None,
"agent_kind": "dify_agent",
"scope": "roster",
"source": "agent_app",
"app_id": None,
"workflow_id": None,
"workflow_node_id": None,
"active_config_snapshot_id": "version-1",
"active_config_snapshot": _version_response(),
"status": "active",
"created_by": "account-1",
"updated_by": "account-1",
"archived_by": None,
"archived_at": None,
"created_at": None,
"updated_at": None,
}
def _version_response(version_id: str = "version-1") -> dict:
return {
"id": version_id,
"agent_id": "agent-1",
"version": 1,
"summary": None,
"version_note": None,
"created_by": "account-1",
"created_at": None,
}
def _workflow_composer_response(**overrides) -> dict:
response = {
"variant": "workflow",
"agent": None,
"active_config_snapshot": None,
"binding": None,
"soul_lock": {"locked": False, "can_unlock": False, "reason": "workflow_only_empty"},
"agent_soul": {},
"node_job": {},
"effective_declared_outputs": [],
"save_options": ["node_job_only"],
"impact_summary": None,
"app_id": "app-1",
"workflow_id": "workflow-1",
"node_id": "node-1",
}
response.update(overrides)
return response
def _agent_app_composer_response() -> dict:
return {
"variant": "agent_app",
"agent": {
"id": "agent-1",
"name": "Analyst",
"description": "",
"scope": "roster",
"status": "active",
"active_config_snapshot_id": "version-1",
},
"active_config_snapshot": _version_response(),
"agent_soul": {},
"save_options": ["save_to_current_version", "save_as_new_version"],
}
def _candidates_response(variant: str) -> dict:
return {
"variant": variant,
"allowed_node_job_candidates": {},
"allowed_soul_candidates": {},
"capabilities": {"human_roster_available": False},
}
@pytest.fixture
def account():
return SimpleNamespace(id="account-1")
@ -151,15 +67,14 @@ def test_roster_list_post_creates_agent_and_returns_detail(app, monkeypatch):
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_roster_agent_detail",
lambda _self, **kwargs: _agent_response(kwargs["agent_id"]),
lambda _self, **kwargs: {"id": kwargs["agent_id"], "tenant_id": kwargs["tenant_id"]},
)
with app.test_request_context(json={"name": "Analyst", "agent_soul": {"prompt": {"system_prompt": "x"}}}):
result, status = _unwrap(AgentRosterListApi.post)(AgentRosterListApi())
assert status == 201
assert result["id"] == "agent-1"
assert result["agent_kind"] == "dify_agent"
assert result == {"id": "agent-1", "tenant_id": "tenant-1"}
def test_invite_options_get_parses_app_id(app, monkeypatch):
@ -167,14 +82,14 @@ def test_invite_options_get_parses_app_id(app, monkeypatch):
def list_invite_options(_self, **kwargs):
captured.update(kwargs)
return {"data": [], "page": kwargs["page"], "limit": kwargs["limit"], "total": 0, "has_more": False}
return {"data": []}
monkeypatch.setattr(roster_controller.AgentRosterService, "list_invite_options", list_invite_options)
with app.test_request_context("/console/api/agents/invite-options?page=1&limit=10&app_id=app-1"):
result = _unwrap(AgentInviteOptionsApi.get)(AgentInviteOptionsApi())
assert result == {"data": [], "page": 1, "limit": 10, "total": 0, "has_more": False}
assert result == {"data": []}
assert captured == {"tenant_id": "tenant-1", "page": 1, "limit": 10, "keyword": None, "app_id": "app-1"}
@ -185,12 +100,12 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_roster_agent_detail",
lambda _self, **kwargs: _agent_response(kwargs["agent_id"]),
lambda _self, **kwargs: {"id": kwargs["agent_id"]},
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"update_roster_agent",
lambda _self, **kwargs: {**_agent_response(kwargs["agent_id"]), "description": kwargs["payload"].description},
lambda _self, **kwargs: {"id": kwargs["agent_id"], "description": kwargs["payload"].description},
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
@ -200,29 +115,12 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"list_agent_versions",
lambda _self, **kwargs: [_version_response()],
lambda _self, **kwargs: [{"id": "version-1"}],
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_agent_version_detail",
lambda _self, **kwargs: {
**_version_response(kwargs["version_id"]),
"agent_id": kwargs["agent_id"],
"config_snapshot": {},
"revisions": [
{
"id": "revision-1",
"previous_snapshot_id": None,
"current_snapshot_id": kwargs["version_id"],
"revision": 1,
"operation": "create_version",
"summary": None,
"version_note": None,
"created_by": "account-1",
"created_at": None,
}
],
},
lambda _self, **kwargs: {"id": kwargs["version_id"], "agent_id": kwargs["agent_id"]},
)
assert _unwrap(AgentRosterDetailApi.get)(AgentRosterDetailApi(), agent_id)["id"] == agent_id
@ -230,10 +128,11 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
assert _unwrap(AgentRosterDetailApi.patch)(AgentRosterDetailApi(), agent_id)["description"] == "updated"
assert _unwrap(AgentRosterDetailApi.delete)(AgentRosterDetailApi(), agent_id) == ("", 204)
assert archived["account_id"] == "account-1"
assert _unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), agent_id)["data"][0]["id"] == "version-1"
version_detail = _unwrap(AgentRosterVersionDetailApi.get)(AgentRosterVersionDetailApi(), agent_id, version_id)
assert version_detail["id"] == version_id
assert version_detail["agent_id"] == agent_id
assert _unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), agent_id) == {"data": [{"id": "version-1"}]}
assert _unwrap(AgentRosterVersionDetailApi.get)(AgentRosterVersionDetailApi(), agent_id, version_id) == {
"id": version_id,
"agent_id": agent_id,
}
def test_workflow_composer_get_put_validate_candidates_impact_and_save(app, monkeypatch):
@ -246,52 +145,50 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(app, monk
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_workflow_composer",
lambda **kwargs: _workflow_composer_response(node_id=kwargs["node_id"]),
lambda **kwargs: {"node_id": kwargs["node_id"]},
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"save_workflow_composer",
lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]),
lambda **kwargs: {"saved": kwargs["payload"].save_strategy.value, "account_id": kwargs["account_id"]},
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_workflow_candidates",
lambda **kwargs: _candidates_response("workflow"),
lambda **kwargs: {"data": []},
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"calculate_impact",
lambda **kwargs: {
"current_snapshot_id": kwargs["current_snapshot_id"],
"workflow_node_count": 1,
"bindings": [],
},
lambda **kwargs: {"current_snapshot_id": kwargs["current_snapshot_id"], "workflow_node_count": 1},
)
workflow_state = _unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), app_model, "node-1")
assert workflow_state["node_id"] == "node-1"
assert _unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), app_model, "node-1") == {
"node_id": "node-1"
}
with app.test_request_context(json=payload):
saved_state = _unwrap(WorkflowAgentComposerApi.put)(WorkflowAgentComposerApi(), app_model, "node-1")
assert saved_state["save_options"] == ["node_job_only"]
assert _unwrap(WorkflowAgentComposerApi.put)(WorkflowAgentComposerApi(), app_model, "node-1") == {
"saved": "node_job_only",
"account_id": "account-1",
}
assert _unwrap(WorkflowAgentComposerValidateApi.post)(
WorkflowAgentComposerValidateApi(), app_model, "node-1"
) == {"result": "success", "errors": []}
assert (
_unwrap(WorkflowAgentComposerCandidatesApi.get)(WorkflowAgentComposerCandidatesApi(), app_model, "node-1")[
"variant"
]
== "workflow"
)
assert _unwrap(WorkflowAgentComposerCandidatesApi.get)(
WorkflowAgentComposerCandidatesApi(), app_model, "node-1"
) == {"data": []}
with app.test_request_context(json=payload):
assert _unwrap(WorkflowAgentComposerImpactApi.post)(WorkflowAgentComposerImpactApi(), app_model, "node-1") == {
"current_snapshot_id": "version-1",
"workflow_node_count": 1,
"bindings": [],
}
assert _unwrap(WorkflowAgentComposerSaveToRosterApi.post)(
WorkflowAgentComposerSaveToRosterApi(), app_model, "node-1"
)["save_options"] == ["node_job_only"]
assert (
_unwrap(WorkflowAgentComposerSaveToRosterApi.post)(
WorkflowAgentComposerSaveToRosterApi(), app_model, "node-1"
)["saved"]
== "node_job_only"
)
def test_workflow_impact_returns_empty_without_version(app):
@ -315,26 +212,28 @@ def test_agent_app_composer_get_put_validate_and_candidates(app, monkeypatch):
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_agent_app_composer",
lambda **kwargs: _agent_app_composer_response(),
lambda **kwargs: {"loaded": True},
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"save_agent_app_composer",
lambda **kwargs: _agent_app_composer_response(),
lambda **kwargs: {"saved": kwargs["payload"].variant.value, "account_id": kwargs["account_id"]},
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_agent_app_candidates",
lambda **kwargs: _candidates_response("agent_app"),
lambda **kwargs: {"data": []},
)
assert _unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), app_model)["variant"] == "agent_app"
assert _unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), app_model) == {"loaded": True}
with app.test_request_context(json=payload):
assert _unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), app_model)["variant"] == "agent_app"
assert _unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), app_model) == {
"saved": "agent_app",
"account_id": "account-1",
}
assert _unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), app_model) == {
"result": "success",
"errors": [],
}
agent_app_candidates = _unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model)
assert agent_app_candidates["variant"] == "agent_app"
assert _unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model) == {"data": []}

View File

@ -6,8 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startMock } from '../../test/fixtures/dify-mock/server.js'
import { loadAppInfoCache } from '../cache/app-info.js'
import { createClient } from '../http/client.js'
import { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../store/manager.js'
import { CACHE_APP_INFO, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { FieldInfo, FieldParameters } from '../types/app-meta.js'
import { AppMetaClient } from './app-meta.js'
import { AppsClient } from './apps.js'
@ -15,24 +15,17 @@ 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 cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' }))
const spy = vi.spyOn(apps, 'describe')
const client = new AppMetaClient({ apps, host: mock.url, cache })
@ -47,7 +40,7 @@ describe('AppMetaClient', () => {
})
it('slim hit + full request triggers fresh fetch + merges', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' }))
const spy = vi.spyOn(apps, 'describe')
const client = new AppMetaClient({ apps, host: mock.url, cache })
@ -61,7 +54,7 @@ describe('AppMetaClient', () => {
})
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 cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') })
const apps = new AppsClient(createClient({ host: mock.url, bearer: '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') })
@ -75,7 +68,7 @@ describe('AppMetaClient', () => {
})
it('invalidate forces next get to fetch', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' }))
const spy = vi.spyOn(apps, 'describe')
const client = new AppMetaClient({ apps, host: mock.url, cache })

View File

@ -0,0 +1,101 @@
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { FILE_PERM } from '../store/dir.js'
import { FileBackend, TOKENS_FILE_NAME } from './file-backend.js'
describe('FileBackend', () => {
let dir: string
let backend: FileBackend
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-tokens-'))
backend = new FileBackend(dir)
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
it('returns undefined when file is missing', async () => {
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
})
it('returns empty list when file is missing', async () => {
expect(await backend.list('cloud.dify.ai')).toEqual([])
})
it('round-trips put/get for a single token', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_abc')
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_abc')
})
it('list returns accountIds for the given host', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
await backend.put('cloud.dify.ai', 'acct-2', 'dfoa_b')
await backend.put('self.example.com', 'acct-3', 'dfoa_c')
const ids = await backend.list('cloud.dify.ai')
expect([...ids].sort()).toEqual(['acct-1', 'acct-2'])
})
it('list returns empty array for unknown host', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
expect(await backend.list('other.example.com')).toEqual([])
})
it('delete removes the entry', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
await backend.delete('cloud.dify.ai', 'acct-1')
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
})
it('delete is a no-op for missing entries', async () => {
await expect(backend.delete('cloud.dify.ai', 'missing')).resolves.toBeUndefined()
})
it('delete prunes empty host entries', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
await backend.delete('cloud.dify.ai', 'acct-1')
expect(await backend.list('cloud.dify.ai')).toEqual([])
})
it('overwrites existing token for same host+accountId', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_old')
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_new')
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_new')
})
it('writes file with mode 0600', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
const info = await stat(join(dir, TOKENS_FILE_NAME))
expect(info.mode & 0o777).toBe(FILE_PERM)
})
it('rewrites existing file with mode 0600 even if previously permissive', async () => {
const path = join(dir, TOKENS_FILE_NAME)
await writeFile(path, 'hosts: {}\n', { mode: 0o644 })
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
const info = await stat(path)
expect(info.mode & 0o777).toBe(FILE_PERM)
})
it('writes valid YAML readable by a fresh backend', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
const fresh = new FileBackend(dir)
expect(await fresh.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_a')
})
it('persists multiple hosts simultaneously', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
await backend.put('self.example.com', 'acct-2', 'dfoa_b')
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_a')
expect(await backend.get('self.example.com', 'acct-2')).toBe('dfoa_b')
})
it('treats malformed YAML as empty', async () => {
const path = join(dir, TOKENS_FILE_NAME)
await writeFile(path, 'not: valid: yaml: [\n', { mode: FILE_PERM })
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
})
})

View File

@ -0,0 +1,99 @@
import type { TokenStore } from './store.js'
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import yaml from 'js-yaml'
import { DIR_PERM, FILE_PERM } from '../store/dir.js'
export const TOKENS_FILE_NAME = 'tokens.yml'
type AccountMap = Record<string, string>
type HostMap = Record<string, AccountMap>
type TokensFile = { hosts?: HostMap }
export class FileBackend implements TokenStore {
private readonly dir: string
private readonly path: string
constructor(dir: string) {
this.dir = dir
this.path = join(dir, TOKENS_FILE_NAME)
}
async put(host: string, accountId: string, token: string): Promise<void> {
const file = await this.read()
const hosts = file.hosts ?? {}
const accounts = hosts[host] ?? {}
accounts[accountId] = token
hosts[host] = accounts
await this.write({ hosts })
}
async get(host: string, accountId: string): Promise<string | undefined> {
const file = await this.read()
return file.hosts?.[host]?.[accountId]
}
async delete(host: string, accountId: string): Promise<void> {
const file = await this.read()
const accounts = file.hosts?.[host]
if (accounts === undefined || !(accountId in accounts))
return
delete accounts[accountId]
if (Object.keys(accounts).length === 0 && file.hosts !== undefined)
delete file.hosts[host]
await this.write(file)
}
async list(host: string): Promise<readonly string[]> {
const file = await this.read()
const accounts = file.hosts?.[host]
return accounts === undefined ? [] : Object.keys(accounts)
}
private async read(): Promise<TokensFile> {
let raw: string
try {
raw = await readFile(this.path, 'utf8')
}
catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
return {}
throw err
}
let parsed: unknown
try {
parsed = yaml.load(raw)
}
catch {
return {}
}
if (parsed === null || typeof parsed !== 'object')
return {}
return parsed as TokensFile
}
private async write(file: TokensFile): Promise<void> {
await mkdir(this.dir, { recursive: true, mode: DIR_PERM })
const body = yaml.dump(file, { lineWidth: -1, noRefs: true })
const tmp = `${this.path}.tmp.${process.pid}.${Date.now()}`
try {
await writeFile(tmp, body, { mode: FILE_PERM })
await rename(tmp, this.path)
}
catch (err) {
try {
await unlink(tmp)
}
catch { /* tmp may not exist */ }
throw err
}
try {
const info = await stat(this.path)
if ((info.mode & 0o777) !== FILE_PERM) {
const { chmod } = await import('node:fs/promises')
await chmod(this.path, FILE_PERM)
}
}
catch { /* best-effort permission tighten */ }
}
}

View File

@ -1,9 +1,9 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ENV_CONFIG_DIR } from '../store/dir.js'
import { HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
import { FILE_PERM } from '../store/dir.js'
import { HOSTS_FILE_NAME, HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
describe('HostsBundleSchema', () => {
it('parses a minimal logged-out bundle', () => {
@ -46,86 +46,86 @@ describe('HostsBundleSchema', () => {
})
expect(parsed.available_workspaces).toHaveLength(2)
})
it('drops unknown top-level fields on parse', () => {
const parsed = HostsBundleSchema.parse({
current_host: 'cloud.dify.ai',
future_field: 42,
token_storage: 'file',
})
expect(parsed.current_host).toBe('cloud.dify.ai')
expect((parsed as Record<string, unknown>).future_field).toBeUndefined()
})
})
describe('loadHosts/saveHosts', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
})
it('returns undefined when nothing was saved', () => {
expect(loadHosts()).toBeUndefined()
it('returns undefined when file is missing', async () => {
expect(await loadHosts(dir)).toBeUndefined()
})
it('round-trips a fully-populated bundle', () => {
saveHosts({
it('round-trips bundle through YAML', async () => {
await saveHosts(dir, {
current_host: 'cloud.dify.ai',
scheme: 'https',
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'My Space', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
token_storage: 'keychain',
token_id: 'tok_xyz',
})
const loaded = loadHosts()
const loaded = await loadHosts(dir)
expect(loaded?.current_host).toBe('cloud.dify.ai')
expect(loaded?.scheme).toBe('https')
expect(loaded?.account?.email).toBe('a@b.c')
expect(loaded?.workspace?.id).toBe('ws-1')
expect(loaded?.available_workspaces).toHaveLength(2)
expect(loaded?.token_storage).toBe('keychain')
expect(loaded?.token_id).toBe('tok_xyz')
})
it('round-trips a file-mode bundle with bearer token', () => {
saveHosts({
current_host: 'self.example.com',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
})
const loaded = loadHosts()
expect(loaded?.tokens?.bearer).toBe('dfoa_test')
expect(loaded?.token_storage).toBe('file')
})
it('overwrites previous bundle on save', () => {
saveHosts({ current_host: 'old.example.com', token_storage: 'file' })
saveHosts({ current_host: 'new.example.com', token_storage: 'keychain' })
const loaded = loadHosts()
expect(loaded?.current_host).toBe('new.example.com')
expect(loaded?.token_storage).toBe('keychain')
})
it('rejects invalid input at save time', () => {
expect(() => saveHosts({
it('writes file with mode 0600', async () => {
await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' })
const info = await stat(join(dir, HOSTS_FILE_NAME))
expect(info.mode & 0o777).toBe(FILE_PERM)
})
it('rewrites permissive existing file with mode 0600', async () => {
const path = join(dir, HOSTS_FILE_NAME)
await writeFile(path, 'current_host: ""\ntoken_storage: file\n', { mode: 0o644 })
await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' })
const info = await stat(path)
expect(info.mode & 0o777).toBe(FILE_PERM)
})
it('atomic write: temp file does not survive on success', async () => {
await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' })
const { readdir } = await import('node:fs/promises')
const entries = await readdir(dir)
expect(entries.filter(n => n.includes('.tmp.'))).toHaveLength(0)
})
it('drops unknown top-level fields', async () => {
const path = join(dir, HOSTS_FILE_NAME)
await writeFile(path, 'current_host: cloud.dify.ai\nfuture_field: 42\ntoken_storage: file\n', { mode: FILE_PERM })
const loaded = await loadHosts(dir)
expect(loaded?.current_host).toBe('cloud.dify.ai')
expect((loaded as Record<string, unknown> | undefined)?.future_field).toBeUndefined()
})
it('throws on malformed YAML', async () => {
const path = join(dir, HOSTS_FILE_NAME)
await writeFile(path, ': : :\n', { mode: FILE_PERM })
await expect(loadHosts(dir)).rejects.toThrow()
})
it('throws when YAML contradicts schema', async () => {
const path = join(dir, HOSTS_FILE_NAME)
await writeFile(path, 'token_storage: cloud\n', { mode: FILE_PERM })
await expect(loadHosts(dir)).rejects.toThrow()
})
it('produces YAML with stable keys', async () => {
await saveHosts(dir, {
current_host: 'cloud.dify.ai',
token_storage: 'cloud',
} as never)).toThrow()
token_storage: 'file',
tokens: { bearer: 'dfoa_x' },
})
const raw = await readFile(join(dir, HOSTS_FILE_NAME), 'utf8')
expect(raw).toContain('current_host: cloud.dify.ai')
expect(raw).toContain('bearer: dfoa_x')
})
})

View File

@ -1,6 +1,10 @@
import type { Store } from '../store/store.js'
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import yaml from 'js-yaml'
import { z } from 'zod'
import { getHostStore, tokenKey } from '../store/manager.js'
import { DIR_PERM, FILE_PERM } from '../store/dir.js'
export const HOSTS_FILE_NAME = 'hosts.yml'
const StorageModeSchema = z.enum(['keychain', 'file'])
export type StorageMode = z.infer<typeof StorageModeSchema>
@ -44,23 +48,53 @@ export const HostsBundleSchema = z.object({
})
export type HostsBundle = z.infer<typeof HostsBundleSchema>
export function loadHosts(): HostsBundle | undefined {
const raw = getHostStore().getTyped<Record<string, unknown>>()
if (raw === null)
return undefined
return HostsBundleSchema.parse(raw)
}
export function saveHosts(bundle: HostsBundle): void {
const validated = HostsBundleSchema.parse(bundle)
getHostStore().setTyped(validated)
}
export function clearLocal(bundle: HostsBundle, store: Store): void {
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
export async function loadHosts(dir: string): Promise<HostsBundle | undefined> {
const path = join(dir, HOSTS_FILE_NAME)
let raw: string
try {
store.unset(tokenKey(bundle.current_host, accountId))
raw = await readFile(path, 'utf8')
}
catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
return undefined
throw err
}
const parsed = yaml.load(raw)
return HostsBundleSchema.parse(parsed ?? {})
}
export async function saveHosts(dir: string, bundle: HostsBundle): Promise<void> {
await mkdir(dir, { recursive: true, mode: DIR_PERM })
const validated = HostsBundleSchema.parse(bundle)
const body = yaml.dump(stripUndefined(validated), { lineWidth: -1, noRefs: true, sortKeys: false })
const target = join(dir, HOSTS_FILE_NAME)
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`
try {
await writeFile(tmp, body, { mode: FILE_PERM })
await rename(tmp, target)
}
catch (err) {
try {
await unlink(tmp)
}
catch { /* tmp may not exist */ }
throw err
}
const { chmod, stat } = await import('node:fs/promises')
try {
const info = await stat(target)
if ((info.mode & 0o777) !== FILE_PERM)
await chmod(target, FILE_PERM)
}
catch { /* best-effort */ }
getHostStore().rm()
}
function stripUndefined<T extends Record<string, unknown>>(input: T): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(input)) {
if (v === undefined)
continue
out[k] = v
}
return out
}

View File

@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const passwords = new Map<string, string>()
const setPassword = vi.fn()
const getPassword = vi.fn()
const deletePassword = vi.fn()
class FakeAsyncEntry {
private readonly key: string
constructor(service: string, username: string) {
this.key = `${service}::${username}`
}
async setPassword(value: string): Promise<void> {
setPassword(this.key, value)
passwords.set(this.key, value)
}
async getPassword(): Promise<string | undefined> {
getPassword(this.key)
return passwords.get(this.key)
}
async deletePassword(): Promise<boolean> {
deletePassword(this.key)
if (!passwords.has(this.key))
return false
passwords.delete(this.key)
return true
}
}
vi.mock('@napi-rs/keyring', () => ({
AsyncEntry: FakeAsyncEntry,
}))
const { KEYRING_SERVICE, KeyringBackend } = await import('./keyring-backend.js')
beforeEach(() => {
passwords.clear()
setPassword.mockClear()
getPassword.mockClear()
deletePassword.mockClear()
})
describe('KeyringBackend', () => {
it('uses service name "difyctl"', () => {
expect(KEYRING_SERVICE).toBe('difyctl')
})
it('returns undefined when no password is stored', async () => {
const k = new KeyringBackend()
expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
})
it('round-trips put/get', async () => {
const k = new KeyringBackend()
await k.put('cloud.dify.ai', 'acct-1', 'dfoa_x')
expect(await k.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_x')
})
it('keys by host::accountId', async () => {
const k = new KeyringBackend()
await k.put('cloud.dify.ai', 'acct-1', 'A')
await k.put('cloud.dify.ai', 'acct-2', 'B')
expect(await k.get('cloud.dify.ai', 'acct-1')).toBe('A')
expect(await k.get('cloud.dify.ai', 'acct-2')).toBe('B')
})
it('delete removes the entry', async () => {
const k = new KeyringBackend()
await k.put('cloud.dify.ai', 'acct-1', 'A')
await k.delete('cloud.dify.ai', 'acct-1')
expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
})
it('delete is a no-op for missing entries', async () => {
const k = new KeyringBackend()
await expect(k.delete('cloud.dify.ai', 'gone')).resolves.toBeUndefined()
})
it('list returns empty array (keyring does not enumerate)', async () => {
const k = new KeyringBackend()
await k.put('cloud.dify.ai', 'acct-1', 'A')
expect(await k.list('cloud.dify.ai')).toEqual([])
})
it('swallows getPassword exceptions and returns undefined', async () => {
const k = new KeyringBackend()
getPassword.mockImplementationOnce(() => {
throw new Error('NoEntry')
})
expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
})
it('swallows delete exceptions', async () => {
const k = new KeyringBackend()
deletePassword.mockImplementationOnce(() => {
throw new Error('NoEntry')
})
await expect(k.delete('cloud.dify.ai', 'acct-1')).resolves.toBeUndefined()
})
it('lets put propagate exceptions (caller decides fallback)', async () => {
const k = new KeyringBackend()
setPassword.mockImplementationOnce(() => {
throw new Error('keyring locked')
})
await expect(k.put('cloud.dify.ai', 'acct-1', 'tok')).rejects.toThrow(/keyring locked/)
})
})

View File

@ -0,0 +1,35 @@
import type { TokenStore } from './store.js'
import { AsyncEntry } from '@napi-rs/keyring'
export const KEYRING_SERVICE = 'difyctl'
function username(host: string, accountId: string): string {
return `${host}::${accountId}`
}
export class KeyringBackend implements TokenStore {
async put(host: string, accountId: string, token: string): Promise<void> {
await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).setPassword(token)
}
async get(host: string, accountId: string): Promise<string | undefined> {
try {
const v = await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).getPassword()
return v ?? undefined
}
catch {
return undefined
}
}
async delete(host: string, accountId: string): Promise<void> {
try {
await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).deletePassword()
}
catch { /* missing entry is fine */ }
}
async list(_host: string): Promise<readonly string[]> {
return []
}
}

View File

@ -0,0 +1,75 @@
import type { TokenStore } from './store.js'
import { describe, expect, it, vi } from 'vitest'
import { selectStore } from './store.js'
function memBackend(label: string): TokenStore & { _label: string } {
const map = new Map<string, string>()
const k = (h: string, a: string) => `${h}::${a}`
return {
_label: label,
async put(h, a, t) { map.set(k(h, a), t) },
async get(h, a) { return map.get(k(h, a)) },
async delete(h, a) { map.delete(k(h, a)) },
async list() { return [] },
}
}
describe('selectStore', () => {
it('returns keychain when probe succeeds', async () => {
const k = memBackend('keyring')
const f = memBackend('file')
const result = await selectStore({
configDir: '/tmp/x',
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('keychain')
expect(result.store).toBe(k)
})
it('falls back to file when keyring put throws', async () => {
const k = memBackend('keyring')
const f = memBackend('file')
k.put = vi.fn().mockRejectedValue(new Error('locked'))
const result = await selectStore({
configDir: '/tmp/x',
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
})
it('falls back to file when probe round-trip mismatches', async () => {
const k = memBackend('keyring')
const f = memBackend('file')
k.get = vi.fn().mockResolvedValue('something-else')
const result = await selectStore({
configDir: '/tmp/x',
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
})
it('falls back to file when keyring constructor throws', async () => {
const f = memBackend('file')
const result = await selectStore({
configDir: '/tmp/x',
factory: {
keyring: () => { throw new Error('no backend') },
file: () => f,
},
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
})
it('cleans up probe entry after successful probe', async () => {
const k = memBackend('keyring')
const f = memBackend('file')
await selectStore({
configDir: '/tmp/x',
factory: { keyring: () => k, file: () => f },
})
expect(await k.get('__difyctl_probe__', '__probe__')).toBeUndefined()
})
})

40
cli/src/auth/store.ts Normal file
View File

@ -0,0 +1,40 @@
import { FileBackend } from './file-backend.js'
import { KeyringBackend } from './keyring-backend.js'
export type TokenStore = {
put: (host: string, accountId: string, token: string) => Promise<void>
get: (host: string, accountId: string) => Promise<string | undefined>
delete: (host: string, accountId: string) => Promise<void>
list: (host: string) => Promise<readonly string[]>
}
export type StorageMode = 'keychain' | 'file'
export type SelectStoreOptions = {
readonly configDir: string
readonly factory?: {
readonly keyring?: () => TokenStore
readonly file?: (dir: string) => TokenStore
}
}
const PROBE_HOST = '__difyctl_probe__'
const PROBE_ACCOUNT = '__probe__'
const PROBE_VALUE = 'probe-v1'
export async function selectStore(opts: SelectStoreOptions): Promise<{ store: TokenStore, mode: StorageMode }> {
const fileFactory = opts.factory?.file ?? ((dir: string) => new FileBackend(dir))
const keyringFactory = opts.factory?.keyring ?? (() => new KeyringBackend())
try {
const k = keyringFactory()
await k.put(PROBE_HOST, PROBE_ACCOUNT, PROBE_VALUE)
const got = await k.get(PROBE_HOST, PROBE_ACCOUNT)
await k.delete(PROBE_HOST, PROBE_ACCOUNT)
if (got !== PROBE_VALUE)
throw new Error('keyring round-trip mismatch')
return { store: k, mode: 'keychain' }
}
catch {
return { store: fileFactory(opts.configDir), mode: 'file' }
}
}

View File

@ -4,8 +4,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import yaml from 'js-yaml'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_APP_INFO, cachePath, getCache } from '../store/manager.js'
import { CACHE_APP_INFO, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { platform } from '../sys/index.js'
import { FieldInfo, FieldParameters } from '../types/app-meta.js'
import { APP_INFO_TTL_MS, loadAppInfoCache } from './app-info.js'
@ -35,25 +35,18 @@ function metaInfoOnly(): AppMeta {
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) })
const c1 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await c1.set('http://localhost:9999', 'app-1', metaInfoOnly())
const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c2 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const got = c2.get('http://localhost:9999', 'app-1')
expect(got).toBeDefined()
expect(got?.meta.info?.id).toBe('app-1')
@ -62,7 +55,7 @@ describe('app-info disk cache', () => {
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 })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)), now: () => now })
await c.set('h', 'app-1', metaInfoOnly())
const r = c.get('h', 'app-1')
expect(r).toBeDefined()
@ -73,23 +66,23 @@ describe('app-info disk cache', () => {
})
it('keys by (host, app_id) — different hosts isolate', async () => {
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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) })
const c1 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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) })
const c2 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await c.set('h', 'app-1', metaInfoOnly())
const { stat } = await import('node:fs/promises')
const s = await stat(appInfoPath(dir))
@ -98,19 +91,19 @@ describe('app-info disk cache', () => {
})
it('missing cache file is not an error', async () => {
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
expect(c.get('h', 'app-1')).toBeUndefined()
})
it('corrupt cache file is treated as empty', async () => {
const { writeFile } = await import('node:fs/promises')
await writeFile(appInfoPath(dir), ': : not valid yaml', 'utf8')
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, 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) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await c.set('h', 'app-1', metaInfoOnly())
const slim: AppMeta = {
...metaInfoOnly(),

View File

@ -3,8 +3,8 @@ import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import yaml from 'js-yaml'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_NUDGE, cachePath, getCache } from '../store/manager.js'
import { CACHE_NUDGE, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { loadNudgeStore, WARN_INTERVAL_MS } from './nudge-store.js'
function nudgeStorePath(dir: string): string {
@ -15,28 +15,21 @@ 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) })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, 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 })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, 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)
@ -44,7 +37,7 @@ describe('NudgeStore', () => {
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 })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, 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)
@ -52,22 +45,22 @@ describe('NudgeStore', () => {
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 })
const s1 = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
await s1.markWarned(HOST)
const s2 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
const s2 = await loadNudgeStore({ store: new YamlStore(cachePath(dir, 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) })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, 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 })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
await store.markWarned(HOST)
const raw = await readFile(nudgeStorePath(dir), 'utf8')
const parsed = yaml.load(raw) as Record<string, unknown>
@ -79,11 +72,11 @@ describe('NudgeStore', () => {
// 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 })
const a = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
const b = await loadNudgeStore({ store: new YamlStore(cachePath(dir, 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 })
const reread = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
expect(reread.canWarn('https://a.example')).toBe(false)
expect(reread.canWarn('https://b.example')).toBe(false)
})

View File

@ -12,6 +12,7 @@ import { BaseError } from '../../errors/base.js'
import { ErrorCode } from '../../errors/codes.js'
import { formatErrorForCli } from '../../errors/format.js'
import { createClient } from '../../http/client.js'
import { resolveConfigDir } from '../../store/dir.js'
import { realStreams } from '../../sys/io/streams'
import { hostWithScheme } from '../../util/host.js'
import { versionInfo } from '../../version/info.js'
@ -23,6 +24,7 @@ export type AuthedContext = {
readonly http: KyInstance
readonly host: string
readonly io: IOStreams
readonly configDir: string
readonly cache?: AppInfoCache
}
@ -36,8 +38,9 @@ export async function buildAuthedContext(
cmd: Pick<Command, 'error'>,
opts: AuthedContextOptions,
): Promise<AuthedContext> {
const configDir = resolveConfigDir()
const io = realStreams(opts.format ?? '')
const bundle = loadHosts()
const bundle = await loadHosts(configDir)
if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
const err = new BaseError({
code: ErrorCode.NotLoggedIn,
@ -58,7 +61,7 @@ export async function buildAuthedContext(
await runCompatNudge({ host, io })
return { bundle, http, host, io, cache }
return { bundle, http, host, io, configDir, cache }
}
// Best-effort nudge: never throws, never blocks. Lives here so every authed

View File

@ -2,7 +2,7 @@ import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openap
import type { DifyMock } from '../../../../../test/fixtures/dify-mock/server.js'
import type { AccountSessionsClient } from '../../../../api/account-sessions.js'
import type { HostsBundle } from '../../../../auth/hosts.js'
import type { Key, Store } from '../../../../store/store.js'
import type { TokenStore } from '../../../../auth/store.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -10,23 +10,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startMock } from '../../../../../test/fixtures/dify-mock/server.js'
import { saveHosts } from '../../../../auth/hosts.js'
import { createClient } from '../../../../http/client.js'
import { ENV_CONFIG_DIR, resolveConfigDir } from '../../../../store/dir.js'
import { tokenKey } from '../../../../store/manager.js'
import { bufferStreams } from '../../../../sys/io/streams'
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
class MemStore implements Store {
readonly entries = new Map<string, unknown>()
get<T>(key: Key<T>): T {
return (this.entries.get(key.key) as T | undefined) ?? key.default
class MemStore implements TokenStore {
readonly entries = new Map<string, string>()
async put(host: string, accountId: string, token: string): Promise<void> {
this.entries.set(`${host}::${accountId}`, token)
}
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
async get(host: string, accountId: string): Promise<string | undefined> {
return this.entries.get(`${host}::${accountId}`)
}
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
async delete(host: string, accountId: string): Promise<void> {
this.entries.delete(`${host}::${accountId}`)
}
async list(host: string): Promise<readonly string[]> {
const prefix = `${host}::`
return Array.from(this.entries.keys()).filter(k => k.startsWith(prefix))
}
}
@ -90,18 +93,11 @@ describe('runDevicesList', () => {
describe('runDevicesRevoke', () => {
let mock: DifyMock
let configDir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
configDir = await mkdtemp(join(tmpdir(), 'difyctl-devrevoke-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = configDir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await mock.stop()
await rm(configDir, { recursive: true, force: true })
})
@ -110,11 +106,11 @@ describe('runDevicesRevoke', () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
saveHosts(b)
await store.put(b.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, b)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl on desktop', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl on desktop', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
expect(store.entries.size).toBe(1)
})
@ -125,7 +121,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-2', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-2', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
})
@ -135,7 +131,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'web', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'web', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
})
@ -145,7 +141,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl', all: false }))
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl', all: false }))
.rejects
.toThrow(/matches multiple/)
})
@ -156,7 +152,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'nonexistent', all: false }))
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'nonexistent', all: false }))
.rejects
.toThrow(/no session matches/)
})
@ -167,7 +163,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, all: true })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, all: true })
expect(io.outBuf()).toContain('Revoked 2 session(s)')
})
@ -175,20 +171,20 @@ describe('runDevicesRevoke', () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
saveHosts(b)
await store.put(b.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, b)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-1', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-1', all: false })
expect(store.entries.size).toBe(0)
await expect(readFile(join(resolveConfigDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
})
it('no target + no --all: throws UsageMissingArg', async () => {
const io = bufferStreams()
const store = new MemStore()
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, bundle: bundleFor(mock.url), http, store, all: false }))
await expect(runDevicesRevoke({ configDir, io, bundle: bundleFor(mock.url), http, store, all: false }))
.rejects
.toThrow(/specify a device label/)
})

View File

@ -1,14 +1,15 @@
import type { SessionRow } from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../../auth/hosts.js'
import type { Store } from '../../../../store/store.js'
import type { TokenStore } from '../../../../auth/store.js'
import type { IOStreams } from '../../../../sys/io/streams'
import { unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { AccountSessionsClient } from '../../../../api/account-sessions.js'
import { clearLocal } from '../../../../auth/hosts.js'
import { HOSTS_FILE_NAME } from '../../../../auth/hosts.js'
import { BaseError } from '../../../../errors/base.js'
import { ErrorCode } from '../../../../errors/codes.js'
import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '../../../../limit/limit.js'
import { getTokenStore } from '../../../../store/manager.js'
import { colorEnabled, colorScheme } from '../../../../sys/io/color.js'
import { runWithSpinner } from '../../../../sys/io/spinner.js'
@ -71,11 +72,11 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
}
export type DevicesRevokeOptions = {
readonly configDir: string
readonly io: IOStreams
readonly bundle: HostsBundle | undefined
readonly http: KyInstance
/** Optional override for tests; production code resolves via `getTokenStore`. */
readonly store?: Store
readonly store: TokenStore
readonly target?: string
readonly all: boolean
readonly yes?: boolean
@ -103,10 +104,8 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
for (const id of ids)
await sessions.revoke(id)
if (selfHit) {
const tokens = opts.store ?? getTokenStore().store
clearLocal(b, tokens)
}
if (selfHit)
await clearLocal(opts.configDir, b, opts.store)
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
}
@ -179,3 +178,18 @@ function renderTable(rows: readonly SessionRow[], currentId: string): string {
cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ').trimEnd()
return body.length === 0 ? `${fmt(header)}\n` : `${[fmt(header), ...body.map(fmt)].join('\n')}\n`
}
async function clearLocal(configDir: string, bundle: HostsBundle, store: TokenStore): Promise<void> {
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
try {
await store.delete(bundle.current_host, accountId)
}
catch { /* best-effort */ }
try {
await unlink(join(configDir, HOSTS_FILE_NAME))
}
catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
throw err
}
}

View File

@ -1,3 +1,4 @@
import { selectStore } from '../../../../auth/store.js'
import { Args, Flags } from '../../../../framework/flags.js'
import { DifyCommand } from '../../../_shared/dify-command.js'
import { httpRetryFlag } from '../../../_shared/global-flags.js'
@ -24,10 +25,13 @@ export default class DevicesRevoke extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { args, flags } = this.parse(DevicesRevoke, argv)
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
const { store } = await selectStore({ configDir: ctx.configDir })
await runDevicesRevoke({
configDir: ctx.configDir,
io: ctx.io,
bundle: ctx.bundle,
http: ctx.http,
store,
target: args.target,
all: flags.all,
yes: flags.yes,

View File

@ -1,4 +1,5 @@
import { Flags } from '../../../framework/flags.js'
import { resolveConfigDir } from '../../../store/dir.js'
import { realStreams } from '../../../sys/io/streams'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runLogin } from './login.js'
@ -30,6 +31,7 @@ export default class Login extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(Login, argv)
await runLogin({
configDir: resolveConfigDir(),
io: realStreams(),
host: flags.host,
noBrowser: flags['no-browser'],

View File

@ -1,5 +1,5 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { Key, Store } from '../../../store/store.js'
import type { TokenStore } from '../../../auth/store.js'
import type { Clock } from './device-flow.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@ -8,8 +8,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { DeviceFlowApi } from '../../../api/oauth-device.js'
import { createClient } from '../../../http/client.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { tokenKey } from '../../../store/manager.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runLogin } from './login.js'
@ -20,38 +18,38 @@ const noopClock: Clock = {
const noopBrowser = async (): Promise<void> => { /* skip OS open */ }
class MemStore implements Store {
readonly entries = new Map<string, unknown>()
get<T>(key: Key<T>): T {
return (this.entries.get(key.key) as T | undefined) ?? key.default
class MemStore implements TokenStore {
readonly entries = new Map<string, string>()
async put(host: string, accountId: string, token: string): Promise<void> {
this.entries.set(`${host}::${accountId}`, token)
}
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
async get(host: string, accountId: string): Promise<string | undefined> {
return this.entries.get(`${host}::${accountId}`)
}
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
async delete(host: string, accountId: string): Promise<void> {
this.entries.delete(`${host}::${accountId}`)
}
async list(host: string): Promise<readonly string[]> {
const prefix = `${host}::`
return Array.from(this.entries.keys())
.filter(k => k.startsWith(prefix))
.map(k => k.slice(prefix.length))
}
}
describe('runLogin', () => {
let mock: DifyMock
let configDir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
configDir = await mkdtemp(join(tmpdir(), 'difyctl-login-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = configDir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await mock.stop()
await rm(configDir, { recursive: true, force: true })
})
@ -60,6 +58,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -74,7 +73,7 @@ describe('runLogin', () => {
expect(bundle.account?.email).toBe('tester@dify.ai')
expect(bundle.workspace?.id).toBe('ws-1')
expect(bundle.available_workspaces).toHaveLength(2)
const stored = store.get(tokenKey(bundle.current_host, 'acct-1'))
const stored = await store.get(bundle.current_host, 'acct-1')
expect(stored).toBe('dfoa_test')
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
@ -92,6 +91,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -115,6 +115,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -134,6 +135,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -150,6 +152,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -166,6 +169,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,

View File

@ -1,6 +1,6 @@
import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js'
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
import type { StorageMode, Store } from '../../../store/store.js'
import type { HostsBundle, StorageMode, Workspace } from '../../../auth/hosts.js'
import type { TokenStore } from '../../../auth/store.js'
import type { IOStreams } from '../../../sys/io/streams'
import type { BrowserEnv, BrowserOpener } from '../../../util/browser.js'
import type { Clock } from './device-flow.js'
@ -8,20 +8,21 @@ import * as os from 'node:os'
import * as readline from 'node:readline'
import { DeviceFlowApi } from '../../../api/oauth-device.js'
import { saveHosts } from '../../../auth/hosts.js'
import { selectStore } from '../../../auth/store.js'
import { createClient } from '../../../http/client.js'
import { getTokenStore, tokenKey } from '../../../store/manager.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
import { decideOpen, OpenDecision, openUrl, realEnv } from '../../../util/browser.js'
import { bareHost, DEFAULT_HOST, resolveHost, validateVerificationURI } from '../../../util/host.js'
import { awaitAuthorization, realClock } from './device-flow.js'
export type LoginOptions = {
readonly configDir: string
readonly io: IOStreams
readonly host?: string
readonly noBrowser?: boolean
readonly insecure?: boolean
readonly deviceLabel?: string
readonly store?: { readonly store: Store, readonly mode: StorageMode }
readonly store?: { readonly store: TokenStore, readonly mode: StorageMode }
readonly api?: DeviceFlowApi
readonly browserEnv?: BrowserEnv
readonly browserOpener?: BrowserOpener
@ -58,11 +59,11 @@ export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
const storeBundle = opts.store ?? getTokenStore()
const storeBundle = opts.store ?? await selectStore({ configDir: opts.configDir })
const bundle = bundleFromSuccess(host, success, storeBundle.mode)
storeBundle.store.set(tokenKey(bundle.current_host, accountKey(bundle)), success.token)
saveHosts(bundle)
await storeBundle.store.put(bundle.current_host, accountKey(bundle), success.token)
await saveHosts(opts.configDir, bundle)
renderLoggedIn(opts.io.out, cs, host, success)
return bundle

View File

@ -1,6 +1,8 @@
import type { KyInstance } from 'ky'
import { loadHosts } from '../../../auth/hosts.js'
import { selectStore } from '../../../auth/store.js'
import { createClient } from '../../../http/client.js'
import { resolveConfigDir } from '../../../store/dir.js'
import { runWithSpinner } from '../../../sys/io/spinner.js'
import { realStreams } from '../../../sys/io/streams'
import { hostWithScheme } from '../../../util/host.js'
@ -16,7 +18,9 @@ export default class Logout extends DifyCommand {
async run(argv: string[]): Promise<void> {
this.parse(Logout, argv)
const bundle = loadHosts()
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
const { store } = await selectStore({ configDir })
let http: KyInstance | undefined
if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') {
@ -30,7 +34,7 @@ export default class Logout extends DifyCommand {
const io = realStreams()
await runWithSpinner(
{ io, label: 'Signing out', enabled: true, style: 'dify-dim' },
() => runLogout({ io, bundle, http }),
() => runLogout({ configDir, io, bundle, http, store }),
)
}
}

View File

@ -1,6 +1,6 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { Key, Store } from '../../../store/store.js'
import type { TokenStore } from '../../../auth/store.js'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -8,23 +8,28 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { saveHosts } from '../../../auth/hosts.js'
import { createClient } from '../../../http/client.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { tokenKey } from '../../../store/manager.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runLogout } from './logout.js'
class MemStore implements Store {
readonly entries = new Map<string, unknown>()
get<T>(key: Key<T>): T {
return (this.entries.get(key.key) as T | undefined) ?? key.default
class MemStore implements TokenStore {
readonly entries = new Map<string, string>()
async put(host: string, accountId: string, token: string): Promise<void> {
this.entries.set(`${host}::${accountId}`, token)
}
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
async get(host: string, accountId: string): Promise<string | undefined> {
return this.entries.get(`${host}::${accountId}`)
}
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
async delete(host: string, accountId: string): Promise<void> {
this.entries.delete(`${host}::${accountId}`)
}
async list(host: string): Promise<readonly string[]> {
const prefix = `${host}::`
return Array.from(this.entries.keys())
.filter(k => k.startsWith(prefix))
.map(k => k.slice(prefix.length))
}
}
@ -47,20 +52,13 @@ function fixtureBundle(host: string): HostsBundle {
describe('runLogout', () => {
let mock: DifyMock
let configDir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
configDir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = configDir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await mock.stop()
await rm(configDir, { recursive: true, force: true })
})
@ -69,11 +67,11 @@ describe('runLogout', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
saveHosts(bundle)
await store.put(bundle.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, bundle)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
expect(store.entries.size).toBe(0)
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
@ -84,7 +82,7 @@ describe('runLogout', () => {
it('not-logged-in: throws BaseError', async () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogout({ io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
await expect(runLogout({ configDir, io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
})
it('hosts.yml absent: still completes locally + emits success', async () => {
@ -93,7 +91,7 @@ describe('runLogout', () => {
const bundle = fixtureBundle(mock.url)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
expect(io.outBuf()).toContain('Logged out of')
})
@ -102,12 +100,12 @@ describe('runLogout', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
saveHosts(bundle)
await store.put(bundle.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, bundle)
mock.setScenario('server-5xx')
const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })
await runLogout({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
expect(store.entries.size).toBe(0)
expect(io.errBuf()).toContain('server revoke failed')
@ -119,11 +117,11 @@ describe('runLogout', () => {
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
bundle.tokens = { bearer: 'dfp_personal_token' }
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfp_personal_token')
saveHosts(bundle)
await store.put(bundle.current_host, 'acct-1', 'dfp_personal_token')
await saveHosts(configDir, bundle)
const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' })
await runLogout({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
expect(io.errBuf()).toBe('')
expect(store.entries.size).toBe(0)
@ -133,11 +131,11 @@ describe('runLogout', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
saveHosts(bundle)
await saveHosts(configDir, bundle)
await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
const cfg = await readFile(join(configDir, 'config.yml'), 'utf8')
expect(cfg).toContain('foo: bar')

View File

@ -1,20 +1,21 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { Store } from '../../../store/store.js'
import type { TokenStore } from '../../../auth/store.js'
import type { IOStreams } from '../../../sys/io/streams'
import { unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { AccountSessionsClient } from '../../../api/account-sessions.js'
import { clearLocal } from '../../../auth/hosts.js'
import { HOSTS_FILE_NAME } from '../../../auth/hosts.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { getTokenStore } from '../../../store/manager.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
export type LogoutOptions = {
readonly configDir: string
readonly io: IOStreams
readonly bundle: HostsBundle | undefined
readonly http?: KyInstance
/** Optional override for tests; production code resolves via `getTokenStore`. */
readonly store?: Store
readonly store: TokenStore
}
export async function runLogout(opts: LogoutOptions): Promise<void> {
@ -39,8 +40,7 @@ export async function runLogout(opts: LogoutOptions): Promise<void> {
}
}
const tokens = opts.store ?? getTokenStore().store
clearLocal(bundle, tokens)
await clearLocal(opts.configDir, bundle, opts.store)
if (revokeWarning !== '')
opts.io.err.write(revokeWarning)
@ -52,3 +52,19 @@ const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
function revokeAllowed(bearer: string): boolean {
return REVOCABLE_PREFIXES.some(p => bearer.startsWith(p))
}
async function clearLocal(configDir: string, bundle: HostsBundle, store: TokenStore): Promise<void> {
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
try {
await store.delete(bundle.current_host, accountId)
}
catch { /* best-effort */ }
const hostsPath = join(configDir, HOSTS_FILE_NAME)
try {
await unlink(hostsPath)
}
catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
throw err
}
}

View File

@ -1,5 +1,6 @@
import { loadHosts } from '../../../auth/hosts.js'
import { Flags } from '../../../framework/flags.js'
import { resolveConfigDir } from '../../../store/dir.js'
import { realStreams } from '../../../sys/io/streams'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runStatus } from './status.js'
@ -20,7 +21,8 @@ export default class Status extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(Status, argv)
const bundle = loadHosts()
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json })
}
}

View File

@ -1,5 +1,6 @@
import { loadHosts } from '../../../auth/hosts.js'
import { Flags } from '../../../framework/flags.js'
import { resolveConfigDir } from '../../../store/dir.js'
import { realStreams } from '../../../sys/io/streams'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runWhoami } from './whoami.js'
@ -18,7 +19,8 @@ export default class Whoami extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(Whoami, argv)
const bundle = loadHosts()
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
await runWhoami({ io: realStreams(), bundle, json: flags.json })
}
}

View File

@ -1,49 +1,43 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { isBaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { runConfigGet } from './run.js'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('runConfigGet', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-get-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
})
it('returns set value with trailing newline', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'yaml' },
})
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' })
it('returns set value with trailing newline', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n format: yaml\n',
'utf8',
)
const out = runConfigGet({ store: makeStore(dir), key: 'defaults.format' })
expect(out).toBe('yaml\n')
})
it('returns empty line when key is unset (matches Go fmt.Fprintln)', () => {
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' })
const out = runConfigGet({ store: makeStore(dir), key: 'defaults.format' })
expect(out).toBe('\n')
})
it('throws BaseError(config_invalid_key) on unknown key', () => {
let caught: unknown
try {
runConfigGet({ store: getConfigurationStore(), key: 'bogus.key' })
runConfigGet({ store: makeStore(dir), key: 'bogus.key' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -51,12 +45,13 @@ describe('runConfigGet', () => {
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
})
it('returns numeric limit as string', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { limit: 75 },
})
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.limit' })
it('returns numeric limit as string', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n limit: 75\n',
'utf8',
)
const out = runConfigGet({ store: makeStore(dir), key: 'defaults.limit' })
expect(out).toBe('75\n')
})
})

View File

@ -1,8 +1,7 @@
import { join } from 'node:path'
import { raw } from '../../../framework/output.js'
import { resolveConfigDir } from '../../../store/dir.js'
import { CONFIG_FILE_NAME } from '../../../store/manager.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runConfigPath } from './run.js'
export default class ConfigPath extends DifyCommand {
static override description = 'Print the resolved config.yml path'
@ -13,8 +12,6 @@ export default class ConfigPath extends DifyCommand {
async run(argv: string[]) {
this.parse(ConfigPath, argv)
return raw(
join(resolveConfigDir(), CONFIG_FILE_NAME),
)
return raw(runConfigPath({ dir: resolveConfigDir() }))
}
}

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { runConfigPath } from './run.js'
describe('runConfigPath', () => {
it('joins dir and config.yml with trailing newline', () => {
const out = runConfigPath({ dir: '/tmp/x' })
expect(out).toBe('/tmp/x/config.yml\n')
})
it('handles trailing slash on dir', () => {
const out = runConfigPath({ dir: '/tmp/x/' })
expect(out).toBe('/tmp/x/config.yml\n')
})
})

View File

@ -0,0 +1,10 @@
import { join } from 'node:path'
import { FILE_NAME } from '../../../config/schema.js'
export type RunConfigPathOptions = {
readonly dir: string
}
export function runConfigPath(opts: RunConfigPathOptions): string {
return `${join(opts.dir, FILE_NAME)}\n`
}

View File

@ -1,46 +1,35 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../../../config/config-loader.js'
import { beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { isBaseError } from '../../../errors/base.js'
import { ErrorCode, ExitCode } from '../../../errors/codes.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { runConfigSet } from './run.js'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('runConfigSet', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-set-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
})
it('persists the value and returns "set k = v\\n"', () => {
const out = runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'json' })
it('writes config.yml and returns "set k = v\\n"', async () => {
const out = runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'json' })
expect(out).toBe('set defaults.format = json\n')
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.defaults.format).toBe('json')
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toContain('format: json')
})
it('rejects invalid format value with config_invalid_value', () => {
it('rejects invalid format value with config_invalid_value', async () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -51,7 +40,7 @@ describe('runConfigSet', () => {
it('rejects unknown key with config_invalid_key', () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -59,22 +48,18 @@ describe('runConfigSet', () => {
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
})
it('preserves prior keys when setting a new one', () => {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'yaml' })
runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: '40' })
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('yaml')
expect(r.config.defaults.limit).toBe(40)
}
it('preserves prior keys when setting a new one', async () => {
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'yaml' })
runConfigSet({ store: makeStore(dir), key: 'defaults.limit', value: '40' })
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toContain('format: yaml')
expect(raw).toContain('limit: 40')
})
it('exit code for invalid value is Usage (2)', () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -85,7 +70,7 @@ describe('runConfigSet', () => {
it('exit code for unknown key is Usage (2)', () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -96,7 +81,7 @@ describe('runConfigSet', () => {
it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: 'abc' })
runConfigSet({ store: makeStore(dir), key: 'defaults.limit', value: 'abc' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -1,61 +1,48 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../../../config/config-loader.js'
import { beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { isBaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { runConfigUnset } from './run.js'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('runConfigUnset', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-unset-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
})
it('clears the requested key, leaves others intact', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'json', limit: 25 },
})
const out = runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' })
it('clears the requested key, leaves others intact', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n format: json\n limit: 25\n',
'utf8',
)
const out = runConfigUnset({ store: makeStore(dir), key: 'defaults.format' })
expect(out).toBe('unset defaults.format\n')
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).not.toBe('json')
expect(r.config.defaults.limit).toBe(25)
}
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).not.toContain('format:')
expect(raw).toContain('limit: 25')
})
it('is a no-op (writes empty config) when key was already unset', () => {
const out = runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' })
it('is a no-op (writes empty config) when key was already unset', async () => {
const out = runConfigUnset({ store: makeStore(dir), key: 'defaults.format' })
expect(out).toBe('unset defaults.format\n')
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toContain('schema_version: 1')
})
it('rejects unknown key', () => {
let caught: unknown
try {
runConfigUnset({ store: getConfigurationStore(), key: 'bogus' })
runConfigUnset({ store: makeStore(dir), key: 'bogus' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -1,69 +1,67 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { FILE_NAME } from '../../../config/schema.js'
import { YamlStore } from '../../../store/store.js'
import { runConfigView } from './run.js'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('runConfigView', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-view-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
// tmpdir cleanup is best-effort
})
it('text format: empty config returns empty string', () => {
const out = runConfigView({ store: getConfigurationStore() })
const out = runConfigView({ store: makeStore(dir) })
expect(out).toBe('')
})
it('text format: emits "key = value" lines for set keys only', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'json', limit: 50 },
state: { current_app: 'app-1' },
})
const out = runConfigView({ store: getConfigurationStore() })
it('text format: emits "key = value" lines for set keys only', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n format: json\n limit: 50\nstate:\n current_app: app-1\n',
'utf8',
)
const out = runConfigView({ store: makeStore(dir) })
expect(out).toBe(
'defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n',
)
})
it('text format: skips unset keys', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'yaml' },
})
const out = runConfigView({ store: getConfigurationStore() })
it('text format: skips unset keys', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n format: yaml\n',
'utf8',
)
const out = runConfigView({ store: makeStore(dir) })
expect(out).toBe('defaults.format = yaml\n')
expect(out).not.toContain('defaults.limit')
expect(out).not.toContain('state.current_app')
})
it('json format: empty config returns "{}\\n"', () => {
const out = runConfigView({ store: getConfigurationStore(), json: true })
const out = runConfigView({ store: makeStore(dir), json: true })
expect(out).toBe('{}\n')
})
it('json format: defaults.limit is numeric, others are strings', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'table', limit: 100 },
state: { current_app: 'app-x' },
})
const out = runConfigView({ store: getConfigurationStore(), json: true })
it('json format: defaults.limit is numeric, others are strings', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n format: table\n limit: 100\nstate:\n current_app: app-x\n',
'utf8',
)
const out = runConfigView({ store: makeStore(dir), json: true })
const parsed = JSON.parse(out) as Record<string, unknown>
expect(parsed['defaults.format']).toBe('table')
expect(parsed['defaults.limit']).toBe(100)
@ -71,7 +69,7 @@ describe('runConfigView', () => {
})
it('json format: trailing newline matches Go encoder.Encode', () => {
const out = runConfigView({ store: getConfigurationStore(), json: true })
const out = runConfigView({ store: makeStore(dir), json: true })
expect(out.endsWith('\n')).toBe(true)
})
})

View File

@ -8,8 +8,8 @@ import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { loadAppInfoCache } from '../../../cache/app-info.js'
import { formatted, stringifyOutput } from '../../../framework/output.js'
import { createClient } from '../../../http/client.js'
import { ENV_CACHE_DIR } from '../../../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
import { CACHE_APP_INFO, cachePath } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { runDescribeApp } from './run.js'
function bundle(): HostsBundle {
@ -29,24 +29,17 @@ function bundle(): HostsBundle {
describe('runDescribeApp', () => {
let mock: DifyMock
let dir: string
let prevCacheDir: string | undefined
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
dir = await mkdtemp(join(tmpdir(), 'difyctl-desc-'))
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 })
})
async function render(opts: Parameters<typeof runDescribeApp>[0]): Promise<string> {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const data = await runDescribeApp(
opts,
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
@ -89,7 +82,7 @@ describe('runDescribeApp', () => {
})
it('refresh: bypasses cache', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runDescribeApp(
{ appId: 'app-1' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },

View File

@ -7,8 +7,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { loadAppInfoCache } from '../../../cache/app-info.js'
import { createClient } from '../../../http/client.js'
import { ENV_CACHE_DIR } from '../../../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
import { CACHE_APP_INFO, cachePath } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { bufferStreams } from '../../../sys/io/streams'
import { resumeApp } from '../../resume/app/run.js'
import { runApp } from './run.js'
@ -30,25 +30,18 @@ function bundle(): HostsBundle {
describe('runApp', () => {
let mock: DifyMock
let dir: string
let prevCacheDir: string | undefined
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
dir = await mkdtemp(join(tmpdir(), 'difyctl-runapp-'))
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('chat: prints answer + conversation hint to stderr', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-1', message: 'hi' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -59,7 +52,7 @@ describe('runApp', () => {
it('workflow: rejects positional message with usage error', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await expect(runApp(
{ appId: 'app-2', message: 'hi' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -68,7 +61,7 @@ describe('runApp', () => {
it('workflow: prints single-string output as plain text', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-2', inputs: { x: '1' } },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -78,7 +71,7 @@ describe('runApp', () => {
it('json: passes through full envelope', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-1', message: 'hi', format: 'json' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -111,7 +104,7 @@ describe('runApp', () => {
it('--stream chat: streams answer to stdout and hint to stderr', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -123,7 +116,7 @@ describe('runApp', () => {
it('--stream -o json chat: aggregates into blocking-shape envelope', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-1', message: 'hi', stream: true, format: 'json' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -136,7 +129,7 @@ describe('runApp', () => {
it('agent-chat without --stream: collects and prints answer', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-4', workspace: 'ws-2', message: 'do research' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -147,7 +140,7 @@ describe('runApp', () => {
it('agent-chat with --stream: live-prints answer and thoughts to stderr', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-4', workspace: 'ws-2', message: 'go', stream: true },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -158,7 +151,7 @@ describe('runApp', () => {
it('--stream workflow -o json: aggregates from workflow_finished', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -171,7 +164,7 @@ describe('runApp', () => {
it('stream-error scenario: error event surfaces typed BaseError', async () => {
mock.setScenario('stream-error')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await expect(runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
@ -180,7 +173,7 @@ describe('runApp', () => {
it('--inputs-file: reads inputs from file', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const inputsFile = join(dir, 'inputs.json')
const { writeFile } = await import('node:fs/promises')
await writeFile(inputsFile, JSON.stringify({ x: 'from-file' }))
@ -204,7 +197,7 @@ describe('runApp', () => {
it('--inputs: accepts JSON object string', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-2', inputsJson: '{"x":"hello"}' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -226,7 +219,7 @@ describe('runApp', () => {
it('hitl pause (text): writes readable block to stdout, hint to stderr, exits 0', async () => {
mock.setScenario('hitl-pause')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
let exitCode = -1
await expect(runApp(
{ appId: 'app-2', inputs: {} },
@ -255,7 +248,7 @@ describe('runApp', () => {
it('hitl pause (json): writes JSON envelope to stdout, exits 0', async () => {
mock.setScenario('hitl-pause')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
let exitCode = -1
await expect(runApp(
{ appId: 'app-2', inputs: {}, format: 'json' },
@ -281,7 +274,7 @@ describe('runApp', () => {
it('resume: withHistory: false completes successfully', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -292,7 +285,7 @@ describe('runApp', () => {
it('resume: submits form and streams workflow to completion', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -303,7 +296,7 @@ describe('runApp', () => {
it('resume --stream: live-prints workflow node progress to stderr', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -314,7 +307,7 @@ describe('runApp', () => {
it('workflow: --file remote URL is passed as remote_url input variable', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-2', files: ['doc=https://example.com/report.pdf'] },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -333,7 +326,7 @@ describe('runApp', () => {
it('workflow: --file @path uploads file and passes local_file input variable', async () => {
const { writeFile } = await import('node:fs/promises')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const filePath = join(dir, 'test.pdf')
await writeFile(filePath, 'fake pdf content')
await runApp(
@ -352,7 +345,7 @@ describe('runApp', () => {
it('workflow: --file overrides same-named key from --inputs (file wins)', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },

View File

@ -22,6 +22,7 @@ export default class UseWorkspace extends DifyCommand {
const { args, flags } = this.parse(UseWorkspace, argv)
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
await runUseWorkspace({ workspaceId: args.workspaceId }, {
configDir: ctx.configDir,
bundle: ctx.bundle,
http: ctx.http,
io: ctx.io,

View File

@ -9,7 +9,6 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runUseWorkspace } from './use.js'
@ -52,29 +51,23 @@ function fakeClient(opts: {
describe('runUseWorkspace', () => {
let configDir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
configDir = await mkdtemp(join(tmpdir(), 'difyctl-use-workspace-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = configDir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(configDir, { recursive: true, force: true })
})
it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
await saveHosts(configDir, b)
const client = fakeClient({})
const next = await runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
@ -89,7 +82,7 @@ describe('runUseWorkspace', () => {
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Switched', role: 'normal' },
])
const reloaded = loadHosts()
const reloaded = await loadHosts(configDir)
expect(reloaded?.workspace?.id).toBe('ws-2')
expect(reloaded?.workspace?.name).toBe('Switched')
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
@ -100,15 +93,15 @@ describe('runUseWorkspace', () => {
// We expect saveHosts to record the fresh name from the server.
const io = bufferStreams()
const b = bundle()
saveHosts(b)
await saveHosts(configDir, b)
const client = fakeClient({})
await runUseWorkspace(
{ workspaceId: 'ws-2' },
{ bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
{ configDir, bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
)
const reloaded = loadHosts()
const reloaded = await loadHosts(configDir)
expect(reloaded?.workspace?.name).toBe('Switched')
expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
})
@ -116,8 +109,8 @@ describe('runUseWorkspace', () => {
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
const before = loadHosts()
await saveHosts(configDir, b)
const before = await loadHosts(configDir)
const client = fakeClient({
switch: () => Promise.reject(new Error('forbidden')),
@ -127,6 +120,7 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
@ -136,7 +130,7 @@ describe('runUseWorkspace', () => {
).rejects.toThrow(/forbidden/)
expect(client.list).not.toHaveBeenCalled()
const after = loadHosts()
const after = await loadHosts(configDir)
expect(after).toEqual(before)
expect(after?.workspace?.id).toBe('ws-1')
})
@ -144,8 +138,8 @@ describe('runUseWorkspace', () => {
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
const before = loadHosts()
await saveHosts(configDir, b)
const before = await loadHosts(configDir)
const client = fakeClient({
list: () => Promise.reject(new Error('transient list failure')),
@ -155,6 +149,7 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
@ -163,14 +158,14 @@ describe('runUseWorkspace', () => {
),
).rejects.toThrow(/transient list failure/)
const after = loadHosts()
const after = await loadHosts(configDir)
expect(after).toEqual(before)
})
it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
await saveHosts(configDir, b)
const client = fakeClient({
switch: () => Promise.resolve({
@ -192,6 +187,7 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-7' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,

View File

@ -13,6 +13,7 @@ export type UseWorkspaceOptions = {
}
export type UseWorkspaceDeps = {
readonly configDir: string
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io: IOStreams
@ -69,7 +70,7 @@ export async function runUseWorkspace(
role: w.role,
})),
}
saveHosts(next)
await saveHosts(deps.configDir, next)
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
return next
}

View File

@ -1,52 +1,48 @@
import type { YamlStore } from '../store/store'
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { isBaseError } from '../errors/base'
import { ErrorCode } from '../errors/codes'
import { ENV_CONFIG_DIR } from '../store/dir'
import { getConfigurationStore } from '../store/manager'
import { YamlStore } from '../store/store'
import { loadConfig } from './config-loader'
import { FILE_NAME } from './schema'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('loadConfig', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-cfg-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
await mkdir(dir, { recursive: true }).catch(() => {})
})
it('returns found:false when config is missing', () => {
const r = loadConfig(getConfigurationStore())
it('returns found:false when config.yml is missing', () => {
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(false)
})
it('parses a minimal valid config', () => {
getConfigurationStore().setTyped({ schema_version: 1 })
const r = loadConfig(getConfigurationStore())
it('parses a minimal valid config.yml', async () => {
await writeFile(join(dir, FILE_NAME), 'schema_version: 1\n', 'utf8')
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
it('parses defaults + state', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'json', limit: 100 },
state: { current_app: 'app-1' },
})
const r = loadConfig(getConfigurationStore())
it('parses defaults + state', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n format: json\n limit: 100\nstate:\n current_app: app-1\n',
'utf8',
)
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('json')
@ -55,29 +51,11 @@ describe('loadConfig', () => {
}
})
it('throws BaseError(config_schema_unsupported) when the store fails to parse the file', () => {
// Simulate a corrupt on-disk file via a fake store; loadConfig must wrap
// the underlying error as ConfigSchemaUnsupported.
const throwingStore = {
getTyped: () => { throw new Error('YAML parse failure') },
} as unknown as YamlStore
it('throws BaseError(config_schema_unsupported) when YAML is malformed', async () => {
await writeFile(join(dir, FILE_NAME), '::not yaml::: {{[', 'utf8')
let caught: unknown
try {
loadConfig(throwingStore)
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught)) {
expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported)
expect(caught.hint).toMatch(/not valid YAML/)
}
})
it('throws BaseError(config_schema_unsupported) when zod validation fails', () => {
getConfigurationStore().setTyped({ defaults: { limit: 9999 } })
let caught: unknown
try {
loadConfig(getConfigurationStore())
loadConfig(makeStore(dir))
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -85,11 +63,23 @@ describe('loadConfig', () => {
expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported)
})
it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', () => {
getConfigurationStore().setTyped({ schema_version: 2 })
it('throws BaseError(config_schema_unsupported) when zod validation fails', async () => {
await writeFile(join(dir, FILE_NAME), 'defaults:\n limit: 9999\n', 'utf8')
let caught: unknown
try {
loadConfig(getConfigurationStore())
loadConfig(makeStore(dir))
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught))
expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported)
})
it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', async () => {
await writeFile(join(dir, FILE_NAME), 'schema_version: 2\n', 'utf8')
let caught: unknown
try {
loadConfig(makeStore(dir))
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { CONFIG_FILE_NAME } from '../store/manager.js'
import {
ALLOWED_FORMATS,
ConfigFileSchema,
CURRENT_SCHEMA_VERSION,
emptyConfig,
FILE_NAME,
} from './schema.js'
describe('config schema', () => {
@ -12,8 +12,8 @@ describe('config schema', () => {
expect(CURRENT_SCHEMA_VERSION).toBe(1)
})
it('CONFIG_FILE_NAME is config.yml', () => {
expect(CONFIG_FILE_NAME).toBe('config.yml')
it('FILE_NAME is config.yml', () => {
expect(FILE_NAME).toBe('config.yml')
})
it('ALLOWED_FORMATS matches Go set (json/yaml/table/wide/name/text)', () => {

View File

@ -1,6 +1,7 @@
import { z } from 'zod'
export const CURRENT_SCHEMA_VERSION = 1
export const FILE_NAME = 'config.yml'
export const ALLOWED_FORMATS = ['json', 'yaml', 'table', 'wide', 'name', 'text'] as const
export type AllowedFormat = (typeof ALLOWED_FORMATS)[number]

View File

@ -8,8 +8,8 @@ import {
} from './codes.js'
describe('error codes', () => {
it('has 18 codes (parity with internal/api/errors)', () => {
expect(ALL_ERROR_CODES).toHaveLength(18)
it('has 17 codes (parity with internal/api/errors)', () => {
expect(ALL_ERROR_CODES).toHaveLength(17)
})
it('has the expected ExitCode buckets', () => {
@ -46,7 +46,6 @@ describe('error codes', () => {
[ErrorCode.NetworkDns, ExitCode.Generic],
[ErrorCode.Server5xx, ExitCode.Generic],
[ErrorCode.Server4xxOther, ExitCode.Generic],
[ErrorCode.ClientError, ExitCode.Generic],
[ErrorCode.Unknown, ExitCode.Generic],
])('exitFor(%s) -> %d', (code, want) => {
expect(exitFor(code)).toBe(want)

View File

@ -15,7 +15,6 @@ export const ErrorCode = {
NetworkDns: 'network_dns',
Server5xx: 'server_5xx',
Server4xxOther: 'server_4xx_other',
ClientError: 'client_error',
Unknown: 'unknown',
} as const
@ -48,7 +47,6 @@ const CODE_TO_EXIT: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = {
network_dns: ExitCode.Generic,
server_5xx: ExitCode.Generic,
server_4xx_other: ExitCode.Generic,
client_error: ExitCode.Generic,
unknown: ExitCode.Generic,
}

View File

@ -1,57 +1,45 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readdir, readFile, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../config/config-loader'
import { emptyConfig } from '../config/schema'
import { emptyConfig, FILE_NAME } from '../config/schema'
import { platform } from '../sys'
import { saveConfig } from './config-writer'
import { ENV_CONFIG_DIR } from './dir'
import { getConfigurationStore } from './manager'
import { YamlStore } from './store'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('saveConfig', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-w-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
it('writes config.yml in the target dir', async () => {
saveConfig(makeStore(dir), { ...emptyConfig(), schema_version: 1 })
const stats = await stat(join(dir, FILE_NAME))
expect(stats.isFile()).toBe(true)
})
it('stamps schema_version=1 even if caller passed 0', () => {
saveConfig(getConfigurationStore(), { ...emptyConfig() })
const r = loadConfig(getConfigurationStore())
saveConfig(makeStore(dir), { ...emptyConfig() })
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
it('overrides a stale schema_version on save', () => {
saveConfig(getConfigurationStore(), {
...emptyConfig(),
schema_version: 999 as never,
})
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
it('round-trips defaults + state', () => {
saveConfig(getConfigurationStore(), {
it('round-trips defaults + state through YAML', () => {
saveConfig(makeStore(dir), {
schema_version: 1,
defaults: { format: 'wide', limit: 75 },
state: { current_app: 'app-xyz' },
})
const r = loadConfig(getConfigurationStore())
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('wide')
@ -60,22 +48,39 @@ describe('saveConfig', () => {
}
})
it('overwrites the previous config on resave', () => {
saveConfig(getConfigurationStore(), {
it('writes file with mode 0o600 (POSIX)', async () => {
if (platform() === 'win32')
return
saveConfig(makeStore(dir), emptyConfig())
const s = await stat(join(dir, FILE_NAME))
expect(s.mode & 0o777).toBe(0o600)
})
it('does not leave a tmp file on success', async () => {
saveConfig(makeStore(dir), emptyConfig())
const entries = await readdir(dir)
expect(entries.filter(f => f.endsWith('.tmp'))).toHaveLength(0)
expect(entries.filter(f => f.includes('.tmp.'))).toHaveLength(0)
})
it('creates parent dir at 0o700 if absent', async () => {
if (platform() === 'win32')
return
const nested = join(dir, 'nested', 'sub')
saveConfig(makeStore(nested), emptyConfig())
const s = await stat(nested)
expect(s.isDirectory()).toBe(true)
expect(s.mode & 0o777).toBe(0o700)
})
it('emits parseable YAML (round-trip via fs.readFile + js-yaml)', async () => {
saveConfig(makeStore(dir), {
schema_version: 1,
defaults: { format: 'json' },
state: {},
})
saveConfig(getConfigurationStore(), {
schema_version: 1,
defaults: { format: 'table' },
state: { current_app: 'app-2' },
})
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('table')
expect(r.config.state.current_app).toBe('app-2')
}
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toMatch(/^schema_version:/m)
expect(raw).toMatch(/format: json/)
})
})

View File

@ -1,64 +0,0 @@
import { BaseError } from '../errors/base'
import { ErrorCode } from '../errors/codes'
export class ConcurrentAccessError extends BaseError {
constructor(filePath: string) {
const msg = `Another process is modifying the file ${filePath}. remove ${filePath}.lock to reset lock.`
super({
code: ErrorCode.ClientError,
message: msg,
hint: `remove ${filePath}.lock to reset lock.`,
})
}
}
type YamlMark = {
line: number
column: number
snippet?: string
}
type YamlParseError = {
reason?: string
mark?: YamlMark
message?: string
}
export class BadYamlFormatError extends BaseError {
constructor(path: string, raw: string, cause: YamlParseError) {
const reason = cause.reason ?? cause.message ?? 'invalid YAML'
const mark = cause.mark
const where = mark ? ` at line ${mark.line + 1}, column ${mark.column + 1}` : ''
const snippet = mark?.snippet ?? excerpt(raw, mark)
const header = `Failed to parse YAML file ${path}: ${reason}${where}.`
const body = snippet ? `\n\n${snippet}` : ''
super({
code: ErrorCode.ClientError,
message: `${header}${body}`,
hint: `Fix the YAML syntax in ${path} or remove the file to reset it.`,
})
}
}
function excerpt(raw: string, mark: YamlMark | undefined): string {
if (mark === undefined)
return ''
const lines = raw.split('\n')
const target = mark.line
if (target < 0 || target >= lines.length)
return ''
const start = Math.max(0, target - 2)
const end = Math.min(lines.length, target + 3)
const width = String(end).length
const out: string[] = []
for (let i = start; i < end; i++) {
const marker = i === target ? '>' : ' '
const num = String(i + 1).padStart(width, ' ')
out.push(`${marker} ${num} | ${lines[i]}`)
if (i === target)
out.push(`${' '.repeat(width + 4)}${' '.repeat(mark.column)}^`)
}
return out.join('\n')
}

View File

@ -1,109 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const passwords = new Map<string, string>()
const setPassword = vi.fn()
const getPassword = vi.fn()
const deletePassword = vi.fn()
class FakeEntry {
private readonly key: string
constructor(service: string, username: string) {
this.key = `${service}::${username}`
}
setPassword(value: string): void {
setPassword(this.key, value)
passwords.set(this.key, value)
}
getPassword(): string | null {
getPassword(this.key)
return passwords.get(this.key) ?? null
}
deletePassword(): boolean {
deletePassword(this.key)
if (!passwords.has(this.key))
return false
passwords.delete(this.key)
return true
}
}
vi.mock('@napi-rs/keyring', () => ({
Entry: FakeEntry,
}))
const { KeyringBasedStore } = await import('./store.js')
const SERVICE = 'difyctl-test'
beforeEach(() => {
passwords.clear()
setPassword.mockClear()
getPassword.mockClear()
deletePassword.mockClear()
})
describe('KeyringBasedStore', () => {
it('returns default when entry missing', () => {
const s = new KeyringBasedStore(SERVICE)
expect(s.get({ key: 'k', default: 'fallback' })).toBe('fallback')
})
it('round-trips strings via JSON encoding', () => {
const s = new KeyringBasedStore(SERVICE)
s.set({ key: 'k', default: '' }, 'tok-abc')
expect(s.get({ key: 'k', default: '' })).toBe('tok-abc')
})
it('isolates entries by key', () => {
const s = new KeyringBasedStore(SERVICE)
s.set({ key: 'a', default: '' }, 'A')
s.set({ key: 'b', default: '' }, 'B')
expect(s.get({ key: 'a', default: '' })).toBe('A')
expect(s.get({ key: 'b', default: '' })).toBe('B')
})
it('unset removes the entry', () => {
const s = new KeyringBasedStore(SERVICE)
s.set({ key: 'k', default: '' }, 'v')
s.unset({ key: 'k', default: '' })
expect(s.get({ key: 'k', default: '' })).toBe('')
})
it('unset is a no-op when entry missing', () => {
const s = new KeyringBasedStore(SERVICE)
expect(() => s.unset({ key: 'gone', default: '' })).not.toThrow()
})
it('swallows getPassword exceptions and returns default', () => {
const s = new KeyringBasedStore(SERVICE)
getPassword.mockImplementationOnce(
() => {
throw new Error('NoEntry')
},
)
expect(s.get({ key: 'k', default: 'd' })).toBe('d')
})
it('swallows unset exceptions', () => {
const s = new KeyringBasedStore(SERVICE)
deletePassword.mockImplementationOnce(
() => {
throw new Error('NoEntry')
},
)
expect(() => s.unset({ key: 'k', default: '' })).not.toThrow()
})
it('lets set propagate exceptions (caller decides fallback)', () => {
const s = new KeyringBasedStore(SERVICE)
setPassword.mockImplementationOnce(
() => {
throw new Error('keyring locked')
},
)
expect(() => s.set({ key: 'k', default: '' }, 'v')).toThrow(/keyring locked/)
})
})

View File

@ -1,78 +0,0 @@
import type { Key, Store } from './store.js'
import { describe, expect, it, vi } from 'vitest'
import { getTokenStore } from './manager.js'
function memStore(label: string): Store & { _label: string } {
const map = new Map<string, unknown>()
return {
_label: label,
get<T>(key: Key<T>): T {
return (map.get(key.key) as T | undefined) ?? key.default
},
set<T>(key: Key<T>, value: T): void {
map.set(key.key, value)
},
unset<T>(key: Key<T>): void {
map.delete(key.key)
},
}
}
describe('getTokenStore', () => {
it('returns keychain store when probe succeeds', () => {
const k = memStore('keyring')
const f = memStore('file')
const result = getTokenStore({
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('keychain')
expect(result.store).toBe(k)
})
it('falls back to file when keyring set throws', () => {
const k = memStore('keyring')
const f = memStore('file')
k.set = vi.fn(
() => {
throw new Error('locked')
},
)
const result = getTokenStore({
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
})
it('falls back to file when probe round-trip mismatches', () => {
const k = memStore('keyring')
const f = memStore('file')
k.get = vi.fn(() => 'something-else')
const result = getTokenStore({
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
})
it('falls back to file when keyring constructor throws', () => {
const f = memStore('file')
const result = getTokenStore({
factory: {
keyring: () => { throw new Error('no backend') },
file: () => f,
},
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
})
it('cleans up probe entry after successful probe', () => {
const k = memStore('keyring')
const f = memStore('file')
getTokenStore({
factory: { keyring: () => k, file: () => f },
})
expect(k.get({ key: '__difyctl_probe__', default: '' })).toBe('')
})
})

View File

@ -1,77 +1,28 @@
import type { Key, StorageMode, Store } from './store'
import type { Store } from './store'
import { join } from 'node:path'
import { FILE_NAME } from '../config/schema'
import { resolveCacheDir, resolveConfigDir } from './dir'
import { KeyringBasedStore, YamlStore } from './store'
import { YamlStore } from './store'
export const CACHE_APP_INFO = 'app-info'
export const CACHE_NUDGE = 'nudge'
const HOSTS_FILE = 'hosts.yml'
const TOKENS_FILE = 'tokens.yml'
export const CONFIG_FILE_NAME = 'config.yml'
const KEYRING_SERVICE = 'difyctl'
function getStore(filePath: string): YamlStore {
return new YamlStore(filePath)
}
function resolveConfigurationPath(): string {
return join(resolveConfigDir(), FILE_NAME)
}
export function cachePath(cacheDir: string, name: string): string {
return join(cacheDir, `${name}.yml`)
}
export function getConfigurationStore(): YamlStore {
return getStore(join(resolveConfigDir(), CONFIG_FILE_NAME))
return getStore(resolveConfigurationPath())
}
export function getCache(cacheName: string): Store {
return getStore(cachePath(resolveCacheDir(), cacheName))
}
export function getHostStore(): YamlStore {
return getStore(join(resolveConfigDir(), HOSTS_FILE))
}
const PROBE_KEY: Key<string> = { key: '__difyctl_probe__', default: '' }
const PROBE_VALUE = 'probe-v1'
export type GetTokenStoreOptions = {
readonly factory?: {
readonly keyring?: () => Store
readonly file?: () => Store
}
}
/**
* Single entry point for the credential store. Probes the OS keyring; if it
* round-trips a value, returns the keychain-backed store. Otherwise falls
* back to the YAML file at `<configDir>/tokens.yml`. Both implementations
* satisfy the `Store` interface, so callers interact uniformly.
*
* Business logic should always obtain the token store through this factory
* rather than constructing one directly.
*/
export function getTokenStore(opts: GetTokenStoreOptions = {}): { store: Store, mode: StorageMode } {
const fileFactory = opts.factory?.file ?? (() => getStore(join(resolveConfigDir(), TOKENS_FILE)))
const keyringFactory = opts.factory?.keyring ?? (() => new KeyringBasedStore(KEYRING_SERVICE))
try {
const k = keyringFactory()
k.set(PROBE_KEY, PROBE_VALUE)
const got = k.get(PROBE_KEY)
k.unset(PROBE_KEY)
if (got !== PROBE_VALUE)
throw new Error('keyring round-trip mismatch')
return { store: k, mode: 'keychain' }
}
catch {
return { store: fileFactory(), mode: 'file' }
}
}
/**
* Maps an auth identity (host + accountId) to a `Store` key. All token store
* reads/writes in business logic go through this helper so the on-disk /
* keyring layout stays consistent.
*/
export function tokenKey(host: string, accountId: string): Key<string> {
return { key: `tokens.${host}.${accountId}`, default: '' }
}

View File

@ -1,10 +1,9 @@
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { readFileSync, writeFileSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { BadYamlFormatError, ConcurrentAccessError } from './errors'
import { YamlStore } from './store'
import { ConcurrentAccessError, YamlStore } from './store'
describe('YamlStore.doGet', () => {
it('returns default when content is undefined', () => {
@ -14,51 +13,33 @@ describe('YamlStore.doGet', () => {
it('reads a flat key', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('name: alice\n')
store.raw_content = 'name: alice\n'
expect(store.doGet({ key: 'name', default: '' })).toBe('alice')
})
it('reads a nested key via dot notation', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('user:\n id: 42\n')
store.raw_content = 'user:\n id: 42\n'
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(42)
})
it('returns default for a missing flat key', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('name: alice\n')
store.raw_content = 'name: alice\n'
expect(store.doGet({ key: 'age', default: -1 })).toBe(-1)
})
it('returns default when an intermediate path segment is absent', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('user:\n name: bob\n')
store.raw_content = 'user:\n name: bob\n'
expect(store.doGet({ key: 'user.address.city', default: 'unknown' })).toBe('unknown')
})
it('returns default when an intermediate path segment is a scalar', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('user: scalar\n')
store.raw_content = 'user: scalar\n'
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(0)
})
it('throws BadYamlFormatError with file path, location, and snippet for malformed YAML', () => {
const path = '/irrelevant'
const store = new YamlStore(path)
store.setRawContent('name: alice\nuser:\n id: 42\n bad: indent\n')
let caught: unknown
try {
store.doGet({ key: 'name', default: '' })
}
catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(BadYamlFormatError)
const msg = (caught as BadYamlFormatError).message
expect(msg).toContain(path)
expect(msg).toMatch(/line \d+, column \d+/)
expect(msg).toContain('bad: indent')
})
})
describe('YamlStore.doSet', () => {
@ -76,7 +57,7 @@ describe('YamlStore.doSet', () => {
it('overwrites an existing key without disturbing siblings', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('name: alice\nage: 30\n')
store.raw_content = 'name: alice\nage: 30\n'
store.doSet({ key: 'name', default: '' }, 'bob')
expect(store.doGet({ key: 'name', default: '' })).toBe('bob')
expect(store.doGet({ key: 'age', default: 0 })).toBe(30)
@ -84,7 +65,7 @@ describe('YamlStore.doSet', () => {
it('replaces a scalar intermediate with an object when path deepens', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('user: scalar\n')
store.raw_content = 'user: scalar\n'
store.doSet({ key: 'user.id', default: 0 }, 99)
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(99)
})
@ -151,12 +132,12 @@ describe('YamlStore persistence', () => {
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.setRawContent('')
s1.raw_content = ''
s1.doSet({ key: 'workspace', default: '' }, 'ws-123')
writeFileSync(path, s1.getRawContent() ?? '')
writeFileSync(path, s1.raw_content ?? '')
const s2 = new YamlStore(path)
s2.setRawContent(readFileSync(path, 'utf8'))
s2.raw_content = readFileSync(path, 'utf8')
expect(s2.doGet({ key: 'workspace', default: '' })).toBe('ws-123')
})
@ -165,12 +146,12 @@ describe('YamlStore persistence', () => {
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.setRawContent('')
s1.raw_content = ''
s1.doSet({ key: 'a.b.c', default: '' }, 'deep')
writeFileSync(path, s1.getRawContent() ?? '')
writeFileSync(path, s1.raw_content ?? '')
const s2 = new YamlStore(path)
s2.setRawContent(readFileSync(path, 'utf8'))
s2.raw_content = readFileSync(path, 'utf8')
expect(s2.doGet({ key: 'a.b.c', default: '' })).toBe('deep')
})
@ -179,17 +160,17 @@ describe('YamlStore persistence', () => {
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.setRawContent('')
s1.raw_content = ''
s1.doSet({ key: 'x', default: '' }, 'first')
writeFileSync(path, s1.getRawContent() ?? '')
writeFileSync(path, s1.raw_content ?? '')
const s2 = new YamlStore(path)
s2.setRawContent(readFileSync(path, 'utf8'))
s2.raw_content = readFileSync(path, 'utf8')
s2.doSet({ key: 'y', default: '' }, 'second')
writeFileSync(path, s2.getRawContent() ?? '')
writeFileSync(path, s2.raw_content ?? '')
const s3 = new YamlStore(path)
s3.setRawContent(readFileSync(path, 'utf8'))
s3.raw_content = readFileSync(path, 'utf8')
expect(s3.doGet({ key: 'x', default: '' })).toBe('first')
expect(s3.doGet({ key: 'y', default: '' })).toBe('second')
})
@ -205,28 +186,8 @@ describe('YamlStore persistence', () => {
const raw = readFileSync(path, 'utf8')
const store2 = new YamlStore(path)
store2.setRawContent(raw)
store2.raw_content = raw
expect(store2.doGet({ key: 'token', default: '' })).toBe('abc-123')
expect(store2.doGet({ key: 'existing', default: '' })).toBe('value')
})
it('flush writes file when dirty (content changed from undefined)', () => {
const path = join(dir, 'config.yml')
const store = new YamlStore(path)
store.setRawContent('key: value\n')
store.flush()
expect(existsSync(path)).toBe(true)
expect(readFileSync(path, 'utf8')).toBe('key: value\n')
})
it('flush is a no-op when loaded content is set back unchanged', async () => {
const path = join(dir, 'config.yml')
await writeFile(path, 'key: value\n')
const store = new YamlStore(path)
store.load()
const mtime = statSync(path).mtimeMs
store.setRawContent('key: value\n')
store.flush()
expect(statSync(path).mtimeMs).toBe(mtime)
})
})

View File

@ -1,16 +1,14 @@
import type { Platform } from '../sys'
import fs from 'node:fs'
import { dirname } from 'node:path'
import { Entry } from '@napi-rs/keyring'
import yaml from 'js-yaml'
import lockfile from 'lockfile'
import { pid, resolvePlatform } from '../sys'
import { BadYamlFormatError, ConcurrentAccessError } from './errors'
const FILE_PERM = 0o600
const DIR_PERM = 0o700
export type Key<T> = {
type Key<T> = {
default: T
key: string
}
@ -18,43 +16,38 @@ export type Key<T> = {
export type Store = {
get: <T>(key: Key<T>) => T
set: <T>(key: Key<T>, value: T) => void
unset: <T>(key: Key<T>) => void
}
export type StorageMode = 'keychain' | 'file'
export class ConcurrentAccessError extends Error {
constructor(filePath: string) {
super(`Another process is modifying the file ${filePath}. remove ${filePath}.lock to reset lock.`)
}
}
abstract class FileBasedStore implements Store {
filePath: string
private rawContent: string | undefined
file_path: string
raw_content: string | undefined
private readonly platform: Platform
private dirty: boolean = false
constructor(filePath: string) {
this.filePath = filePath
constructor(file_path: string) {
this.file_path = file_path
this.platform = resolvePlatform()
fs.mkdirSync(dirname(this.file_path), { recursive: true, mode: DIR_PERM })
}
unlock(): void {
lockfile.unlockSync(`${this.filePath}.lock`)
lockfile.unlockSync(`${this.file_path}.lock`)
}
/**
* atomically write raw_content (if any)
*/
flush(): void {
fs.mkdirSync(dirname(this.filePath), { recursive: true, mode: DIR_PERM })
// we don't handle A-B-A scenario,
// which is not likely to happen in cli
if (!this.dirty) {
return
}
if (this.rawContent !== undefined) {
const tmp = `${this.filePath}.tmp.${pid()}.${Date.now()}`
if (this.raw_content !== undefined) {
const tmp = `${this.file_path}.tmp.${pid()}.${Date.now()}`
try {
fs.writeFileSync(tmp, this.rawContent, { mode: FILE_PERM })
this.platform.atomicReplace(tmp, this.filePath)
fs.writeFileSync(tmp, this.raw_content, { mode: FILE_PERM })
this.platform.atomicReplace(tmp, this.file_path)
}
catch (err) {
try {
@ -64,20 +57,16 @@ abstract class FileBasedStore implements Store {
throw err
}
}
this.dirty = false
}
lock(): void {
try {
lockfile.lockSync(`${this.filePath}.lock`, {
stale: 30_000,
})
lockfile.lockSync(`${this.file_path}.lock`)
}
catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EEXIST') {
throw new ConcurrentAccessError(this.filePath)
throw new ConcurrentAccessError(this.file_path)
}
throw err
}
@ -85,8 +74,7 @@ abstract class FileBasedStore implements Store {
load(): void {
try {
this.rawContent = fs.readFileSync(this.filePath, 'utf8')
this.dirty = false
this.raw_content = fs.readFileSync(this.file_path, 'utf8')
}
catch (err) {
const code = (err as NodeJS.ErrnoException).code
@ -96,18 +84,10 @@ abstract class FileBasedStore implements Store {
}
}
public setRawContent(content: string): void {
this.dirty = (content !== this.getRawContent())
this.rawContent = content
}
public getRawContent(): string | undefined {
return this.rawContent
}
protected withLock<R>(body: () => R): R {
this.lock()
try {
this.load()
return body()
}
finally {
@ -116,44 +96,18 @@ abstract class FileBasedStore implements Store {
}
get<T>(key: Key<T>): T {
return this.withLock(() => {
this.load()
return this.doGet(key)
})
return this.withLock(() => this.doGet(key))
}
set<T>(key: Key<T>, value: T) {
this.withLock(() => {
this.load()
this.doSet(key, value)
this.flush()
})
}
unset<T>(key: Key<T>): void {
this.withLock(() => {
this.load()
this.doUnset(key)
this.flush()
})
}
/**
* Remove the underlying file of the store. No-op if file doesn't exist.
*/
rm(): void {
try {
fs.unlinkSync(this.filePath)
}
catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
throw err
}
}
abstract doGet<T>(key: Key<T>): T
abstract doSet<T>(key: Key<T>, value: T): void
abstract doUnset<T>(key: Key<T>): void
}
export class YamlStore extends FileBasedStore {
@ -162,7 +116,7 @@ export class YamlStore extends FileBasedStore {
}
doGet<T>(key: Key<T>): T {
const data = loadYaml(this.getRawContent(), this.filePath)
const data = loadYaml(this.raw_content)
const parts = key.key.split('.')
let current: unknown = data
for (const part of parts) {
@ -176,20 +130,19 @@ export class YamlStore extends FileBasedStore {
getTyped<T>(): T | null {
return this.withLock(() => {
this.load()
return loadYaml(this.getRawContent(), this.filePath) as T
return loadYaml(this.raw_content) as T
})
}
setTyped<T>(data: T): void {
this.withLock(() => {
this.load()
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true })
this.flush()
})
}
doSet<T>(key: Key<T>, value: T): void {
const data = loadYaml(this.getRawContent(), this.filePath) || {}
const data = loadYaml(this.raw_content) || {}
const parts = key.key.split('.')
const lastKey = parts.pop()
if (lastKey === undefined)
@ -201,74 +154,12 @@ export class YamlStore extends FileBasedStore {
current = current[part] as Record<string, unknown>
}
current[lastKey] = value
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
}
doUnset<T>(key: Key<T>): void {
const data = loadYaml(this.getRawContent(), this.filePath) || {}
const parts = key.key.split('.')
const lastKey = parts.pop()
if (lastKey === undefined)
return
let current: Record<string, unknown> = data
for (const part of parts) {
const next = current[part]
if (next === null || next === undefined || typeof next !== 'object')
return
current = next as Record<string, unknown>
}
if (!(lastKey in current))
return
delete current[lastKey]
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true })
}
}
function loadYaml(raw: string | undefined, file_path: string): Record<string, unknown> | null {
function loadYaml(raw: string | undefined): Record<string, unknown> | null {
if (raw === undefined)
return null
try {
return (yaml.load(raw) ?? {}) as Record<string, unknown>
}
catch (err) {
if (err instanceof yaml.YAMLException)
throw new BadYamlFormatError(file_path, raw, err)
throw err
}
}
/**
* OS-keyring-based storage primitive. Sits at the same layer as
* `FileBasedStore`: implements `Store` with each `Key<T>` corresponding to a
* single keyring entry under the configured service. Values are JSON-encoded.
*/
export class KeyringBasedStore implements Store {
private readonly service: string
constructor(service: string) {
this.service = service
}
get<T>(key: Key<T>): T {
try {
const v = new Entry(this.service, key.key).getPassword()
if (v === null || v === undefined || v === '')
return key.default
return JSON.parse(v) as T
}
catch {
return key.default
}
}
set<T>(key: Key<T>, value: T): void {
new Entry(this.service, key.key).setPassword(JSON.stringify(value))
}
unset<T>(key: Key<T>): void {
try {
new Entry(this.service, key.key).deletePassword()
}
catch { /* missing entry is fine */ }
}
return (yaml.load(raw) ?? {}) as Record<string, unknown>
}

View File

@ -5,8 +5,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadNudgeStore } from '../cache/nudge-store.js'
import { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_NUDGE, getCache } from '../store/manager.js'
import { CACHE_NUDGE, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { maybeNudgeCompat } from './nudge.js'
const HOST = 'https://cloud.dify.ai'
@ -44,18 +44,11 @@ describe('maybeNudgeCompat', () => {
let dir: string
let store: NudgeStore
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
store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow })
store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: fixedNow })
})
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 })
})
@ -85,12 +78,12 @@ describe('maybeNudgeCompat', () => {
it('warns again after the silence window has elapsed', async () => {
const yesterday = new Date(NOW.getTime() - 25 * 60 * 60 * 1000)
const tStore = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => yesterday })
const tStore = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => yesterday })
await tStore.markWarned(HOST)
const probe = vi.fn(async () => UNSUPPORTED)
const { emit, lines } = emitterSpy()
const freshStore = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow })
const freshStore = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: fixedNow })
await maybeNudgeCompat(HOST, baseDeps({ store: freshStore, probe, emit }))
expect(probe).toHaveBeenCalledOnce()

View File

@ -160,8 +160,7 @@ describe('runVersionProbe', () => {
const url = new URL(mock.url)
const prevConfig = process.env[ENV_CONFIG_DIR]
try {
process.env[ENV_CONFIG_DIR] = configDir
saveHosts({
await saveHosts(configDir, {
current_host: url.host,
scheme: url.protocol.replace(':', ''),
token_storage: 'file',

View File

@ -5,6 +5,7 @@ import type { Channel } from './info.js'
import { META_PROBE_TIMEOUT_MS, MetaClient } from '../api/meta.js'
import { loadHosts } from '../auth/hosts.js'
import { createClient } from '../http/client.js'
import { resolveConfigDir } from '../store/dir.js'
import { arch, platform } from '../sys/index.js'
import { hostWithScheme } from '../util/host.js'
import { difyCompat, evaluateCompat } from './compat.js'
@ -47,7 +48,7 @@ export type RunVersionProbeOptions = {
readonly probe?: MetaProbe
}
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts()
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts(resolveConfigDir())
const defaultProbe: MetaProbe = async (endpoint) => {
const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })

View File

@ -7,8 +7,6 @@ REPO_ROOT="$SCRIPT_DIR/.."
cd "$REPO_ROOT"
EXCLUDES_FILE="api/pyrefly-local-excludes.txt"
TEST_CONTAINERS_DIR="tests/test_containers_integration_tests"
TEST_CONTAINERS_CONFIG="$TEST_CONTAINERS_DIR/pyrefly.toml"
target_paths=()
for target_path in "$@"; do
@ -35,22 +33,7 @@ if [[ -f "$EXCLUDES_FILE" ]]; then
done < "$EXCLUDES_FILE"
fi
run_pyrefly() {
local tmp_output
tmp_output="$(mktemp)"
set +e
"$@" >"$tmp_output" 2>&1
local pyrefly_status=$?
set -e
uv run --directory api python libs/pyrefly_diagnostics.py < "$tmp_output"
rm -f "$tmp_output"
return "$pyrefly_status"
}
status=0
tmp_output="$(mktemp)"
pyrefly_command=(
uv run --directory api --dev pyrefly check
"${pyrefly_args[@]}"
@ -59,15 +42,12 @@ if (( ${#target_paths[@]} > 0 )); then
pyrefly_command+=("${target_paths[@]}")
fi
run_pyrefly "${pyrefly_command[@]}" || status=$?
set +e
"${pyrefly_command[@]}" >"$tmp_output" 2>&1
pyrefly_status=$?
set -e
if (( ${#target_paths[@]} == 0 )); then
run_pyrefly \
uv run --directory api --dev pyrefly check \
"--summary=none" \
"--use-ignore-files=false" \
"--config=$TEST_CONTAINERS_CONFIG" \
|| status=$?
fi
uv run --directory api python libs/pyrefly_diagnostics.py < "$tmp_output"
rm -f "$tmp_output"
exit "$status"
exit "$pyrefly_status"

View File

@ -3693,11 +3693,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-icon.tsx": {
"react/static-components": {
"count": 2

View File

@ -12,9 +12,7 @@ import {
zGetAgentsByAgentIdVersionsByVersionIdResponse,
zGetAgentsByAgentIdVersionsPath,
zGetAgentsByAgentIdVersionsResponse,
zGetAgentsInviteOptionsQuery,
zGetAgentsInviteOptionsResponse,
zGetAgentsQuery,
zGetAgentsResponse,
zPatchAgentsByAgentIdBody,
zPatchAgentsByAgentIdPath,
@ -23,15 +21,22 @@ import {
zPostAgentsResponse,
} from './zod.gen'
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentsInviteOptions',
path: '/agents/invite-options',
tags: ['console'],
})
.input(z.object({ query: zGetAgentsInviteOptionsQuery.optional() }))
.output(zGetAgentsInviteOptionsResponse)
export const inviteOptions = {
@ -61,8 +66,16 @@ export const byVersionId = {
get: get2,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get3 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentsByAgentIdVersions',
@ -77,20 +90,35 @@ export const versions = {
byVersionId,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const delete_ = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'deleteAgentsByAgentId',
path: '/agents/{agent_id}',
successStatus: 204,
tags: ['console'],
})
.input(z.object({ params: zDeleteAgentsByAgentIdPath }))
.output(zDeleteAgentsByAgentIdResponse)
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get4 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentsByAgentId',
@ -100,8 +128,16 @@ export const get4 = oc
.input(z.object({ params: zGetAgentsByAgentIdPath }))
.output(zGetAgentsByAgentIdResponse)
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const patch = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'patchAgentsByAgentId',
@ -118,15 +154,22 @@ export const byAgentId = {
versions,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get5 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgents',
path: '/agents',
tags: ['console'],
})
.input(z.object({ query: zGetAgentsQuery.optional() }))
.output(zGetAgentsResponse)
/**
@ -143,7 +186,6 @@ export const post = oc
method: 'POST',
operationId: 'postAgents',
path: '/agents',
successStatus: 201,
tags: ['console'],
})
.input(z.object({ body: zPostAgentsBody }))

View File

@ -4,14 +4,6 @@ export type ClientOptions = {
baseUrl: `${string}://${string}/console/api` | (string & {})
}
export type AgentRosterListResponse = {
data: Array<AgentRosterResponse>
has_more: boolean
limit: number
page: number
total: number
}
export type RosterAgentCreatePayload = {
agent_soul?: AgentSoulConfig
description?: string
@ -22,38 +14,6 @@ export type RosterAgentCreatePayload = {
version_note?: string | null
}
export type AgentRosterResponse = {
active_config_snapshot?: AgentConfigSnapshotSummaryResponse
active_config_snapshot_id?: string | null
agent_kind: AgentKind
app_id?: string | null
archived_at?: string | null
archived_by?: string | null
created_at?: string | null
created_by?: string | null
description: string
icon?: string | null
icon_background?: string | null
icon_type?: AgentIconType
id: string
name: string
scope: AgentScope
source: AgentSource
status: AgentStatus
updated_at?: string | null
updated_by?: string | null
workflow_id?: string | null
workflow_node_id?: string | null
}
export type AgentInviteOptionsResponse = {
data: Array<AgentInviteOptionResponse>
has_more: boolean
limit: number
page: number
total: number
}
export type RosterAgentUpdatePayload = {
description?: string | null
icon?: string | null
@ -62,22 +22,6 @@ export type RosterAgentUpdatePayload = {
name?: string | null
}
export type AgentConfigSnapshotListResponse = {
data: Array<AgentConfigSnapshotSummaryResponse>
}
export type AgentConfigSnapshotDetailResponse = {
agent_id?: string | null
config_snapshot: AgentSoulConfig
created_at?: string | null
created_by?: string | null
id: string
revisions?: Array<AgentConfigRevisionResponse>
summary?: string | null
version: number
version_note?: string | null
}
export type AgentSoulConfig = {
app_features?: {
[key: string]: unknown
@ -100,63 +44,6 @@ export type AgentSoulConfig = {
export type AgentIconType = 'emoji' | 'image' | 'link'
export type AgentConfigSnapshotSummaryResponse = {
agent_id?: string | null
created_at?: string | null
created_by?: string | null
id: string
summary?: string | null
version: number
version_note?: string | null
}
export type AgentKind = 'dify_agent'
export type AgentScope = 'roster' | 'workflow_only'
export type AgentSource = 'agent_app' | 'imported' | 'system' | 'workflow'
export type AgentStatus = 'active' | 'archived'
export type AgentInviteOptionResponse = {
active_config_snapshot?: AgentConfigSnapshotSummaryResponse
active_config_snapshot_id?: string | null
agent_kind: AgentKind
app_id?: string | null
archived_at?: string | null
archived_by?: string | null
created_at?: string | null
created_by?: string | null
description: string
existing_node_ids?: Array<string>
icon?: string | null
icon_background?: string | null
icon_type?: AgentIconType
id: string
in_current_workflow_count?: number
is_in_current_workflow?: boolean
name: string
scope: AgentScope
source: AgentSource
status: AgentStatus
updated_at?: string | null
updated_by?: string | null
workflow_id?: string | null
workflow_node_id?: string | null
}
export type AgentConfigRevisionResponse = {
created_at?: string | null
created_by?: string | null
current_snapshot_id: string
id: string
operation: AgentConfigRevisionOperation
previous_snapshot_id?: string | null
revision: number
summary?: string | null
version_note?: string | null
}
export type AppVariableConfig = {
default?: unknown
name: string
@ -237,13 +124,6 @@ export type AgentSoulToolsConfig = {
dify_tools?: Array<AgentSoulDifyToolConfig>
}
export type AgentConfigRevisionOperation
= | 'create_version'
| 'save_current_version'
| 'save_new_agent'
| 'save_new_version'
| 'save_to_roster'
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
export type AgentSoulModelCredentialRef = {
@ -277,16 +157,14 @@ export type AgentSoulDifyToolCredentialRef = {
export type GetAgentsData = {
body?: never
path?: never
query?: {
keyword?: string
limit?: number
page?: number
}
query?: never
url: '/agents'
}
export type GetAgentsResponses = {
200: AgentRosterListResponse
200: {
[key: string]: unknown
}
}
export type GetAgentsResponse = GetAgentsResponses[keyof GetAgentsResponses]
@ -299,7 +177,9 @@ export type PostAgentsData = {
}
export type PostAgentsResponses = {
201: AgentRosterResponse
200: {
[key: string]: unknown
}
}
export type PostAgentsResponse = PostAgentsResponses[keyof PostAgentsResponses]
@ -307,17 +187,14 @@ export type PostAgentsResponse = PostAgentsResponses[keyof PostAgentsResponses]
export type GetAgentsInviteOptionsData = {
body?: never
path?: never
query?: {
app_id?: string
keyword?: string
limit?: number
page?: number
}
query?: never
url: '/agents/invite-options'
}
export type GetAgentsInviteOptionsResponses = {
200: AgentInviteOptionsResponse
200: {
[key: string]: unknown
}
}
export type GetAgentsInviteOptionsResponse
@ -333,8 +210,8 @@ export type DeleteAgentsByAgentIdData = {
}
export type DeleteAgentsByAgentIdResponses = {
204: {
[key: string]: never
200: {
[key: string]: unknown
}
}
@ -351,7 +228,9 @@ export type GetAgentsByAgentIdData = {
}
export type GetAgentsByAgentIdResponses = {
200: AgentRosterResponse
200: {
[key: string]: unknown
}
}
export type GetAgentsByAgentIdResponse
@ -367,7 +246,9 @@ export type PatchAgentsByAgentIdData = {
}
export type PatchAgentsByAgentIdResponses = {
200: AgentRosterResponse
200: {
[key: string]: unknown
}
}
export type PatchAgentsByAgentIdResponse
@ -383,7 +264,9 @@ export type GetAgentsByAgentIdVersionsData = {
}
export type GetAgentsByAgentIdVersionsResponses = {
200: AgentConfigSnapshotListResponse
200: {
[key: string]: unknown
}
}
export type GetAgentsByAgentIdVersionsResponse
@ -400,7 +283,9 @@ export type GetAgentsByAgentIdVersionsByVersionIdData = {
}
export type GetAgentsByAgentIdVersionsByVersionIdResponses = {
200: AgentConfigSnapshotDetailResponse
200: {
[key: string]: unknown
}
}
export type GetAgentsByAgentIdVersionsByVersionIdResponse

View File

@ -20,136 +20,6 @@ export const zRosterAgentUpdatePayload = z.object({
name: z.string().min(1).max(255).nullish(),
})
/**
* AgentConfigSnapshotSummaryResponse
*/
export const zAgentConfigSnapshotSummaryResponse = z.object({
agent_id: z.string().nullish(),
created_at: z.string().nullish(),
created_by: z.string().nullish(),
id: z.string(),
summary: z.string().nullish(),
version: z.int(),
version_note: z.string().nullish(),
})
/**
* AgentConfigSnapshotListResponse
*/
export const zAgentConfigSnapshotListResponse = z.object({
data: z.array(zAgentConfigSnapshotSummaryResponse),
})
/**
* AgentKind
*
* Agent implementation family.
*
* This leaves room for future non-Dify agent implementations while keeping
* the current roster/workflow APIs scoped to Dify Agent.
*/
export const zAgentKind = z.enum(['dify_agent'])
/**
* AgentScope
*
* Visibility and lifecycle scope of an Agent record.
*/
export const zAgentScope = z.enum(['roster', 'workflow_only'])
/**
* AgentSource
*
* Origin that created or imported the Agent.
*/
export const zAgentSource = z.enum(['agent_app', 'imported', 'system', 'workflow'])
/**
* AgentStatus
*
* Soft lifecycle state for Agent records.
*/
export const zAgentStatus = z.enum(['active', 'archived'])
/**
* AgentRosterResponse
*/
export const zAgentRosterResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.optional(),
active_config_snapshot_id: z.string().nullish(),
agent_kind: zAgentKind,
app_id: z.string().nullish(),
archived_at: z.string().nullish(),
archived_by: z.string().nullish(),
created_at: z.string().nullish(),
created_by: z.string().nullish(),
description: z.string(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: zAgentIconType.optional(),
id: z.string(),
name: z.string(),
scope: zAgentScope,
source: zAgentSource,
status: zAgentStatus,
updated_at: z.string().nullish(),
updated_by: z.string().nullish(),
workflow_id: z.string().nullish(),
workflow_node_id: z.string().nullish(),
})
/**
* AgentRosterListResponse
*/
export const zAgentRosterListResponse = z.object({
data: z.array(zAgentRosterResponse),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* AgentInviteOptionResponse
*/
export const zAgentInviteOptionResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.optional(),
active_config_snapshot_id: z.string().nullish(),
agent_kind: zAgentKind,
app_id: z.string().nullish(),
archived_at: z.string().nullish(),
archived_by: z.string().nullish(),
created_at: z.string().nullish(),
created_by: z.string().nullish(),
description: z.string(),
existing_node_ids: z.array(z.string()).optional(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: zAgentIconType.optional(),
id: z.string(),
in_current_workflow_count: z.int().optional().default(0),
is_in_current_workflow: z.boolean().optional().default(false),
name: z.string(),
scope: zAgentScope,
source: zAgentSource,
status: zAgentStatus,
updated_at: z.string().nullish(),
updated_by: z.string().nullish(),
workflow_id: z.string().nullish(),
workflow_node_id: z.string().nullish(),
})
/**
* AgentInviteOptionsResponse
*/
export const zAgentInviteOptionsResponse = z.object({
data: z.array(zAgentInviteOptionResponse),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* AppVariableConfig
*/
@ -208,34 +78,6 @@ export const zAgentSoulSkillsFilesConfig = z.object({
skills: z.array(z.record(z.string(), z.unknown())).optional(),
})
/**
* AgentConfigRevisionOperation
*
* Audit operation recorded for Agent Soul version/revision changes.
*/
export const zAgentConfigRevisionOperation = z.enum([
'create_version',
'save_current_version',
'save_new_agent',
'save_new_version',
'save_to_roster',
])
/**
* AgentConfigRevisionResponse
*/
export const zAgentConfigRevisionResponse = z.object({
created_at: z.string().nullish(),
created_by: z.string().nullish(),
current_snapshot_id: z.string(),
id: z.string(),
operation: zAgentConfigRevisionOperation,
previous_snapshot_id: z.string().nullish(),
revision: z.int(),
summary: z.string().nullish(),
version_note: z.string().nullish(),
})
/**
* AgentKnowledgeQueryMode
*/
@ -354,67 +196,39 @@ export const zRosterAgentCreatePayload = z.object({
})
/**
* AgentConfigSnapshotDetailResponse
* Success
*/
export const zAgentConfigSnapshotDetailResponse = z.object({
agent_id: z.string().nullish(),
config_snapshot: zAgentSoulConfig,
created_at: z.string().nullish(),
created_by: z.string().nullish(),
id: z.string(),
revisions: z.array(zAgentConfigRevisionResponse).optional(),
summary: z.string().nullish(),
version: z.int(),
version_note: z.string().nullish(),
})
export const zGetAgentsQuery = z.object({
keyword: z.string().optional(),
limit: z.int().gte(1).lte(100).optional().default(20),
page: z.int().gte(1).optional().default(1),
})
/**
* Agent roster list
*/
export const zGetAgentsResponse = zAgentRosterListResponse
export const zGetAgentsResponse = z.record(z.string(), z.unknown())
export const zPostAgentsBody = zRosterAgentCreatePayload
/**
* Agent created
* Success
*/
export const zPostAgentsResponse = zAgentRosterResponse
export const zGetAgentsInviteOptionsQuery = z.object({
app_id: z.string().optional(),
keyword: z.string().optional(),
limit: z.int().gte(1).lte(100).optional().default(20),
page: z.int().gte(1).optional().default(1),
})
export const zPostAgentsResponse = z.record(z.string(), z.unknown())
/**
* Agent invite options
* Success
*/
export const zGetAgentsInviteOptionsResponse = zAgentInviteOptionsResponse
export const zGetAgentsInviteOptionsResponse = z.record(z.string(), z.unknown())
export const zDeleteAgentsByAgentIdPath = z.object({
agent_id: z.string(),
})
/**
* Agent archived
* Success
*/
export const zDeleteAgentsByAgentIdResponse = z.record(z.string(), z.never())
export const zDeleteAgentsByAgentIdResponse = z.record(z.string(), z.unknown())
export const zGetAgentsByAgentIdPath = z.object({
agent_id: z.string(),
})
/**
* Agent detail
* Success
*/
export const zGetAgentsByAgentIdResponse = zAgentRosterResponse
export const zGetAgentsByAgentIdResponse = z.record(z.string(), z.unknown())
export const zPatchAgentsByAgentIdBody = zRosterAgentUpdatePayload
@ -423,18 +237,18 @@ export const zPatchAgentsByAgentIdPath = z.object({
})
/**
* Agent updated
* Success
*/
export const zPatchAgentsByAgentIdResponse = zAgentRosterResponse
export const zPatchAgentsByAgentIdResponse = z.record(z.string(), z.unknown())
export const zGetAgentsByAgentIdVersionsPath = z.object({
agent_id: z.string(),
})
/**
* Agent versions
* Success
*/
export const zGetAgentsByAgentIdVersionsResponse = zAgentConfigSnapshotListResponse
export const zGetAgentsByAgentIdVersionsResponse = z.record(z.string(), z.unknown())
export const zGetAgentsByAgentIdVersionsByVersionIdPath = z.object({
agent_id: z.string(),
@ -442,6 +256,6 @@ export const zGetAgentsByAgentIdVersionsByVersionIdPath = z.object({
})
/**
* Agent version detail
* Success
*/
export const zGetAgentsByAgentIdVersionsByVersionIdResponse = zAgentConfigSnapshotDetailResponse
export const zGetAgentsByAgentIdVersionsByVersionIdResponse = z.record(z.string(), z.unknown())

View File

@ -352,7 +352,6 @@ import {
zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunBody,
zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunPath,
zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody,
@ -3517,12 +3516,7 @@ export const post47 = oc
path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact',
tags: ['console'],
})
.input(
z.object({
body: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody,
params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath,
}),
)
.input(z.object({ params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath }))
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse)
export const impact = {

View File

@ -167,14 +167,6 @@ export type AdvancedChatWorkflowRunPayload = {
query?: string
}
export type AgentAppComposerResponse = {
active_config_snapshot: AgentConfigSnapshotSummaryResponse
agent: AgentComposerAgentResponse
agent_soul: AgentSoulConfig
save_options: Array<ComposerSaveStrategy>
variant: string
}
export type ComposerSavePayload = {
agent_soul?: AgentSoulConfig
binding?: ComposerBindingPayload
@ -188,18 +180,6 @@ export type ComposerSavePayload = {
version_note?: string | null
}
export type AgentComposerCandidatesResponse = {
allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse
allowed_soul_candidates?: AgentComposerSoulCandidatesResponse
capabilities?: ComposerCandidateCapabilities
variant: ComposerVariant
}
export type AgentComposerValidateResponse = {
errors?: Array<string>
result: string
}
export type AnnotationReplyPayload = {
embedding_model_name: string
embedding_provider_name: string
@ -756,28 +736,6 @@ export type HumanInputDeliveryTestPayload = {
}
}
export type WorkflowAgentComposerResponse = {
active_config_snapshot?: AgentConfigSnapshotSummaryResponse
agent?: AgentComposerAgentResponse
agent_soul: AgentSoulConfig
app_id?: string | null
binding?: AgentComposerBindingResponse
effective_declared_outputs?: Array<DeclaredOutputConfig>
impact_summary?: AgentComposerImpactResponse
node_id?: string | null
node_job: WorkflowNodeJobConfig
save_options: Array<ComposerSaveStrategy>
soul_lock: AgentComposerSoulLockResponse
variant: string
workflow_id?: string | null
}
export type AgentComposerImpactResponse = {
bindings?: Array<AgentComposerImpactBindingResponse>
current_snapshot_id?: string | null
workflow_node_count: number
}
export type WorkflowRunNodeExecutionResponse = {
created_at?: number | null
created_by_account?: SimpleAccount
@ -1003,25 +961,6 @@ export type AdvancedChatWorkflowRunForListResponse = {
version?: string | null
}
export type AgentConfigSnapshotSummaryResponse = {
agent_id?: string | null
created_at?: string | null
created_by?: string | null
id: string
summary?: string | null
version: number
version_note?: string | null
}
export type AgentComposerAgentResponse = {
active_config_snapshot_id?: string | null
description: string
id: string
name: string
scope: AgentScope
status: AgentStatus
}
export type AgentSoulConfig = {
app_features?: {
[key: string]: unknown
@ -1042,13 +981,6 @@ export type AgentSoulConfig = {
tools?: AgentSoulToolsConfig
}
export type ComposerSaveStrategy
= | 'node_job_only'
| 'save_as_new_agent'
| 'save_as_new_version'
| 'save_to_current_version'
| 'save_to_roster'
export type ComposerBindingPayload = {
agent_id?: string | null
binding_type: 'inline_agent' | 'roster_agent'
@ -1071,6 +1003,13 @@ export type WorkflowNodeJobConfig = {
workflow_prompt?: string
}
export type ComposerSaveStrategy
= | 'node_job_only'
| 'save_as_new_agent'
| 'save_as_new_version'
| 'save_to_current_version'
| 'save_to_roster'
export type ComposerSoulLockPayload = {
locked?: boolean
unlocked_from_version_id?: string | null
@ -1078,38 +1017,6 @@ export type ComposerSoulLockPayload = {
export type ComposerVariant = 'agent_app' | 'workflow'
export type AgentComposerNodeJobCandidatesResponse = {
declare_output_types?: Array<DeclaredOutputType>
human_contacts?: Array<{
[key: string]: unknown
}>
previous_node_outputs?: Array<{
[key: string]: unknown
}>
}
export type AgentComposerSoulCandidatesResponse = {
cli_tools?: Array<{
[key: string]: unknown
}>
dify_tools?: Array<{
[key: string]: unknown
}>
human_contacts?: Array<{
[key: string]: unknown
}>
knowledge_datasets?: Array<{
[key: string]: unknown
}>
skills_files?: Array<{
[key: string]: unknown
}>
}
export type ComposerCandidateCapabilities = {
human_roster_available?: boolean
}
export type AnnotationHitHistory = {
annotation_content?: string | null
annotation_question?: string | null
@ -1398,39 +1305,6 @@ export type PipelineVariableResponse = {
variable: string
}
export type AgentComposerBindingResponse = {
agent_id?: string | null
binding_type: WorkflowAgentBindingType
current_snapshot_id?: string | null
id: string
node_id: string
workflow_id: string
}
export type DeclaredOutputConfig = {
array_item?: DeclaredArrayItem
check?: DeclaredOutputCheckConfig
description?: string | null
failure_strategy?: DeclaredOutputFailureStrategy
file?: DeclaredOutputFileConfig
id?: string | null
name: string
required?: boolean
type: DeclaredOutputType
}
export type AgentComposerSoulLockResponse = {
can_unlock?: boolean
locked: boolean
reason?: string | null
}
export type AgentComposerImpactBindingResponse = {
app_id: string
node_id: string
workflow_id: string
}
export type WorkflowDraftVariableWithoutValue = {
description?: string
edited?: boolean
@ -1479,10 +1353,6 @@ export type WorkflowOnlineUser = {
username: string
}
export type AgentScope = 'roster' | 'workflow_only'
export type AgentStatus = 'active' | 'archived'
export type AppVariableConfig = {
default?: unknown
name: string
@ -1563,9 +1433,19 @@ export type AgentSoulToolsConfig = {
dify_tools?: Array<AgentSoulDifyToolConfig>
}
export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do'
export type DeclaredOutputConfig = {
array_item?: DeclaredArrayItem
check?: DeclaredOutputCheckConfig
description?: string | null
failure_strategy?: DeclaredOutputFailureStrategy
file?: DeclaredOutputFileConfig
id?: string | null
name: string
required?: boolean
type: DeclaredOutputType
}
export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do'
export type SimpleModelConfig = {
model_dict?: JsonValue
@ -1635,7 +1515,29 @@ export type WorkflowRunForArchivedLogResponse = {
triggered_from?: string | null
}
export type WorkflowAgentBindingType = 'inline_agent' | 'roster_agent'
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
export type AgentSoulModelCredentialRef = {
id?: string | null
provider?: string | null
type: string
}
export type AgentSoulDifyToolConfig = {
credential_ref?: AgentSoulDifyToolCredentialRef
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
description?: string | null
enabled?: boolean
name?: string | null
plugin_id?: string | null
provider?: string | null
provider_id?: string | null
provider_type?: string
runtime_parameters?: {
[key: string]: unknown
}
tool_name: string
}
export type DeclaredArrayItem = {
description?: string | null
@ -1664,29 +1566,7 @@ export type DeclaredOutputFileConfig = {
mime_types?: Array<string>
}
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
export type AgentSoulModelCredentialRef = {
id?: string | null
provider?: string | null
type: string
}
export type AgentSoulDifyToolConfig = {
credential_ref?: AgentSoulDifyToolCredentialRef
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
description?: string | null
enabled?: boolean
name?: string | null
plugin_id?: string | null
provider?: string | null
provider_id?: string | null
provider_type?: string
runtime_parameters?: {
[key: string]: unknown
}
tool_name: string
}
export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
export type UserActionConfig = {
button_style?: ButtonStyle
@ -1696,6 +1576,12 @@ export type UserActionConfig = {
export type FormInputConfig = unknown
export type AgentSoulDifyToolCredentialRef = {
id?: string | null
provider?: string | null
type?: 'provider' | 'tool'
}
export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop'
export type DeclaredOutputRetryConfig = {
@ -1704,12 +1590,6 @@ export type DeclaredOutputRetryConfig = {
retry_interval_ms?: number
}
export type AgentSoulDifyToolCredentialRef = {
id?: string | null
provider?: string | null
type?: 'provider' | 'tool'
}
export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary'
export type ParagraphInputConfig = {
@ -2182,7 +2062,9 @@ export type GetAppsByAppIdAgentComposerData = {
}
export type GetAppsByAppIdAgentComposerResponses = {
200: AgentAppComposerResponse
200: {
[key: string]: unknown
}
}
export type GetAppsByAppIdAgentComposerResponse
@ -2198,7 +2080,9 @@ export type PutAppsByAppIdAgentComposerData = {
}
export type PutAppsByAppIdAgentComposerResponses = {
200: AgentAppComposerResponse
200: {
[key: string]: unknown
}
}
export type PutAppsByAppIdAgentComposerResponse
@ -2214,7 +2098,9 @@ export type GetAppsByAppIdAgentComposerCandidatesData = {
}
export type GetAppsByAppIdAgentComposerCandidatesResponses = {
200: AgentComposerCandidatesResponse
200: {
[key: string]: unknown
}
}
export type GetAppsByAppIdAgentComposerCandidatesResponse
@ -2230,7 +2116,9 @@ export type PostAppsByAppIdAgentComposerValidateData = {
}
export type PostAppsByAppIdAgentComposerValidateResponses = {
200: AgentComposerValidateResponse
200: {
[key: string]: unknown
}
}
export type PostAppsByAppIdAgentComposerValidateResponse
@ -4627,7 +4515,9 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerData = {
}
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = {
200: WorkflowAgentComposerResponse
200: {
[key: string]: unknown
}
}
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
@ -4644,7 +4534,9 @@ export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerData = {
}
export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = {
200: WorkflowAgentComposerResponse
200: {
[key: string]: unknown
}
}
export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
@ -4661,14 +4553,16 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesData
}
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses = {
200: AgentComposerCandidatesResponse
200: {
[key: string]: unknown
}
}
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse
= GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses]
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData = {
body: ComposerSavePayload
body?: never
path: {
app_id: string
node_id: string
@ -4678,7 +4572,9 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData =
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses = {
200: AgentComposerImpactResponse
200: {
[key: string]: unknown
}
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse
@ -4695,7 +4591,9 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterD
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses = {
200: WorkflowAgentComposerResponse
200: {
[key: string]: unknown
}
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse
@ -4712,7 +4610,9 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateData
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses = {
200: AgentComposerValidateResponse
200: {
[key: string]: unknown
}
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse

View File

@ -77,14 +77,6 @@ export const zAdvancedChatWorkflowRunPayload = z.object({
query: z.string().optional().default(''),
})
/**
* AgentComposerValidateResponse
*/
export const zAgentComposerValidateResponse = z.object({
errors: z.array(z.string()).optional(),
result: z.string(),
})
/**
* AnnotationReplyPayload
*/
@ -738,16 +730,12 @@ export const zSite = z.object({
})
/**
* AgentConfigSnapshotSummaryResponse
* ComposerBindingPayload
*/
export const zAgentConfigSnapshotSummaryResponse = z.object({
export const zComposerBindingPayload = z.object({
agent_id: z.string().nullish(),
created_at: z.string().nullish(),
created_by: z.string().nullish(),
id: z.string(),
summary: z.string().nullish(),
version: z.int(),
version_note: z.string().nullish(),
binding_type: z.enum(['inline_agent', 'roster_agent']),
current_snapshot_id: z.string().nullish(),
})
/**
@ -761,15 +749,6 @@ export const zComposerSaveStrategy = z.enum([
'save_to_roster',
])
/**
* ComposerBindingPayload
*/
export const zComposerBindingPayload = z.object({
agent_id: z.string().nullish(),
binding_type: z.enum(['inline_agent', 'roster_agent']),
current_snapshot_id: z.string().nullish(),
})
/**
* ComposerSoulLockPayload
*/
@ -783,24 +762,6 @@ export const zComposerSoulLockPayload = z.object({
*/
export const zComposerVariant = z.enum(['agent_app', 'workflow'])
/**
* AgentComposerSoulCandidatesResponse
*/
export const zAgentComposerSoulCandidatesResponse = z.object({
cli_tools: z.array(z.record(z.string(), z.unknown())).optional(),
dify_tools: z.array(z.record(z.string(), z.unknown())).optional(),
human_contacts: z.array(z.record(z.string(), z.unknown())).optional(),
knowledge_datasets: z.array(z.record(z.string(), z.unknown())).optional(),
skills_files: z.array(z.record(z.string(), z.unknown())).optional(),
})
/**
* ComposerCandidateCapabilities
*/
export const zComposerCandidateCapabilities = z.object({
human_roster_available: z.boolean().optional().default(false),
})
/**
* AnnotationHitHistory
*/
@ -1274,33 +1235,6 @@ export const zWorkflowPaginationResponse = z.object({
page: z.int(),
})
/**
* AgentComposerSoulLockResponse
*/
export const zAgentComposerSoulLockResponse = z.object({
can_unlock: z.boolean().optional().default(false),
locked: z.boolean(),
reason: z.string().nullish(),
})
/**
* AgentComposerImpactBindingResponse
*/
export const zAgentComposerImpactBindingResponse = z.object({
app_id: z.string(),
node_id: z.string(),
workflow_id: z.string(),
})
/**
* AgentComposerImpactResponse
*/
export const zAgentComposerImpactResponse = z.object({
bindings: z.array(zAgentComposerImpactBindingResponse).optional(),
current_snapshot_id: z.string().nullish(),
workflow_node_count: z.int(),
})
export const zWorkflowDraftVariableWithoutValue = z.object({
description: z.string().optional(),
edited: z.boolean().optional(),
@ -1541,32 +1475,6 @@ export const zWorkflowOnlineUsersResponse = z.object({
data: z.array(zWorkflowOnlineUsersByApp),
})
/**
* AgentScope
*
* Visibility and lifecycle scope of an Agent record.
*/
export const zAgentScope = z.enum(['roster', 'workflow_only'])
/**
* AgentStatus
*
* Soft lifecycle state for Agent records.
*/
export const zAgentStatus = z.enum(['active', 'archived'])
/**
* AgentComposerAgentResponse
*/
export const zAgentComposerAgentResponse = z.object({
active_config_snapshot_id: z.string().nullish(),
description: z.string(),
id: z.string(),
name: z.string(),
scope: zAgentScope,
status: zAgentStatus,
})
/**
* AppVariableConfig
*/
@ -1630,37 +1538,6 @@ export const zAgentSoulSkillsFilesConfig = z.object({
*/
export const zWorkflowNodeJobMode = z.enum(['let_agent_figure_it_out', 'tell_agent_what_to_do'])
/**
* DeclaredOutputType
*/
export const zDeclaredOutputType = z.enum([
'array',
'boolean',
'file',
'number',
'object',
'string',
])
/**
* AgentComposerNodeJobCandidatesResponse
*/
export const zAgentComposerNodeJobCandidatesResponse = z.object({
declare_output_types: z.array(zDeclaredOutputType).optional(),
human_contacts: z.array(z.record(z.string(), z.unknown())).optional(),
previous_node_outputs: z.array(z.record(z.string(), z.unknown())).optional(),
})
/**
* AgentComposerCandidatesResponse
*/
export const zAgentComposerCandidatesResponse = z.object({
allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(),
allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(),
capabilities: zComposerCandidateCapabilities.optional(),
variant: zComposerVariant,
})
/**
* SimpleModelConfig
*/
@ -1848,63 +1725,6 @@ export const zWorkflowArchivedLogPaginationResponse = z.object({
total: z.int(),
})
/**
* WorkflowAgentBindingType
*
* How a workflow node is bound to an Agent.
*/
export const zWorkflowAgentBindingType = z.enum(['inline_agent', 'roster_agent'])
/**
* AgentComposerBindingResponse
*/
export const zAgentComposerBindingResponse = z.object({
agent_id: z.string().nullish(),
binding_type: zWorkflowAgentBindingType,
current_snapshot_id: z.string().nullish(),
id: z.string(),
node_id: z.string(),
workflow_id: z.string(),
})
/**
* DeclaredArrayItem
*
* Per-item shape for an ``array``-typed declared output.
*
* PRD §OUTPUT 配置框 keeps arrays one level deep on first version; nested arrays
* are rejected so the runtime type checker and JSON Schema stay easy to reason
* about. Stage 4 §4.2.
*/
export const zDeclaredArrayItem = z.object({
description: z.string().nullish(),
type: zDeclaredOutputType,
})
/**
* DeclaredOutputCheckConfig
*
* File-output content check via a model-based comparison against a benchmark file.
*
* Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3.
*/
export const zDeclaredOutputCheckConfig = z.object({
benchmark_file_ref: z.record(z.string(), z.unknown()).nullish(),
enabled: z.boolean().optional().default(false),
model_ref: z.record(z.string(), z.unknown()).nullish(),
prompt: z.string().nullish(),
})
/**
* DeclaredOutputFileConfig
*
* File-type output metadata. Both lists empty means "any file accepted".
*/
export const zDeclaredOutputFileConfig = z.object({
extensions: z.array(z.string()).optional(),
mime_types: z.array(z.string()).optional(),
})
/**
* AgentKnowledgeQueryMode
*/
@ -1943,8 +1763,124 @@ export const zAgentSoulModelConfig = z.object({
plugin_id: z.string().min(1).max(255),
})
/**
* DeclaredOutputCheckConfig
*
* File-output content check via a model-based comparison against a benchmark file.
*
* Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3.
*/
export const zDeclaredOutputCheckConfig = z.object({
benchmark_file_ref: z.record(z.string(), z.unknown()).nullish(),
enabled: z.boolean().optional().default(false),
model_ref: z.record(z.string(), z.unknown()).nullish(),
prompt: z.string().nullish(),
})
/**
* DeclaredOutputFileConfig
*
* File-type output metadata. Both lists empty means "any file accepted".
*/
export const zDeclaredOutputFileConfig = z.object({
extensions: z.array(z.string()).optional(),
mime_types: z.array(z.string()).optional(),
})
/**
* DeclaredOutputType
*/
export const zDeclaredOutputType = z.enum([
'array',
'boolean',
'file',
'number',
'object',
'string',
])
/**
* DeclaredArrayItem
*
* Per-item shape for an ``array``-typed declared output.
*
* PRD §OUTPUT 配置框 keeps arrays one level deep on first version; nested arrays
* are rejected so the runtime type checker and JSON Schema stay easy to reason
* about. Stage 4 §4.2.
*/
export const zDeclaredArrayItem = z.object({
description: z.string().nullish(),
type: zDeclaredOutputType,
})
export const zFormInputConfig = z.unknown()
/**
* AgentSoulDifyToolCredentialRef
*
* Reference to a stored Dify Plugin Tool credential.
*
* Secret values are resolved only at runtime. The legacy ``credential_id``
* field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so
* old Agent tool payloads can be read while new payloads stay explicit.
*/
export const zAgentSoulDifyToolCredentialRef = z.object({
id: z.string().max(255).nullish(),
provider: z.string().max(255).nullish(),
type: z.enum(['provider', 'tool']).optional().default('tool'),
})
/**
* AgentSoulDifyToolConfig
*
* One Dify Plugin Tool configured on Agent Soul.
*
* The API backend prepares this persisted product shape into
* ``DifyPluginToolConfig`` before sending a run request to Agent backend.
* ``provider_id`` keeps compatibility with existing Agent tool config payloads;
* new callers should send ``plugin_id`` + ``provider`` when available.
*/
export const zAgentSoulDifyToolConfig = z.object({
credential_ref: zAgentSoulDifyToolCredentialRef.optional(),
credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'),
description: z.string().nullish(),
enabled: z.boolean().optional().default(true),
name: z.string().max(255).nullish(),
plugin_id: z.string().max(255).nullish(),
provider: z.string().max(255).nullish(),
provider_id: z.string().max(255).nullish(),
provider_type: z.string().optional().default('plugin'),
runtime_parameters: z.record(z.string(), z.unknown()).optional(),
tool_name: z.string().min(1).max(255),
})
/**
* AgentSoulToolsConfig
*/
export const zAgentSoulToolsConfig = z.object({
cli_tools: z.array(z.record(z.string(), z.unknown())).optional(),
dify_tools: z.array(zAgentSoulDifyToolConfig).optional(),
})
/**
* AgentSoulConfig
*/
export const zAgentSoulConfig = z.object({
app_features: z.record(z.string(), z.unknown()).optional(),
app_variables: z.array(zAppVariableConfig).optional(),
env: zAgentSoulEnvConfig.optional(),
human: zAgentSoulHumanConfig.optional(),
knowledge: zAgentSoulKnowledgeConfig.optional(),
memory: zAgentSoulMemoryConfig.optional(),
misc_legacy: z.record(z.string(), z.unknown()).optional(),
model: zAgentSoulModelConfig.optional(),
prompt: zAgentSoulPromptConfig.optional(),
sandbox: zAgentSoulSandboxConfig.optional(),
schema_version: z.int().optional().default(1),
skills_files: zAgentSoulSkillsFilesConfig.optional(),
tools: zAgentSoulToolsConfig.optional(),
})
/**
* OutputErrorStrategy
*
@ -2015,83 +1951,6 @@ export const zWorkflowNodeJobConfig = z.object({
workflow_prompt: z.string().optional().default(''),
})
/**
* AgentSoulDifyToolCredentialRef
*
* Reference to a stored Dify Plugin Tool credential.
*
* Secret values are resolved only at runtime. The legacy ``credential_id``
* field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so
* old Agent tool payloads can be read while new payloads stay explicit.
*/
export const zAgentSoulDifyToolCredentialRef = z.object({
id: z.string().max(255).nullish(),
provider: z.string().max(255).nullish(),
type: z.enum(['provider', 'tool']).optional().default('tool'),
})
/**
* AgentSoulDifyToolConfig
*
* One Dify Plugin Tool configured on Agent Soul.
*
* The API backend prepares this persisted product shape into
* ``DifyPluginToolConfig`` before sending a run request to Agent backend.
* ``provider_id`` keeps compatibility with existing Agent tool config payloads;
* new callers should send ``plugin_id`` + ``provider`` when available.
*/
export const zAgentSoulDifyToolConfig = z.object({
credential_ref: zAgentSoulDifyToolCredentialRef.optional(),
credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'),
description: z.string().nullish(),
enabled: z.boolean().optional().default(true),
name: z.string().max(255).nullish(),
plugin_id: z.string().max(255).nullish(),
provider: z.string().max(255).nullish(),
provider_id: z.string().max(255).nullish(),
provider_type: z.string().optional().default('plugin'),
runtime_parameters: z.record(z.string(), z.unknown()).optional(),
tool_name: z.string().min(1).max(255),
})
/**
* AgentSoulToolsConfig
*/
export const zAgentSoulToolsConfig = z.object({
cli_tools: z.array(z.record(z.string(), z.unknown())).optional(),
dify_tools: z.array(zAgentSoulDifyToolConfig).optional(),
})
/**
* AgentSoulConfig
*/
export const zAgentSoulConfig = z.object({
app_features: z.record(z.string(), z.unknown()).optional(),
app_variables: z.array(zAppVariableConfig).optional(),
env: zAgentSoulEnvConfig.optional(),
human: zAgentSoulHumanConfig.optional(),
knowledge: zAgentSoulKnowledgeConfig.optional(),
memory: zAgentSoulMemoryConfig.optional(),
misc_legacy: z.record(z.string(), z.unknown()).optional(),
model: zAgentSoulModelConfig.optional(),
prompt: zAgentSoulPromptConfig.optional(),
sandbox: zAgentSoulSandboxConfig.optional(),
schema_version: z.int().optional().default(1),
skills_files: zAgentSoulSkillsFilesConfig.optional(),
tools: zAgentSoulToolsConfig.optional(),
})
/**
* AgentAppComposerResponse
*/
export const zAgentAppComposerResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse,
agent: zAgentComposerAgentResponse,
agent_soul: zAgentSoulConfig,
save_options: z.array(zComposerSaveStrategy),
variant: z.string(),
})
/**
* ComposerSavePayload
*/
@ -2108,25 +1967,6 @@ export const zComposerSavePayload = z.object({
version_note: z.string().nullish(),
})
/**
* WorkflowAgentComposerResponse
*/
export const zWorkflowAgentComposerResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.optional(),
agent: zAgentComposerAgentResponse.optional(),
agent_soul: zAgentSoulConfig,
app_id: z.string().nullish(),
binding: zAgentComposerBindingResponse.optional(),
effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(),
impact_summary: zAgentComposerImpactResponse.optional(),
node_id: z.string().nullish(),
node_job: zWorkflowNodeJobConfig,
save_options: z.array(zComposerSaveStrategy),
soul_lock: zAgentComposerSoulLockResponse,
variant: z.string(),
workflow_id: z.string().nullish(),
})
/**
* ButtonStyle
*
@ -2572,9 +2412,9 @@ export const zGetAppsByAppIdAgentComposerPath = z.object({
})
/**
* Agent app composer state
* Success
*/
export const zGetAppsByAppIdAgentComposerResponse = zAgentAppComposerResponse
export const zGetAppsByAppIdAgentComposerResponse = z.record(z.string(), z.unknown())
export const zPutAppsByAppIdAgentComposerBody = zComposerSavePayload
@ -2583,18 +2423,18 @@ export const zPutAppsByAppIdAgentComposerPath = z.object({
})
/**
* Agent app composer saved
* Success
*/
export const zPutAppsByAppIdAgentComposerResponse = zAgentAppComposerResponse
export const zPutAppsByAppIdAgentComposerResponse = z.record(z.string(), z.unknown())
export const zGetAppsByAppIdAgentComposerCandidatesPath = z.object({
app_id: z.string(),
})
/**
* Agent app composer candidates
* Success
*/
export const zGetAppsByAppIdAgentComposerCandidatesResponse = zAgentComposerCandidatesResponse
export const zGetAppsByAppIdAgentComposerCandidatesResponse = z.record(z.string(), z.unknown())
export const zPostAppsByAppIdAgentComposerValidateBody = zComposerSavePayload
@ -2603,9 +2443,9 @@ export const zPostAppsByAppIdAgentComposerValidatePath = z.object({
})
/**
* Agent app composer validation result
* Success
*/
export const zPostAppsByAppIdAgentComposerValidateResponse = zAgentComposerValidateResponse
export const zPostAppsByAppIdAgentComposerValidateResponse = z.record(z.string(), z.unknown())
export const zGetAppsByAppIdAgentLogsPath = z.object({
app_id: z.string(),
@ -3883,10 +3723,12 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.obj
})
/**
* Workflow agent composer state
* Success
*/
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
= zWorkflowAgentComposerResponse
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = z.record(
z.string(),
z.unknown(),
)
export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerBody = zComposerSavePayload
@ -3896,10 +3738,12 @@ export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.obj
})
/**
* Workflow agent composer saved
* Success
*/
export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
= zWorkflowAgentComposerResponse
export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = z.record(
z.string(),
z.unknown(),
)
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPath = z.object({
app_id: z.string(),
@ -3907,13 +3751,12 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPa
})
/**
* Workflow agent composer candidates
* Success
*/
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse
= zAgentComposerCandidatesResponse
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody
= zComposerSavePayload
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = z.record(
z.string(),
z.unknown(),
)
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath = z.object({
app_id: z.string(),
@ -3921,10 +3764,12 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath
})
/**
* Workflow agent composer impact
* Success
*/
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse
= zAgentComposerImpactResponse
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse = z.record(
z.string(),
z.unknown(),
)
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody
= zComposerSavePayload
@ -3935,10 +3780,10 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRoste
})
/**
* Workflow agent composer saved to roster
* Success
*/
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse
= zWorkflowAgentComposerResponse
= z.record(z.string(), z.unknown())
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateBody
= zComposerSavePayload
@ -3949,10 +3794,12 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidatePat
})
/**
* Workflow agent composer validation result
* Success
*/
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse
= zAgentComposerValidateResponse
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse = z.record(
z.string(),
z.unknown(),
)
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({
app_id: z.string(),

View File

@ -1,4 +1,4 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { renderWithNuqs } from '@/test/nuqs-testing'
@ -13,11 +13,9 @@ const mockUseWorkflowOnlineUsers = vi.hoisted(() => vi.fn((_options: unknown) =>
const mockReplace = vi.fn()
const mockRouter = { replace: mockReplace }
let mockSearchParams = new URLSearchParams('')
vi.mock('@/next/navigation', () => ({
useRouter: () => mockRouter,
usePathname: () => '/apps',
useSearchParams: () => mockSearchParams,
useSearchParams: () => new URLSearchParams(''),
}))
vi.mock('@/service/client', () => ({
@ -51,10 +49,12 @@ vi.mock('@/context/app-context', () => ({
}))
const mockSetKeywords = vi.fn()
const mockSetTagIDs = vi.fn()
const mockSetIsCreatedByMe = vi.fn()
const mockSetCategory = vi.fn()
const mockQueryState = {
category: 'all',
tagIDs: [] as string[],
keywords: '',
isCreatedByMe: false,
}
@ -64,20 +64,11 @@ vi.mock('../hooks/use-apps-query-state', () => ({
query: mockQueryState,
setCategory: mockSetCategory,
setKeywords: mockSetKeywords,
setTagIDs: mockSetTagIDs,
setIsCreatedByMe: mockSetIsCreatedByMe,
}),
}))
vi.mock('@/features/tag-management/components/tag-filter', () => ({
TagFilter: ({ value, onChange, onOpenTagManagement }: { value: string[], onChange: (value: string[]) => void, onOpenTagManagement: () => void }) => (
<div>
<button type="button" onClick={() => onChange(['tag-1'])}>common.tag.placeholder</button>
<span data-testid="tag-filter-value">{value.join(',')}</span>
<button type="button" onClick={onOpenTagManagement}>Manage tags</button>
</div>
),
}))
let mockOnDSLFileDropped: ((file: File) => void) | null = null
let mockDragging = false
vi.mock('../hooks/use-dsl-drag-drop', () => ({
@ -199,9 +190,9 @@ vi.mock('../app-card', () => ({
}))
vi.mock('../new-app-card', () => ({
default: (_props: { ref?: React.Ref<unknown> }) => {
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
},
}),
}))
vi.mock('../empty', () => ({
@ -239,7 +230,6 @@ beforeAll(() => {
// Render helper wrapping with shared nuqs testing helper plus a seeded
// systemFeatures cache so List can resolve its useSuspenseQuery.
const renderList = (searchParams = '') => {
mockSearchParams = new URLSearchParams(searchParams)
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
@ -263,6 +253,7 @@ describe('List', () => {
mockServiceState.isLoading = false
mockServiceState.isFetchingNextPage = false
mockQueryState.category = 'all'
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockUseWorkflowOnlineUsers.mockClear()
@ -384,12 +375,12 @@ describe('List', () => {
describe('App List Query', () => {
it('should build paged query input from active filters', () => {
mockQueryState.tagIDs = ['tag-1']
mockQueryState.keywords = 'sales'
mockQueryState.isCreatedByMe = true
mockQueryState.category = AppModeEnum.WORKFLOW
renderList()
fireEvent.click(screen.getByText('common.tag.placeholder'))
const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions
@ -406,17 +397,6 @@ describe('List', () => {
expect(options.getNextPageParam({ has_more: true, page: 2 })).toBe(3)
expect(options.getNextPageParam({ has_more: false, page: 2 })).toBeUndefined()
})
it('should remove legacy tagIDs from URL while preserving other filters', async () => {
renderList('?category=workflow&tagIDs=tag-1;tag-2&keywords=sales&isCreatedByMe=true')
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith(
'/apps?category=workflow&keywords=sales&isCreatedByMe=true',
{ scroll: false },
)
})
})
})
describe('Tag Filter', () => {

View File

@ -5,7 +5,6 @@ import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../../constants'
import { useAppsQueryState } from '../use-apps-query-state'
const renderWithAdapter = (searchParams = '') => {
// eslint-disable-next-line react/use-state -- renderHook executes a custom hook, not React.useState
return renderHookWithNuqs(() => useAppsQueryState(), { searchParams })
}
@ -19,11 +18,13 @@ describe('useAppsQueryState', () => {
expect(result.current.query).toEqual({
category: 'all',
tagIDs: [],
keywords: '',
isCreatedByMe: false,
})
expect(typeof result.current.setCategory).toBe('function')
expect(typeof result.current.setKeywords).toBe('function')
expect(typeof result.current.setTagIDs).toBe('function')
expect(typeof result.current.setIsCreatedByMe).toBe('function')
})
@ -34,6 +35,7 @@ describe('useAppsQueryState', () => {
expect(result.current.query).toEqual({
category: AppModeEnum.WORKFLOW,
tagIDs: ['tag1', 'tag2'],
keywords: 'search term',
isCreatedByMe: true,
})
@ -115,6 +117,33 @@ describe('useAppsQueryState', () => {
}
})
it('should update tag filter URL state', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.setTagIDs(['tag1', 'tag2'])
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2')
expect(update.options.history).toBe('push')
})
it('should remove tagIDs from URL when empty', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?tagIDs=tag1;tag2')
act(() => {
result.current.setTagIDs([])
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.tagIDs).toEqual([])
expect(update.searchParams.has('tagIDs')).toBe(false)
})
it('should update created-by-me URL state', async () => {
const { result, onUrlUpdate } = renderWithAdapter()

View File

@ -1,4 +1,4 @@
import { debounce, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
import { debounce, parseAsArrayOf, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { AppModes } from '@/types/app'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
@ -16,6 +16,9 @@ const appListQueryParsers = {
category: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' }),
tagIDs: parseAsArrayOf(parseAsString, ';')
.withDefault([])
.withOptions({ history: 'push' }),
keywords: parseAsString.withDefault('').withOptions({
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
}),
@ -35,6 +38,10 @@ export function useAppsQueryState() {
setQuery({ keywords })
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery({ tagIDs })
}, [setQuery])
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
setQuery({ isCreatedByMe })
}, [setQuery])
@ -43,6 +50,7 @@ export function useAppsQueryState() {
query,
setCategory,
setKeywords,
setTagIDs,
setIsCreatedByMe,
}), [query, setCategory, setKeywords, setIsCreatedByMe])
}), [query, setCategory, setKeywords, setTagIDs, setIsCreatedByMe])
}

View File

@ -15,7 +15,6 @@ import { useAppContext } from '@/context/app-context'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum } from '@/types/app'
@ -45,18 +44,15 @@ const List: FC<Props> = ({
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const searchParams = useSearchParams()
const pathname = usePathname()
const { replace } = useRouter()
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
query: { category, keywords, isCreatedByMe },
query: { category, tagIDs, keywords, isCreatedByMe },
setCategory,
setKeywords,
setTagIDs,
setIsCreatedByMe,
} = useAppsQueryState()
const [tagIDs, setTagIDs] = useState<string[]>([])
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
@ -75,16 +71,6 @@ const List: FC<Props> = ({
enabled: isCurrentWorkspaceEditor,
})
useEffect(() => {
if (!searchParams.has('tagIDs'))
return
const params = new URLSearchParams(searchParams.toString())
params.delete('tagIDs')
const query = params.toString()
replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
}, [pathname, replace, searchParams])
const appListQuery = useMemo<AppListQuery>(() => ({
page: 1,
limit: 30,

View File

@ -25,7 +25,6 @@ import {
$setSelection,
BLUR_COMMAND,
FOCUS_COMMAND,
KEY_ESCAPE_COMMAND,
} from 'lexical'
import * as React from 'react'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
@ -391,7 +390,7 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, '{{foo}}')
})
it('handles workflow variable selection: flat vars (current/error_message/last_run) and closes on Escape from search input', async () => {
it('handles workflow variable selection: flat vars (current/error_message/last_run)', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
@ -445,16 +444,6 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
await flushNextTick()
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_LAST_RUN_BLOCK_COMMAND, null)
// Re-open menu and press Escape in the VarReferenceVars search input to exercise handleClose().
await setEditorText(editor, '{', true)
await flushNextTick()
const searchInput = await screen.findByPlaceholderText('workflow.common.searchVar')
await act(async () => {
fireEvent.keyDown(searchInput, { key: 'Escape' })
})
await flushNextTick()
expect(dispatchSpy).toHaveBeenCalledWith(KEY_ESCAPE_COMMAND, expect.any(KeyboardEvent))
// Re-open menu and select a flat var that is not handled by the special-case list.
// This covers the "no-op" path in the `isFlat` branch.
dispatchSpy.mockClear()
@ -600,7 +589,7 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
})
})
it('defaults to the first workflow variable and removes the full slash query when selecting by keyboard', async () => {
it('removes the full slash query when selecting a workflow variable', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
@ -625,18 +614,9 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
await setEditorText(editor, '/e', true)
await flushNextTick()
const firstItem = screen.getByText('first_value').closest('[data-selected]')
const secondItem = screen.getByText('second_value').closest('[data-selected]')
expect(firstItem).toHaveAttribute('data-selected', 'true')
expect(secondItem).toHaveAttribute('data-selected', 'false')
fireEvent.keyDown(document, { key: 'ArrowDown' })
expect(firstItem).toHaveAttribute('data-selected', 'false')
expect(secondItem).toHaveAttribute('data-selected', 'true')
fireEvent.keyDown(document, { key: 'Enter' })
await act(async () => {
fireEvent.click(await screen.findByText('second_value'))
})
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'second_value'])
await waitFor(() => expect(readEditorText(editor)).not.toContain('/e'))

View File

@ -3,12 +3,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { PluginSource, TaskStatus } from '@/app/components/plugins/types'
import PluginItem from '../plugin-item'
vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({
MagicBox: ({ className }: { className?: string }) => (
<svg data-testid="magic-box-icon" className={className} />
),
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src, size }: { src: string, size: string }) => (
<div data-testid="card-icon" data-src={src} data-size={size} />
@ -114,23 +108,6 @@ describe('PluginItem', () => {
expect(cardIcon).toHaveAttribute('data-src', 'https://example.com/icons/my-icon.svg')
expect(cardIcon).toHaveAttribute('data-size', 'small')
})
it('should show default tool icon when plugin icon is empty', () => {
const { container } = render(
<PluginItem
plugin={createPlugin({ icon: '' })}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span data-testid="status-icon" />}
statusText="status"
/>,
)
expect(mockGetIconUrl).not.toHaveBeenCalled()
expect(screen.queryByTestId('card-icon')).not.toBeInTheDocument()
expect(container.querySelector('[data-testid="magic-box-icon"]')).toHaveClass('size-8', 'text-text-tertiary')
expect(screen.getByTestId('status-icon').parentElement).toHaveClass('absolute', '-bottom-0.5', '-right-0.5', 'z-10')
})
})
describe('Props', () => {

View File

@ -1,7 +1,6 @@
import type { FC, ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import CardIcon from '@/app/components/plugins/card/base/card-icon'
type PluginItemProps = {
@ -25,20 +24,13 @@ const PluginItem: FC<PluginItemProps> = ({
action,
onClear,
}) => {
const hasPluginIcon = !!plugin.icon
return (
<div className="group/item flex gap-1 rounded-lg p-2 hover:bg-state-base-hover">
<div className="relative shrink-0 self-start">
{hasPluginIcon
? (
<CardIcon
size="small"
src={getIconUrl(plugin.icon)}
/>
)
// eslint-disable-next-line hyoban/prefer-tailwind-icons -- Reuse the same MagicBox component as the marketplace install button.
: <MagicBox className="size-8 text-text-tertiary" />}
<CardIcon
size="small"
src={getIconUrl(plugin.icon)}
/>
<div className="absolute -right-0.5 -bottom-0.5 z-10">
{statusIcon}
</div>

View File

@ -9,15 +9,13 @@ vi.mock('../var-reference-vars', () => ({
default: ({
vars,
onChange,
itemWidth,
isSupportFileVar,
}: {
vars: NodeOutPutVar[]
onChange: (value: ValueSelector, item: Var) => void
itemWidth?: number
isSupportFileVar?: boolean
}) => {
mockVarReferenceVars({ vars, onChange, itemWidth, isSupportFileVar })
mockVarReferenceVars({ vars, onChange, isSupportFileVar })
return <div data-testid="var-reference-vars">{vars.length}</div>
},
}))
@ -56,7 +54,6 @@ describe('AssignedVarReferencePopup', () => {
render(
<AssignedVarReferencePopup
vars={[createOutputVar()]}
itemWidth={280}
onChange={onChange}
/>,
)
@ -65,7 +62,6 @@ describe('AssignedVarReferencePopup', () => {
expect(mockVarReferenceVars).toHaveBeenCalledWith({
vars: [createOutputVar()],
onChange,
itemWidth: 280,
isSupportFileVar: true,
})
})

View File

@ -33,13 +33,11 @@ describe('VarReferenceVars', () => {
vars: [{ variable: 'valid_name', type: VarType.string }],
}])
it('should filter vars through the search box and call onClose on escape', () => {
const onClose = vi.fn()
it('should filter vars through the search box', () => {
render(
<VarReferenceVars
vars={baseVars}
onChange={vi.fn()}
onClose={onClose}
/>,
)
@ -47,45 +45,6 @@ describe('VarReferenceVars', () => {
target: { value: 'valid' },
})
expect(screen.getByText('valid_name')).toBeInTheDocument()
fireEvent.keyDown(screen.getByPlaceholderText('workflow.common.searchVar'), { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should select the first visible variable by default and support arrow navigation in slash mode', () => {
const onChange = vi.fn()
render(
<VarReferenceVars
hideSearch
vars={createVars([{
title: 'Node A',
nodeId: 'node-a',
vars: [
{ variable: 'first_value', type: VarType.string },
{ variable: 'second_value', type: VarType.string },
],
}])}
onChange={onChange}
/>,
)
const firstItem = screen.getByText('first_value').closest('[data-selected]')
const secondItem = screen.getByText('second_value').closest('[data-selected]')
expect(firstItem).toHaveAttribute('data-selected', 'true')
expect(secondItem).toHaveAttribute('data-selected', 'false')
fireEvent.keyDown(document, { key: 'ArrowDown' })
expect(firstItem).toHaveAttribute('data-selected', 'false')
expect(secondItem).toHaveAttribute('data-selected', 'true')
fireEvent.keyDown(document, { key: 'Enter' })
expect(onChange).toHaveBeenCalledWith(['node-a', 'second_value'], expect.objectContaining({
variable: 'second_value',
}))
})
it('should call onChange when a variable item is chosen', () => {
@ -117,7 +76,7 @@ describe('VarReferenceVars', () => {
/>,
)
expect(screen.getByText('workflow.common.noVar')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('workflow.common.noVar')
fireEvent.click(screen.getByText('manage-input'))
expect(onManageInputField).toHaveBeenCalledTimes(1)
@ -208,43 +167,6 @@ describe('VarReferenceVars', () => {
expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' }))
})
it('should resolve selectors for special variables and file support from keyboard selection', () => {
const onChange = vi.fn()
render(
<VarReferenceVars
hideSearch
isSupportFileVar
vars={createVars([
{
title: 'Specials',
nodeId: 'node-special',
vars: [
{ variable: 'env.API_KEY', type: VarType.string },
{ variable: 'conversation.user_name', type: VarType.string, des: 'User name' },
{ variable: 'current', type: VarType.string },
{ variable: 'asset', type: VarType.file },
],
},
])}
onChange={onChange}
/>,
)
fireEvent.keyDown(document, { key: 'Enter' })
fireEvent.keyDown(document, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'Enter' })
fireEvent.keyDown(document, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'Enter' })
fireEvent.keyDown(document, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'Enter' })
expect(onChange).toHaveBeenNthCalledWith(1, ['env', 'API_KEY'], expect.objectContaining({ variable: 'env.API_KEY' }))
expect(onChange).toHaveBeenNthCalledWith(2, ['conversation', 'user_name'], expect.objectContaining({ variable: 'conversation.user_name' }))
expect(onChange).toHaveBeenNthCalledWith(3, ['node-special', 'current'], expect.objectContaining({ variable: 'current' }))
expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' }))
})
it('should render object vars and select them by node path', () => {
const onChange = vi.fn()
@ -324,26 +246,4 @@ describe('VarReferenceVars', () => {
fireEvent.click(screen.getByText('asset'))
expect(onChange).not.toHaveBeenCalled()
})
it('should ignore file vars when file support is disabled during keyboard selection', () => {
const onChange = vi.fn()
render(
<VarReferenceVars
hideSearch
vars={createVars([
{
title: 'Files',
nodeId: 'node-files',
vars: [{ variable: 'asset', type: VarType.file }],
},
])}
onChange={onChange}
/>,
)
fireEvent.keyDown(document, { key: 'Enter' })
expect(onChange).not.toHaveBeenCalled()
})
})

View File

@ -9,12 +9,10 @@ import VarReferenceVars from './var-reference-vars'
type Props = {
vars: NodeOutPutVar[]
onChange: (value: ValueSelector, varDetail: Var) => void
itemWidth?: number
}
const AssignedVarReferencePopup: FC<Props> = ({
vars,
onChange,
itemWidth,
}) => {
const { t } = useTranslation()
// max-h-[300px] overflow-y-auto todo: use portal to handle long list
@ -32,7 +30,6 @@ const AssignedVarReferencePopup: FC<Props> = ({
searchBoxClassName="mt-1"
vars={vars}
onChange={onChange}
itemWidth={itemWidth}
isSupportFileVar
/>
)}

View File

@ -64,7 +64,6 @@ const VarReferencePopup: FC<Props> = ({
searchBoxClassName="mt-1"
vars={vars}
onChange={onChange}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
showManageInputField={showManageRagInputFields}
onManageInputField={() => setShowInputFieldPanel?.(true)}

View File

@ -1,20 +1,28 @@
'use client'
import type { FC } from 'react'
import type { StructuredOutput } from '../../../llm/types'
import type { Field } from '@/app/components/workflow/nodes/llm/types'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxClear,
ComboboxEmpty,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
} from '@langgenius/dify-ui/combobox'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useHover } from 'ahooks'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { VarType } from '@/app/components/workflow/types'
@ -28,9 +36,18 @@ import {
getVariableDisplayName,
} from './var-reference-vars.helpers'
const VAR_SEARCH_INPUT_CLASS_NAME = 'var-search-input'
export const VAR_REFERENCE_CHILD_POPUP_CLASS_NAME = 'var-reference-vars-child-popup'
type ReferenceVarItem = {
nodeId: string
title: string
itemData: Var
optionIndex: number
isFlat?: boolean
isException?: boolean
isLoopVar?: boolean
}
const resolveValueSelector = ({
itemData,
isFlat,
@ -66,41 +83,26 @@ const resolveValueSelector = ({
}
type ItemProps = {
nodeId: string
title: string
objPath: string[]
itemData: Var
item: ReferenceVarItem
onChange: (value: ValueSelector, item: Var) => void
onHovering?: (value: boolean) => void
itemWidth?: number
isSupportFileVar?: boolean
isException?: boolean
isLoopVar?: boolean
isFlat?: boolean
isInCodeGeneratorInstructionEditor?: boolean
className?: string
preferSchemaType?: boolean
isSelected?: boolean
onActivate?: () => void
}
const Item: FC<ItemProps> = ({
nodeId,
title,
objPath,
itemData,
function Item({
item,
onChange,
onHovering,
isSupportFileVar,
isException,
isLoopVar,
isFlat,
isInCodeGeneratorInstructionEditor,
className,
preferSchemaType,
isSelected,
onActivate,
}) => {
}: ItemProps) {
const {
nodeId,
title,
itemData,
isException,
isFlat,
isLoopVar,
} = item
const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties
const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && (itemData.children as Var[]).length > 0)
const isEnv = itemData.variable.startsWith('env.')
@ -159,69 +161,16 @@ const Item: FC<ItemProps> = ({
return objStructuredOutput
})()
const itemRef = useRef<HTMLDivElement>(null)
const [isItemHovering, setIsItemHovering] = useState(false)
useHover(itemRef, {
onChange: (hovering) => {
if (hovering) {
setIsItemHovering(true)
}
else {
if (isObj || isStructureOutput) {
setTimeout(() => {
setIsItemHovering(false)
}, 100)
}
else {
setIsItemHovering(false)
}
}
},
})
const [isChildrenHovering, setIsChildrenHovering] = useState(false)
const isHovering = isItemHovering || isChildrenHovering
const open = (isObj || isStructureOutput) && isHovering
useEffect(() => {
onHovering?.(isHovering)
}, [isHovering, onHovering])
const handleChosen = (e: React.MouseEvent) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
const valueSelector = resolveValueSelector({
itemData,
isFlat,
isSupportFileVar,
nodeId,
objPath,
})
if (valueSelector)
onChange(valueSelector, itemData)
}
const variableCategory = useMemo(
() => getVariableCategory({ isEnv, isChatVar, isLoopVar, isRagVariable }),
[isEnv, isChatVar, isLoopVar, isRagVariable],
)
const itemTrigger = (
<div
ref={itemRef}
className={cn(
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
(isHovering || isSelected) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3 outline-hidden focus:outline-hidden focus-visible:outline-hidden',
className,
)}
data-selected={isSelected ? 'true' : 'false'}
onClick={handleChosen}
onMouseEnter={onActivate}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}}
<ComboboxItem
value={item}
>
<div className="flex w-0 grow items-center">
<ComboboxItemText className="flex items-center gap-1 px-0">
{!isFlat && (
<VariableIconWithColor
variables={itemData.variable.split('.')}
@ -232,33 +181,33 @@ const Item: FC<ItemProps> = ({
{isFlat && flatVarIcon}
{!isEnv && !isChatVar && !isRagVariable && (
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{varName}</div>
<span title={itemData.variable} className="min-w-0 grow truncate">{varName}</span>
)}
{isEnv && (
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('env.', '')}</div>
<span title={itemData.variable} className="min-w-0 grow truncate">{itemData.variable.replace('env.', '')}</span>
)}
{isChatVar && (
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('conversation.', '')}</div>
<span title={itemData.des} className="min-w-0 grow truncate">{itemData.variable.replace('conversation.', '')}</span>
)}
{isRagVariable && (
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.split('.').slice(-1)[0]}</div>
<span title={itemData.des} className="min-w-0 grow truncate">{itemData.variable.split('.').slice(-1)[0]}</span>
)}
</div>
<div className="ml-1 shrink-0 text-xs font-normal text-text-tertiary capitalize">{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}</div>
</ComboboxItemText>
<span className="text-xs font-normal text-text-tertiary capitalize">{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}</span>
{
(isObj || isStructureOutput) && (
<span aria-hidden className={cn('ml-0.5 i-custom-vender-line-arrows-chevron-right size-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
<span aria-hidden className="i-custom-vender-line-arrows-chevron-right size-3 text-text-quaternary" />
)
}
</div>
</ComboboxItem>
)
if (!isObj && !isStructureOutput)
return itemTrigger
return (
<Popover
open={open}
onOpenChange={noop}
>
<PopoverTrigger nativeButton={false} render={itemTrigger} />
<Popover>
<PopoverTrigger nativeButton={false} openOnHover render={itemTrigger} />
<PopoverContent
placement="left-start"
sideOffset={0}
@ -268,7 +217,6 @@ const Item: FC<ItemProps> = ({
<PickerStructurePanel
root={{ nodeId, nodeName: title, attrName: itemData.variable, attrAlias: itemData.schemaType }}
payload={structuredOutput!}
onHovering={setIsChildrenHovering}
onSelect={(valueSelector) => {
onChange(valueSelector, itemData)
}}
@ -279,6 +227,20 @@ const Item: FC<ItemProps> = ({
)
}
function getReferenceVarLabel(item: ReferenceVarItem) {
return getVariableDisplayName(item.itemData.variable, !!item.isFlat)
}
function getReferenceVarValue(item: ReferenceVarItem) {
return `${item.nodeId}:${item.itemData.variable}:${item.optionIndex}`
}
function isSameReferenceVar(item: ReferenceVarItem, value: ReferenceVarItem) {
return item.nodeId === value.nodeId
&& item.itemData.variable === value.itemData.variable
&& item.optionIndex === value.optionIndex
}
type Props = {
hideSearch?: boolean
searchText?: string
@ -286,7 +248,6 @@ type Props = {
vars: NodeOutPutVar[]
isSupportFileVar?: boolean
onChange: (value: ValueSelector, item: Var) => void
itemWidth?: number
maxHeightClass?: string
onClose?: () => void
onBlur?: () => void
@ -296,68 +257,44 @@ type Props = {
autoFocus?: boolean
preferSchemaType?: boolean
}
const VarReferenceVars: FC<Props> = ({
function VarReferenceVars({
hideSearch,
searchText,
searchBoxClassName,
vars,
isSupportFileVar,
onChange,
itemWidth,
maxHeightClass,
onClose,
onBlur,
isInCodeGeneratorInstructionEditor,
showManageInputField,
onManageInputField,
autoFocus = true,
preferSchemaType,
}) => {
}: Props) {
const { t } = useTranslation()
const [internalSearchValue, setInternalSearchValue] = useState('')
const listRef = useRef<HTMLDivElement>(null)
const searchValue = searchText ?? internalSearchValue
const filteredVars = useMemo(() => filterReferenceVars(vars, searchValue), [vars, searchValue])
const selectableItems = useMemo(() => {
return filteredVars.flatMap(node => node.vars.map(item => ({
nodeId: node.nodeId,
isFlat: node.isFlat,
itemData: item,
})))
}, [filteredVars])
const indexedFilteredVars = useMemo(() => {
const groupedItems = useMemo(() => {
let optionIndex = 0
return filteredVars.map(node => ({
...node,
vars: node.vars.map(variable => ({
variable,
vars: node.vars.map((variable): ReferenceVarItem => ({
nodeId: node.nodeId,
title: node.title,
itemData: variable,
isFlat: node.isFlat,
isException: variable.isException,
isLoopVar: node.isLoop,
optionIndex: optionIndex++,
})),
}))
}, [filteredVars])
const [selectedIndex, setSelectedIndex] = useState(-1)
const effectiveSelectedIndex = selectableItems.length ? Math.min(Math.max(selectedIndex, 0), selectableItems.length - 1) : -1
const selectableItems = useMemo(() => groupedItems.flatMap(node => node.vars), [groupedItems])
useEffect(() => {
const listElement = listRef.current
const selectedElement = listElement?.querySelector('[data-selected="true"]') as HTMLElement | null
if (!listElement || !selectedElement)
return
const selectedTop = selectedElement.offsetTop
const selectedBottom = selectedTop + selectedElement.offsetHeight
const visibleTop = listElement.scrollTop
const visibleBottom = visibleTop + listElement.clientHeight
if (selectedTop < visibleTop)
listElement.scrollTop = selectedTop
else if (selectedBottom > visibleBottom)
listElement.scrollTop = selectedBottom - listElement.clientHeight
}, [effectiveSelectedIndex])
const selectItem = useCallback((index: number) => {
const selectedItem = selectableItems[index]
const selectItem = useCallback((selectedItem?: ReferenceVarItem) => {
if (!selectedItem)
return
@ -372,141 +309,97 @@ const VarReferenceVars: FC<Props> = ({
if (valueSelector)
onChange(valueSelector, itemData)
}, [isSupportFileVar, onChange, selectableItems])
}, [isSupportFileVar, onChange])
const handleKeyboardEvent = useCallback((event: Pick<KeyboardEvent, 'key' | 'preventDefault' | 'stopPropagation'>) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose?.()
return
}
if (!selectableItems.length)
const handleValueChange = useCallback((item: ReferenceVarItem | null) => {
if (!item)
return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
event.stopPropagation()
setSelectedIndex(
event.key === 'ArrowDown'
? Math.min(effectiveSelectedIndex + 1, selectableItems.length - 1)
: Math.max(effectiveSelectedIndex - 1, 0),
)
return
}
selectItem(item)
}, [selectItem])
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
selectItem(effectiveSelectedIndex)
}
}, [effectiveSelectedIndex, onClose, selectableItems.length, selectItem])
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
handleKeyboardEvent(e)
}, [handleKeyboardEvent])
useEffect(() => {
if (!hideSearch)
return
const handleDocumentKeyDown = (event: KeyboardEvent) => {
if (event.altKey || event.ctrlKey || event.metaKey)
return
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key))
return
handleKeyboardEvent(event)
}
document.addEventListener('keydown', handleDocumentKeyDown, true)
return () => document.removeEventListener('keydown', handleDocumentKeyDown, true)
}, [handleKeyboardEvent, hideSearch])
const handleInputValueChange = useCallback((value: string) => {
if (searchText === undefined)
setInternalSearchValue(value)
}, [searchText])
return (
<>
<Combobox<ReferenceVarItem>
inline
open
value={null}
items={selectableItems}
inputValue={searchValue}
onInputValueChange={handleInputValueChange}
onValueChange={handleValueChange}
filter={null}
itemToStringLabel={getReferenceVarLabel}
itemToStringValue={getReferenceVarValue}
isItemEqualToValue={isSameReferenceVar}
>
{
!hideSearch && (
<>
<div className={cn('m-2', searchBoxClassName)} onClick={e => e.stopPropagation()}>
<Input
className={VAR_SEARCH_INPUT_CLASS_NAME}
showLeftIcon
showClearIcon
value={searchValue}
<div className={cn('m-2', searchBoxClassName)}>
<ComboboxInputGroup>
<ComboboxInput
aria-label={t('common.searchVar', { ns: 'workflow' }) || ''}
placeholder={t('common.searchVar', { ns: 'workflow' }) || ''}
onChange={e => setInternalSearchValue(e.target.value)}
onKeyDown={handleKeyDown}
onClear={() => setInternalSearchValue('')}
onBlur={onBlur}
autoFocus={autoFocus}
/>
</div>
<div
className="relative left-[-4px] h-[0.5px] bg-black/5"
style={{
width: 'calc(100% + 8px)',
}}
>
</div>
</>
{searchValue && (
<ComboboxClear
aria-label={t('operation.clear', { ns: 'common' })}
/>
)}
</ComboboxInputGroup>
</div>
)
}
{filteredVars.length > 0
? (
<div ref={listRef} className={cn('max-h-[85vh] overflow-x-hidden overflow-y-auto', maxHeightClass)}>
<ComboboxList className={maxHeightClass}>
{
indexedFilteredVars.map((item, i) => (
<div key={item.nodeId} className={cn(!item.isFlat && 'mt-3', i === 0 && item.isFlat && 'mt-2')}>
{!item.isFlat && (
<div
className="truncate px-3 system-xs-medium-uppercase leading-[22px] text-text-tertiary"
title={item.title}
groupedItems.map((group, i) => (
<ComboboxGroup key={group.nodeId} items={group.vars}>
{!group.isFlat && (
<ComboboxGroupLabel
title={group.title}
>
{item.title}
</div>
{group.title}
</ComboboxGroupLabel>
)}
{item.vars.map(({ variable, optionIndex }) => (
{group.vars.map(item => (
<Item
key={optionIndex}
title={item.title}
nodeId={item.nodeId}
objPath={[]}
itemData={variable}
key={item.optionIndex}
item={item}
onChange={onChange}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
isException={variable.isException}
isLoopVar={item.isLoop}
isFlat={item.isFlat}
isInCodeGeneratorInstructionEditor={isInCodeGeneratorInstructionEditor}
preferSchemaType={preferSchemaType}
isSelected={effectiveSelectedIndex === optionIndex}
onActivate={() => setSelectedIndex(optionIndex)}
/>
))}
{item.isFlat && !indexedFilteredVars[i + 1]?.isFlat && !!indexedFilteredVars.find(item => !item.isFlat) && (
{group.isFlat && !groupedItems[i + 1]?.isFlat && !!groupedItems.find(item => !item.isFlat) && (
<div className="relative mt-[14px] flex items-center space-x-1">
<div className="h-0 w-3 shrink-0 border border-divider-subtle"></div>
<div className="system-2xs-semibold-uppercase text-text-tertiary">{t('debug.lastOutput', { ns: 'workflow' })}</div>
<div className="h-0 shrink-0 grow border border-divider-subtle"></div>
</div>
)}
</div>
</ComboboxGroup>
))
}
</div>
</ComboboxList>
)
: <div className="mt-2 pl-3 text-xs leading-[18px] font-medium text-gray-500 uppercase">{t('common.noVar', { ns: 'workflow' })}</div>}
: <ComboboxEmpty>{t('common.noVar', { ns: 'workflow' })}</ComboboxEmpty>}
{
showManageInputField && (
showManageInputField && onManageInputField && (
<ManageInputField
onManage={onManageInputField || noop}
onManage={onManageInputField}
/>
)
}
</>
</Combobox>
)
}

View File

@ -18,10 +18,6 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(() => new URLSearchParams()),
}))
vi.mock('@/app/components/billing/pricing', () => ({
default: () => <div>billing.plansCommon.mostPopular</div>,
}))
const mockUseProviderContext = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),