Compare commits

..

20 Commits

Author SHA1 Message Date
53ae9e0669 [autofix.ci] apply automated fixes 2026-05-31 09:24:25 +00:00
857a5901a7 feat(api): Agent App feature-config endpoint (opener/follow-up/citations/...)
Adds POST /console/api/apps/<id>/agent-features, a dedicated write surface for
an Agent App's PRD "Misc Legacy" presentation features. The legacy
/model-config endpoint also writes model / prompt / agent tools, which an Agent
App owns through its Soul, so reusing it would be semantically wrong and let a
caller override Soul-owned config.

AgentAppFeatureConfigService validates only the allowed feature subset
(opening_statement, suggested_questions, suggested_questions_after_answer,
speech_to_text, text_to_speech, retriever_resource, sensitive_word_avoidance),
fills disabled/empty defaults, and writes a new app_model_config version with
model / prompt / agent_mode left NULL. Soul-owned keys (model, pre_prompt,
agent_mode, tools, user_input_form) are dropped, so App.is_agent stays False
and the app mode is never mutated.

Live-verified: posting opener + follow-up + citations (with model/agent_mode
smuggled in) persists a new config row with model=None/agent_mode=None, surfaces
through /v1/parameters, keeps mode=agent, and streaming chat still works.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 17:19:56 +08:00
90d892c386 [autofix.ci] apply automated fixes 2026-05-29 13:27:33 +00:00
9508df254b feat(api): Agent App type — seed app_model_config for legacy feature flags
Per PRD (Misc Legacy / expert zone), an Agent App must support conversation
opener, follow-up suggestions, citations, content moderation and annotation
reply. These app-level presentation features live on app_model_config (design
Q3), but create_app never made one for Agent Apps, so they were unconfigurable
and /parameters returned hardcoded defaults.

create_app now creates a model-less app_model_config row for AppMode.AGENT
(model/prompt/tools stay in the Agent Soul; agent_mode left unset so
App.is_agent stays False and the app mode is not mutated). The webapp
/parameters endpoint and the chat pipeline already read feature flags from
this row.

Live-verified: a fresh Agent App gets the row; setting opening_statement +
suggested_questions persists and surfaces through /v1/parameters, while chat
continues to stream Soul-backed answers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 21:22:51 +08:00
c3974aa822 feat(api): Agent App type S5/S6 — webapp parameters for Agent Apps
Add a shared get_app_parameters() resolver that maps any app type to its
webapp parameters, and route the web, service-API and explore /parameters
endpoints through it. Agent Apps have neither a workflow nor a legacy
app_model_config, so their presentation features (opening statement,
suggestions, file upload, ...) default to disabled with a free-form chat
input until a dedicated config surface lands.

This unblocks the public web app and service API for Agent Apps: /parameters
now returns a valid config instead of 500-ing on the missing app_model_config.

Live-verified end-to-end via the web app entry (passport -> parameters ->
streamed chat) and /v1/parameters. Unit tests cover all four resolver
branches (workflow, easy-UI config, agent defaults, unavailable).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 21:09:54 +08:00
3d2d456a6c feat(api): Agent App type S7 (logs) — expose conversation/message logs
Allow AppMode.AGENT through the console conversation-log, message-log and
average-session-interaction mode gates so an Agent App's conversations and
messages show up in the app logs and statistics, same as chat / agent-chat.
Token/message/conversation daily statistics already accept any mode.

Live-verified: conversation list, message history (multi-turn memory intact)
and conversation detail all return for an Agent App.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 21:09:54 +08:00
732f81a419 [autofix.ci] apply automated fixes 2026-05-29 13:03:38 +00:00
43b744a33f feat(api): Agent App type S4 — accept AppMode.AGENT on web + service API chat
Allow AppMode.AGENT through the web and service-API chat-message (and stop)
mode gates so Agent Apps are reachable over /v1/chat-messages and the web
app, routing to AgentAppGenerator like the console debug path. The web /
service-API chat payloads carry no override model_config, so no payload
change is needed there — model + prompt come from the bound Agent Soul.

Live-verified: /v1/chat-messages with an app token streams a Soul-backed
answer end-to-end through the real agent backend.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 20:58:55 +08:00
3ac5c4addc feat(api): Agent App type S2c/S3/S4 — wire chat pipeline + live-verified
Route AppMode.AGENT through AppGenerateService to a new AgentAppGenerator
that resolves the bound roster Agent + published Soul snapshot, synthesizes
an EasyUI-shaped app config from the Soul, and drives one conversation turn
via AgentAppRunner against the dify-agent backend (streamed message +
message_end over the existing chat SSE pipeline).

- AgentAppGenerator + AgentAppGenerateResponseConverter
- AgentAppGenerateEntity (agent_id + agent_config_snapshot_id)
- console chat-messages: accept AppMode.AGENT; make model_config optional
  (Agent Apps derive model/prompt from the Soul, not an override config)
- synthesize prompt_type=simple and NULL app_model_config_id so the
  conversation persists without a legacy app_model_config row

Live-verified end-to-end against the real agent backend: single-turn,
multi-turn resume (history preserved, session reused), and the runtime
session row persists with owner_type=conversation and an intact snapshot
(no "cleaned trap").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 20:56:18 +08:00
69f727d3a5 fix(api): drop unused import in agent_app config manager 2026-05-29 20:16:11 +08:00
5a957fd36b feat(api): Agent App type S2c (config) — Soul → chat-pipeline app config
Direction (your call): ride the existing chat (EasyUI) message + SSE pipeline,
synthesizing the app config from the Agent Soul rather than inventing a new
pipeline.

``AgentAppConfigManager`` shapes the Agent Soul (model + system prompt) plus any
app-level feature flags stored on ``app_model_config`` (Q3) into an
app_model_config-style dict, then reuses the same chat sub-managers
(ModelConfigManager / PromptTemplateConfigManager / features) to build an
EasyUI-shaped ``AgentAppConfig``. Model + prompt always come from the Soul
(single source of truth); feature flags from app_model_config when present.

Tests: 3 config-synthesis cases (soul model/prompt, feature-flag passthrough +
soul override, missing-model). ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 20:15:36 +08:00
a1ef47710d feat(api): Agent App type S2c (runner) — drive a turn via agent backend
``AgentAppRunner`` runs one conversation turn against the dify-agent backend
(instead of the legacy in-process ReAct loop): load the conversation's prior
session_snapshot, build the run request (S2b), create the run, consume the
event stream, and republish the assistant answer as chat queue events
(``QueueLLMChunkEvent`` + ``QueueMessageEndEvent``) so the existing EasyUI chat
task pipeline persists the message and streams SSE. On success the conversation
session_snapshot is saved for multi-turn continuity; failures raise
AgentBackendError; the answer is normalized to text (plain string or structured
JSON).

MVP emits the final answer as one chunk + message-end; token-level streaming is
a follow-up refinement. The generator/entity/converter wiring + live-stack
verification land next.

Tests: 4 runner cases via the deterministic fake backend client + fake queue
(success→chunk+end+session-save, prior-snapshot threading, failure raises,
answer extraction). 15 agent_app unit tests green; ruff + pyrefly clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:25:16 +08:00
246c1d74a4 feat(api): Agent App type S2b — agent-app run request builder
* ``AgentBackendRunRequestBuilder.build_for_agent_app`` + the
  ``AgentBackendAgentAppRunInput`` DTO: the app-shaped layer graph (agent soul
  system prompt → user message → execution context → history → llm → optional
  plugin tools → optional output), purpose=agent_app, on_exit=suspend. No
  workflow-node-job / previous-node prompt.
* ``AgentAppRuntimeRequestBuilder`` maps an Agent Soul snapshot + conversation
  turn into a CreateRunRequest: plugin-daemon plugin_id/provider normalization,
  credential fetch + scalar normalization, Dify plugin tools (reuses
  WorkflowAgentPluginToolsBuilder), conversation-scoped execution_context with
  invoke_from=agent_app, and the prior session_snapshot for multi-turn.

Tests: 5 builder/DTO cases + reuses the conversation session store; 40 passing
across agent_backend + agent_app suites. ruff + pyrefly clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:14:43 +08:00
1fc9a7802c feat(api): Agent App type S2a — unified agent_runtime_sessions table
Q2 decision: unify the workflow-only ``workflow_agent_runtime_sessions`` into
an owner-agnostic ``agent_runtime_sessions`` table serving both owners. Feature
is unreleased, so the old table is dropped (no data migration).

* ``AgentRuntimeSession`` model (table ``agent_runtime_sessions``) with an
  ``owner_type`` discriminator (workflow_run | conversation): workflow columns
  (workflow_id/run_id/node_id/binding_id/agent_config_snapshot_id/
  composition_layer_specs) and ``conversation_id`` are mutually-exclusive,
  enforced by two partial unique indexes. Back-compat aliases
  ``WorkflowAgentRuntimeSession`` / ``WorkflowAgentRuntimeSessionStatus`` keep
  the shipped lifecycle path (PR #36724) unchanged; the workflow store now sets
  ``owner_type=workflow_run``.
* New ``AgentAppRuntimeSessionStore`` (conversation-keyed) for the Agent App
  side of the same table: one conversation = one Agent session for multi-turn.
* Migration 121e7346074d (drop old + create unified) — applies and
  downgrade/upgrade round-trips clean on Postgres.

Tests: 6 new conversation-store ORM round-trip tests; 154 existing workflow
lifecycle + agent_backend tests still green against the unified table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:02:21 +08:00
71512ad2be [autofix.ci] apply automated fixes 2026-05-29 10:50:23 +00:00
d067d84811 feat(api): Agent App type S1 — AppMode.AGENT + create flow + binding
First slice of the Agent App type (新 Agent 作为独立 App 类型,替代
chatbot/agent-legacy/completion). Design:
https://km.dify.langgenius.ai/wiki/spaces/DT/pages/460161070/Agent+App+Type

* New ``AppMode.AGENT = "agent"`` (distinct from legacy ``agent-chat`` ReAct
  app). Runtime model/prompt/tools live in the bound Agent Soul, so the
  default template seeds no model_config.
* Create flow: creating an Agent App also creates a roster Agent bound 1:1 via
  ``Agent.app_id`` (decision Q1), inside the same transaction so app + backing
  agent persist atomically. ``AgentRosterService.create_backing_agent_for_app``
  builds the agent + a v1 (empty) Agent Soul snapshot without committing; the
  user configures model/prompt/tools afterward in the Composer.
* ``App.bound_agent_id`` resolves the backing roster Agent from the app id (so
  the console can open the Composer in roster-detail mode); surfaced on the app
  detail response. Returns None for non-agent apps (short-circuits, no DB hit).
* ``CreateAppParams`` / ``AppListParams`` accept "agent"; list filter handles it.

Scope: S1 only (foundation). Runtime/preview, web/service API, access &
sharing, logs and feature flags land in S2–S7 per the design.

Tests: roster backing-agent build/link/get + enum/template/params/bound_agent_id
short-circuit. 46 passing in app + agent service suites; ruff + pyrefly clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 18:46:10 +08:00
2cc567c6a3 feat: add DTO for agent api (#36797)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-29 03:36:41 +00:00
a180ab19e4 chore: type check test container tests (#36790) 2026-05-29 01:54:25 +00:00
13eaa436e7 test: isolate Redis state in container tests (#36740) 2026-05-28 12:42:25 +00:00
3596d12e4c refactor(cli): use Store interface as token storage (#36726) 2026-05-28 10:02:51 +00:00
118 changed files with 5993 additions and 1919 deletions

View File

@ -34,6 +34,7 @@ from clients.agent_backend.request_builder import (
DIFY_PLUGIN_TOOLS_LAYER_ID,
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
WORKFLOW_USER_PROMPT_LAYER_ID,
AgentBackendAgentAppRunInput,
AgentBackendModelConfig,
AgentBackendOutputConfig,
AgentBackendRunRequestBuilder,
@ -49,6 +50,7 @@ __all__ = [
"DIFY_PLUGIN_TOOLS_LAYER_ID",
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
"WORKFLOW_USER_PROMPT_LAYER_ID",
"AgentBackendAgentAppRunInput",
"AgentBackendError",
"AgentBackendHTTPError",
"AgentBackendInternalEvent",

View File

@ -45,6 +45,7 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
AGENT_SOUL_PROMPT_LAYER_ID = "agent_soul_prompt"
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
@ -181,9 +182,138 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
return value
class AgentBackendAgentAppRunInput(BaseModel):
"""Inputs to build one Agent App conversation-turn run request.
Unlike the workflow-node input there is no workflow-node-job prompt and no
previous-node context: the user prompt is the chat message, and multi-turn
continuity comes from ``session_snapshot`` + the history layer keyed by the
conversation.
"""
model: AgentBackendModelConfig
execution_context: DifyExecutionContextLayerConfig
user_prompt: str
agent_soul_prompt: str | None = None
purpose: RunPurpose = "agent_app"
idempotency_key: str | None = None
output: AgentBackendOutputConfig | None = None
tools: DifyPluginToolsLayerConfig | None = None
session_snapshot: CompositorSessionSnapshot | None = None
include_history: bool = True
suspend_on_exit: bool = True
metadata: dict[str, JsonValue] = Field(default_factory=dict)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
@field_validator("user_prompt")
@classmethod
def _reject_blank_prompt(cls, value: str) -> str:
if not value.strip():
raise ValueError("prompt must not be blank")
return value
class AgentBackendRunRequestBuilder:
"""Converts API product state into the public ``dify-agent`` run protocol."""
def build_for_agent_app(self, run_input: AgentBackendAgentAppRunInput) -> CreateRunRequest:
"""Build an Agent App conversation-turn run request.
Layer graph: optional Agent Soul system prompt → user prompt →
execution context → optional history (multi-turn) → LLM → optional
plugin tools → optional structured output. Mirrors the workflow-node
layer ordering minus the workflow-job / previous-node prompt.
"""
layers: list[RunLayerSpec] = []
if run_input.agent_soul_prompt:
layers.append(
RunLayerSpec(
name=AGENT_SOUL_PROMPT_LAYER_ID,
type=PLAIN_PROMPT_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "agent_soul"},
config=PromptLayerConfig(prefix=run_input.agent_soul_prompt),
)
)
layers.extend(
[
RunLayerSpec(
name=AGENT_APP_USER_PROMPT_LAYER_ID,
type=PLAIN_PROMPT_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "agent_app_user_prompt"},
config=PromptLayerConfig(user=run_input.user_prompt),
),
RunLayerSpec(
name=DIFY_EXECUTION_CONTEXT_LAYER_ID,
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=run_input.execution_context,
),
]
)
if run_input.include_history:
layers.append(
RunLayerSpec(
name=DIFY_AGENT_HISTORY_LAYER_ID,
type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "agent_session_history"},
)
)
layers.append(
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=DifyPluginLLMLayerConfig(
plugin_id=run_input.model.plugin_id,
model_provider=run_input.model.model_provider,
model=run_input.model.model,
credentials=run_input.model.credentials,
model_settings=run_input.model.model_settings or None,
),
)
)
if run_input.tools is not None and run_input.tools.tools:
layers.append(
RunLayerSpec(
name=DIFY_PLUGIN_TOOLS_LAYER_ID,
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.tools,
)
)
if run_input.output is not None:
layers.append(
RunLayerSpec(
name=DIFY_AGENT_OUTPUT_LAYER_ID,
type=DIFY_OUTPUT_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=DifyOutputLayerConfig(
json_schema=run_input.output.json_schema,
description=run_input.output.description,
strict=run_input.output.strict,
),
)
)
return CreateRunRequest(
composition=RunComposition(layers=layers),
purpose=run_input.purpose,
idempotency_key=run_input.idempotency_key,
metadata=run_input.metadata,
session_snapshot=run_input.session_snapshot,
on_exit=LayerExitSignals(
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
),
)
def build_cleanup_request(
self,
*,

View File

@ -81,4 +81,15 @@ default_app_templates: Mapping[AppMode, Mapping] = {
},
},
},
# agent default mode (new Agent App type). The runtime model / prompt / tools
# come from the bound Agent Soul snapshot, so no model_config is seeded in the
# template; create_app still creates a model-less app_model_config row to hold
# app-level presentation features (opener, follow-up, citations, ...).
AppMode.AGENT: {
"app": {
"mode": AppMode.AGENT,
"enable_site": True,
"enable_api": True,
},
},
}

View File

@ -51,6 +51,7 @@ from .agent import roster as agent_roster
from .app import (
advanced_prompt_template,
agent,
agent_app_feature,
annotation,
app,
audio,
@ -146,6 +147,7 @@ __all__ = [
"activate",
"advanced_prompt_template",
"agent",
"agent_app_feature",
"agent_composer",
"agent_providers",
"agent_roster",

View File

@ -1,9 +1,17 @@
from flask_restx import Resource
from controllers.common.schema import register_schema_models
from controllers.common.schema import register_response_schema_models, 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
@ -11,23 +19,40 @@ 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 AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
return dump_response(
WorkflowAgentComposerResponse,
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
@ -36,18 +61,24 @@ 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 AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
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,
),
)
@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
@ -55,21 +86,29 @@ 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 {"result": "success", "errors": []}
return dump_response(AgentComposerValidateResponse, {"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 AgentComposerService.get_workflow_candidates(app_id=app_model.id)
return dump_response(
AgentComposerCandidatesResponse,
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
@ -79,13 +118,21 @@ 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 {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
return AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=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),
)
@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
@ -94,26 +141,34 @@ 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 AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
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,
),
)
@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 AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id)
return dump_response(
AgentAppComposerResponse,
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
@ -122,17 +177,23 @@ 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 AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
app_id=app_model.id,
account_id=account.id,
payload=payload,
return dump_response(
AgentAppComposerResponse,
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
@ -140,14 +201,20 @@ class AgentAppComposerValidateApi(Resource):
def post(self, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return {"result": "success", "errors": []}
return dump_response(AgentComposerValidateResponse, {"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 AgentComposerService.get_agent_app_candidates(app_id=app_model.id)
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
)

View File

@ -4,10 +4,18 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import register_schema_models
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
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
@ -29,6 +37,14 @@ register_schema_models(
RosterAgentUpdatePayload,
RosterListQuery,
)
register_response_schema_models(
console_ns,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentRosterListResponse,
AgentRosterResponse,
)
def _agent_roster_service() -> AgentRosterService:
@ -37,17 +53,23 @@ 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 _agent_roster_service().list_roster_agents(
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
return dump_response(
AgentRosterListResponse,
_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
@ -57,36 +79,49 @@ 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 service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id), 201
return dump_response(
AgentRosterResponse,
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 _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 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,
),
)
@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 _agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id))
return dump_response(
AgentRosterResponse,
_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
@ -94,10 +129,14 @@ 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 _agent_roster_service().update_roster_agent(
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload
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
),
)
@console_ns.response(204, "Agent archived")
@setup_required
@login_required
@account_initialization_required
@ -110,23 +149,31 @@ 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 {"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))}
return dump_response(
AgentConfigSnapshotListResponse,
{"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 _agent_roster_service().get_agent_version_detail(
tenant_id=tenant_id,
agent_id=str(agent_id),
version_id=str(version_id),
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),
),
)

View File

@ -0,0 +1,80 @@
"""Agent App presentation-feature configuration endpoint.
The new Agent App type keeps model / prompt / tools in its bound Agent Soul, so
the legacy ``/model-config`` surface (which writes model, prompt and agent tool
config) is the wrong place to configure its app-level presentation features.
This endpoint exposes only the PRD "Misc Legacy" feature subset — conversation
opener, follow-up suggestions, citations, content moderation and speech — and
persists them onto the app's ``app_model_config`` without touching anything the
Soul owns.
"""
from typing import Any
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, 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 events.app_event import app_model_config_was_updated
from libs.login import current_account_with_tenant, login_required
from models.model import App, AppMode
from services.agent_app_feature_service import AgentAppFeatureConfigService
class AgentAppFeaturesRequest(BaseModel):
"""Presentation features configurable on an Agent App.
All fields are optional; an omitted field is reset to its disabled/empty
default (the config form sends the full desired feature state on save).
"""
opening_statement: str | None = Field(default=None, description="Conversation opener shown before the first turn")
suggested_questions: list[str] | None = Field(
default=None, description="Preset questions shown alongside the opener"
)
suggested_questions_after_answer: dict[str, Any] | None = Field(
default=None, description="Follow-up suggestions config, e.g. {'enabled': true}"
)
speech_to_text: dict[str, Any] | None = Field(default=None, description="Speech-to-text config")
text_to_speech: dict[str, Any] | None = Field(default=None, description="Text-to-speech config")
retriever_resource: dict[str, Any] | None = Field(
default=None, description="Citations / attributions config, e.g. {'enabled': true}"
)
sensitive_word_avoidance: dict[str, Any] | None = Field(default=None, description="Content moderation config")
register_schema_models(console_ns, AgentAppFeaturesRequest)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/apps/<uuid:app_id>/agent-features")
class AgentAppFeatureConfigResource(Resource):
@console_ns.doc("update_agent_app_features")
@console_ns.doc(description="Update an Agent App's presentation features (opener, follow-up, citations, ...)")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[AgentAppFeaturesRequest.__name__])
@console_ns.response(200, "Features updated successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(400, "Invalid configuration")
@console_ns.response(404, "App not found")
@setup_required
@login_required
@edit_permission_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
def post(self, app_model: App):
args = AgentAppFeaturesRequest.model_validate(console_ns.payload)
current_user, _ = current_account_with_tenant()
new_app_model_config = AgentAppFeatureConfigService.update_features(
app_model=app_model,
account=current_user,
config=args.model_dump(exclude_none=True),
)
app_model_config_was_updated.send(app_model, app_model_config=new_app_model_config)
return {"result": "success"}

View File

@ -393,6 +393,8 @@ class AppDetailWithSite(AppDetail):
max_active_requests: int | None = None
deleted_tools: list[DeletedTool] = Field(default_factory=list)
site: Site | None = None
# For Agent App type: the roster Agent backing this app (None otherwise).
bound_agent_id: str | None = None
@computed_field(return_type=str | None) # type: ignore
@property

View File

@ -43,7 +43,11 @@ logger = logging.getLogger(__name__)
class BaseMessagePayload(BaseModel):
inputs: dict[str, Any]
model_config_data: dict[str, Any] = Field(..., alias="model_config")
# Agent Apps (AppMode.AGENT) derive their model + prompt from the bound Agent
# Soul, so no override ``model_config`` is sent; chat / agent-chat / completion
# debugging still pass it. Optional here, required in practice by those modes
# downstream when their config is built from args.
model_config_data: dict[str, Any] = Field(default_factory=dict, alias="model_config")
files: list[Any] | None = Field(default=None, description="Uploaded files")
response_mode: Literal["blocking", "streaming"] = Field(default="blocking", description="Response mode")
retriever_from: str = Field(default="dev", description="Retriever source")
@ -157,7 +161,7 @@ class ChatMessageApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT])
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT])
@edit_permission_required
def post(self, app_model: App):
args_model = ChatMessagePayload.model_validate(console_ns.payload)

View File

@ -205,7 +205,7 @@ class ChatConversationApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@edit_permission_required
def get(self, app_model: App):
current_user, _ = current_account_with_tenant()
@ -316,7 +316,7 @@ class ChatConversationDetailApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@edit_permission_required
def get(self, app_model: App, conversation_id: UUID):
conversation_id_str = str(conversation_id)
@ -332,7 +332,7 @@ class ChatConversationDetailApi(Resource):
@console_ns.response(404, "Conversation not found")
@setup_required
@login_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@account_initialization_required
@edit_permission_required
def delete(self, app_model: App, conversation_id: UUID):

View File

@ -178,7 +178,7 @@ class ChatMessageListApi(Resource):
@login_required
@account_initialization_required
@setup_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@edit_permission_required
def get(self, app_model: App):
args = ChatMessagesQuery.model_validate(request.args.to_dict())

View File

@ -293,7 +293,7 @@ class AverageSessionInteractionStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
def get(self, app_model: App):
account, _ = current_account_with_tenant()

View File

@ -1,11 +1,9 @@
from typing import Any, cast
from controllers.common import fields
from controllers.console import console_ns
from controllers.console.app.error import AppUnavailableError
from controllers.console.explore.wraps import InstalledAppResource
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from models.model import AppMode, InstalledApp
from core.app.app_config.common.parameters_mapping import AppParametersUnavailableError, get_app_parameters
from models.model import InstalledApp
from services.app_service import AppService
@ -20,23 +18,10 @@ class AppParameterApi(InstalledAppResource):
if app_model is None:
raise AppUnavailableError()
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow
if workflow is None:
raise AppUnavailableError()
features_dict: dict[str, Any] = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app_model.app_model_config
if app_model_config is None:
raise AppUnavailableError()
features_dict = cast(dict[str, Any], app_model_config.to_dict())
user_input_form = features_dict.get("user_input_form", [])
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
try:
parameters = get_app_parameters(app_model)
except AppParametersUnavailableError:
raise AppUnavailableError()
return fields.Parameters.model_validate(parameters).model_dump(mode="json")

View File

@ -1,5 +1,3 @@
from typing import Any, cast
from flask_restx import Resource
from controllers.common.fields import Parameters
@ -7,9 +5,9 @@ from controllers.common.schema import register_response_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import AppUnavailableError
from controllers.service_api.wraps import validate_app_token
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.app_config.common.parameters_mapping import AppParametersUnavailableError, get_app_parameters
from fields.base import ResponseModel
from models.model import App, AppMode
from models.model import App
from services.app_service import AppService
@ -43,23 +41,10 @@ class AppParameterApi(Resource):
Returns the input form parameters and configuration for the application.
"""
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow
if workflow is None:
raise AppUnavailableError()
features_dict: dict[str, Any] = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app_model.app_model_config
if app_model_config is None:
raise AppUnavailableError()
features_dict = cast(dict[str, Any], app_model_config.to_dict())
user_input_form = features_dict.get("user_input_form", [])
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
try:
parameters = get_app_parameters(app_model)
except AppParametersUnavailableError:
raise AppUnavailableError()
return Parameters.model_validate(parameters).model_dump(mode="json")

View File

@ -197,7 +197,7 @@ class ChatApi(Resource):
Supports conversation management and both blocking and streaming response modes.
"""
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
raise NotChatAppError()
payload = ChatRequestPayload.model_validate(service_api_ns.payload or {})
@ -262,7 +262,7 @@ class ChatStopApi(Resource):
def post(self, app_model: App, end_user: EndUser, task_id: str):
"""Stop a running chat message generation."""
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
raise NotChatAppError()
AppTaskService.stop_task(

View File

@ -1,5 +1,4 @@
import logging
from typing import Any, cast
from flask import request
from flask_restx import Resource
@ -9,10 +8,10 @@ from werkzeug.exceptions import Unauthorized
from constants import HEADER_NAME_APP_CODE
from controllers.common import fields
from controllers.common.schema import register_response_schema_models, register_schema_models
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.app_config.common.parameters_mapping import AppParametersUnavailableError, get_app_parameters
from libs.passport import PassportService
from libs.token import extract_webapp_passport
from models.model import App, AppMode, EndUser
from models.model import App, EndUser
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
@ -58,23 +57,10 @@ class AppParameterApi(WebApiResource):
)
def get(self, app_model: App, end_user: EndUser):
"""Retrieve app parameters."""
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow
if workflow is None:
raise AppUnavailableError()
features_dict: dict[str, Any] = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app_model.app_model_config
if app_model_config is None:
raise AppUnavailableError()
features_dict = cast(dict[str, Any], app_model_config.to_dict())
user_input_form = features_dict.get("user_input_form", [])
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
try:
parameters = get_app_parameters(app_model)
except AppParametersUnavailableError:
raise AppUnavailableError()
return fields.Parameters.model_validate(parameters).model_dump(mode="json")

View File

@ -171,7 +171,7 @@ class ChatApi(WebApiResource):
)
def post(self, app_model: App, end_user: EndUser):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
raise NotChatAppError()
payload = ChatMessagePayload.model_validate(web_ns.payload or {})
@ -228,7 +228,7 @@ class ChatStopApi(WebApiResource):
@web_ns.response(200, "Success", web_ns.models[SimpleResultResponse.__name__])
def post(self, app_model: App, end_user: EndUser, task_id: str):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
raise NotChatAppError()
AppTaskService.stop_task(

View File

@ -1,9 +1,12 @@
from collections.abc import Mapping
from typing import Any, TypedDict
from typing import TYPE_CHECKING, Any, TypedDict
from configs import dify_config
from constants import DEFAULT_FILE_NUMBER_LIMITS
if TYPE_CHECKING:
from models.model import App
class FeatureToggleDict(TypedDict):
enabled: bool
@ -70,3 +73,39 @@ def get_parameters_from_feature_dict(
"workflow_file_upload_limit": dify_config.WORKFLOW_FILE_UPLOAD_LIMIT,
},
}
class AppParametersUnavailableError(Exception):
"""Raised when an app cannot yet expose webapp parameters (no published config)."""
def get_app_parameters(app_model: "App") -> AppParametersDict:
"""Resolve the webapp parameters for any app type.
Workflow / advanced-chat apps read their feature flags from the bound
workflow; easy-UI apps (chat / agent-chat / completion) from their
``app_model_config``. An Agent App has neither a workflow nor a legacy
``app_model_config`` — its presentation features are not yet configurable,
so every toggle defaults to disabled with a free-form chat input.
"""
from models.model import AppMode
features_dict: Mapping[str, Any]
user_input_form: list[dict[str, Any]]
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow
if workflow is None:
raise AppParametersUnavailableError()
features_dict = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
elif app_model.app_model_config is not None:
features_dict = app_model.app_model_config.to_dict()
user_input_form = features_dict.get("user_input_form", [])
elif app_model.mode == AppMode.AGENT:
features_dict = {}
user_input_form = []
else:
raise AppParametersUnavailableError()
return get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)

View File

View File

@ -0,0 +1,105 @@
"""Build the EasyUI-style app config for an Agent App from its Agent Soul.
An Agent App has no legacy ``app_model_config``: its model / prompt live in the
bound Agent Soul snapshot. To ride the existing chat message + SSE pipeline we
synthesize an ``app_model_config``-shaped dict from the Soul (model + system
prompt) plus any app-level feature flags (opening statement, follow-up, …)
stored on ``app_model_config`` when present, then reuse the same sub-managers
the chat app type uses.
"""
from typing import Any
from core.app.app_config.base_app_config_manager import BaseAppConfigManager
from core.app.app_config.common.sensitive_word_avoidance.manager import SensitiveWordAvoidanceConfigManager
from core.app.app_config.easy_ui_based_app.dataset.manager import DatasetConfigManager
from core.app.app_config.easy_ui_based_app.model_config.manager import ModelConfigManager
from core.app.app_config.easy_ui_based_app.prompt_template.manager import PromptTemplateConfigManager
from core.app.app_config.easy_ui_based_app.variables.manager import BasicVariablesConfigManager
from core.app.app_config.entities import (
EasyUIBasedAppConfig,
EasyUIBasedAppModelConfigFrom,
PromptTemplateEntity,
)
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode, AppModelConfig, Conversation
class AgentAppConfig(EasyUIBasedAppConfig):
"""Agent App config entity (EasyUI-shaped so it rides the chat pipeline).
Unlike legacy EasyUI apps, an Agent App has no ``app_model_config`` row, so
the id may be absent; persistence stores ``NULL`` for the conversation's
``app_model_config_id`` in that case.
"""
app_model_config_id: str | None = None
class AgentAppConfigManager(BaseAppConfigManager):
@classmethod
def get_app_config(
cls,
*,
app_model: App,
agent_soul: AgentSoulConfig,
app_model_config: AppModelConfig | None = None,
conversation: Conversation | None = None,
) -> AgentAppConfig:
"""Build the Agent App config from the Agent Soul (+ optional feature flags)."""
config_dict = cls._synthesize_config_dict(agent_soul, app_model_config)
app_mode = AppMode.value_of(app_model.mode)
app_config = AgentAppConfig(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
app_mode=app_mode,
# The config is derived from the Agent Soul snapshot, not a legacy
# app_model_config row; the id is informational only.
app_model_config_from=EasyUIBasedAppModelConfigFrom.APP_LATEST_CONFIG,
app_model_config_id=app_model_config.id if app_model_config else None,
app_model_config_dict=config_dict,
model=ModelConfigManager.convert(config=config_dict),
prompt_template=PromptTemplateConfigManager.convert(config=config_dict),
sensitive_word_avoidance=SensitiveWordAvoidanceConfigManager.convert(config=config_dict),
dataset=DatasetConfigManager.convert(config=config_dict),
additional_features=cls.convert_features(config_dict, app_mode),
)
app_config.variables, app_config.external_data_variables = BasicVariablesConfigManager.convert(
config=config_dict
)
return app_config
@staticmethod
def _synthesize_config_dict(
agent_soul: AgentSoulConfig,
app_model_config: AppModelConfig | None,
) -> dict[str, Any]:
"""Shape a Soul + feature flags into an ``app_model_config``-style dict.
Feature flags (opening statement / follow-up / tts / stt / citations /
moderation / annotation) come from ``app_model_config`` when present
(Q3: stored there), otherwise defaults; model + prompt always come from
the Agent Soul (the single source of truth for those).
"""
base: dict[str, Any] = app_model_config.to_dict() if app_model_config else {}
model = agent_soul.model
if model is not None:
base["model"] = {
"provider": model.model_provider,
"name": model.model,
"mode": "chat",
"completion_params": dict(model.model_settings or {}),
}
# The Agent Soul system prompt rides the EasyUI "simple" prompt slot; the
# agent backend is the real prompt authority, this only feeds the chat
# pipeline's bookkeeping (token counting, persistence).
base["prompt_type"] = PromptTemplateEntity.PromptType.SIMPLE.value
base["pre_prompt"] = agent_soul.prompt.system_prompt or ""
# Agent App takes the user message directly; no completion-style inputs form.
base.setdefault("user_input_form", [])
return base
__all__ = ["AgentAppConfig", "AgentAppConfigManager"]

View File

@ -0,0 +1,252 @@
"""Agent App generator: orchestrate one conversation turn for an Agent App.
Mirrors the agent_chat generator (conversation + message + queue + streamed
response over the EasyUI chat pipeline), but the backing config comes from the
bound Agent Soul and the answer is produced by ``AgentAppRunner`` calling the
dify-agent backend rather than an in-process LLM/ReAct loop.
"""
from __future__ import annotations
import contextvars
import logging
import threading
import uuid
from collections.abc import Generator, Mapping
from typing import Any
from flask import Flask, current_app
from sqlalchemy import select
from clients.agent_backend import AgentBackendRunEventAdapter
from clients.agent_backend.factory import create_agent_backend_run_client
from configs import dify_config
from constants import UUID_NIL
from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
from core.app.apps.agent_app.app_runner import AgentAppRunner
from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
from core.app.entities.app_invoke_entities import (
AgentAppGenerateEntity,
DifyRunContext,
InvokeFrom,
UserFrom,
)
from core.app.llm.model_access import build_dify_model_access
from core.ops.ops_trace_manager import TraceQueueManager
from extensions.ext_database import db
from models import Account, App, EndUser
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from services.conversation_service import ConversationService
logger = logging.getLogger(__name__)
class AgentAppGeneratorError(ValueError):
"""Raised when an Agent App turn cannot be set up."""
class AgentAppGenerator(MessageBasedAppGenerator):
def generate(
self,
*,
app_model: App,
user: Account | EndUser,
args: Mapping[str, Any],
invoke_from: InvokeFrom,
streaming: bool = True,
) -> Mapping[str, Any] | Generator[Mapping | str, None, None]:
if not streaming:
raise AgentAppGeneratorError("Agent App only supports streaming mode")
query = args.get("query")
if not isinstance(query, str) or not query.strip():
raise AgentAppGeneratorError("query is required")
query = query.replace("\x00", "")
inputs = args["inputs"]
# Resolve the bound roster Agent + its published Agent Soul snapshot.
agent, snapshot, agent_soul = self._resolve_agent(app_model)
conversation = None
conversation_id = args.get("conversation_id")
if conversation_id:
conversation = ConversationService.get_conversation(
app_model=app_model, conversation_id=conversation_id, user=user
)
# Build the EasyUI-shaped config from the Agent Soul so the chat pipeline
# can persist usage; the answer itself comes from the agent backend.
app_model_config = app_model.app_model_config
app_config = AgentAppConfigManager.get_app_config(
app_model=app_model,
agent_soul=agent_soul,
app_model_config=app_model_config,
conversation=conversation,
)
model_conf = ModelConfigConverter.convert(app_config)
trace_manager = TraceQueueManager(app_model.id, user.id if isinstance(user, Account) else user.session_id)
application_generate_entity = AgentAppGenerateEntity(
task_id=str(uuid.uuid4()),
app_config=app_config,
model_conf=model_conf,
conversation_id=conversation.id if conversation else None,
inputs=self._prepare_user_inputs(
user_inputs=inputs, variables=app_config.variables, tenant_id=app_model.tenant_id
),
query=query,
files=[],
parent_message_id=(
args.get("parent_message_id")
if invoke_from not in {InvokeFrom.SERVICE_API, InvokeFrom.OPENAPI}
else UUID_NIL
),
user_id=user.id,
stream=streaming,
invoke_from=invoke_from,
extras={"auto_generate_conversation_name": args.get("auto_generate_name", True)},
call_depth=0,
trace_manager=trace_manager,
agent_id=agent.id,
agent_config_snapshot_id=snapshot.id,
)
conversation, message = self._init_generate_records(application_generate_entity, conversation)
queue_manager = MessageBasedAppQueueManager(
task_id=application_generate_entity.task_id,
user_id=application_generate_entity.user_id,
invoke_from=application_generate_entity.invoke_from,
conversation_id=conversation.id,
app_mode=conversation.mode,
message_id=message.id,
)
context = contextvars.copy_context()
worker_thread = threading.Thread(
target=self._generate_worker,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"context": context,
"application_generate_entity": application_generate_entity,
"queue_manager": queue_manager,
"conversation_id": conversation.id,
"message_id": message.id,
"user_from": UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER,
},
)
worker_thread.start()
response = self._handle_response(
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
conversation=conversation,
message=message,
user=user,
stream=streaming,
)
return AgentAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
def _generate_worker(
self,
*,
flask_app: Flask,
context: contextvars.Context,
application_generate_entity: AgentAppGenerateEntity,
queue_manager: AppQueueManager,
conversation_id: str,
message_id: str,
user_from: UserFrom,
) -> None:
from libs.flask_utils import preserve_flask_contexts
with preserve_flask_contexts(flask_app, context_vars=context):
try:
conversation = self._get_conversation(conversation_id)
message = self._get_message(message_id)
app_config = application_generate_entity.app_config
dify_context = DifyRunContext(
tenant_id=app_config.tenant_id,
app_id=app_config.app_id,
user_id=application_generate_entity.user_id,
user_from=user_from,
invoke_from=application_generate_entity.invoke_from,
)
credentials_provider, _ = build_dify_model_access(dify_context)
_, _, agent_soul = self._resolve_agent_by_id(
tenant_id=app_config.tenant_id,
agent_id=application_generate_entity.agent_id,
snapshot_id=application_generate_entity.agent_config_snapshot_id,
)
runner = AgentAppRunner(
request_builder=AgentAppRuntimeRequestBuilder(credentials_provider=credentials_provider),
agent_backend_client=create_agent_backend_run_client(
base_url=dify_config.AGENT_BACKEND_BASE_URL,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
),
event_adapter=AgentBackendRunEventAdapter(),
session_store=AgentAppRuntimeSessionStore(),
)
runner.run(
dify_context=dify_context,
agent_id=application_generate_entity.agent_id,
agent_config_snapshot_id=application_generate_entity.agent_config_snapshot_id,
agent_soul=agent_soul,
conversation_id=conversation.id,
query=application_generate_entity.query,
message_id=message.id,
model_name=application_generate_entity.model_conf.model,
queue_manager=queue_manager,
)
except GenerateTaskStoppedError:
pass
except Exception as e:
logger.exception("Unknown Error in Agent App generate worker")
queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER)
finally:
db.session.close()
def _resolve_agent(self, app_model: App) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
agent = db.session.scalar(
select(Agent).where(
Agent.app_id == app_model.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
)
if agent is None:
raise AgentAppGeneratorError("Agent App has no bound Agent")
return self._resolve_agent_by_id(
tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id
)
@staticmethod
def _resolve_agent_by_id(
*, tenant_id: str, agent_id: str, snapshot_id: str | None
) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
agent = db.session.scalar(select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id))
if agent is None:
raise AgentAppGeneratorError("Agent not found")
if not snapshot_id:
raise AgentAppGeneratorError("Agent has no published version")
snapshot = db.session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.id == snapshot_id))
if snapshot is None:
raise AgentAppGeneratorError("Agent published version not found")
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
return agent, snapshot, agent_soul
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]

View File

@ -0,0 +1,175 @@
"""Agent App runner: drive one conversation turn through the dify-agent backend.
Unlike the legacy ``AgentChatAppRunner`` (which runs an in-process ReAct loop),
this runner delegates to the Agent backend: build the run request from the
Agent Soul + conversation, create the run, consume its event stream, and
republish the assistant answer as chat queue events so the existing
EasyUI chat task pipeline persists the message and streams SSE. The conversation
``session_snapshot`` is saved on success for multi-turn continuity (S3).
"""
from __future__ import annotations
import json
import logging
from typing import Any
from pydantic import JsonValue
from clients.agent_backend import (
AgentBackendError,
AgentBackendInternalEventType,
AgentBackendRunClient,
AgentBackendRunEventAdapter,
AgentBackendRunSucceededInternalEvent,
AgentBackendStreamInternalEvent,
)
from core.app.apps.agent_app.runtime_request_builder import (
AgentAppRuntimeBuildContext,
AgentAppRuntimeRequestBuilder,
)
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.entities.app_invoke_entities import DifyRunContext
from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
from models.agent_config_entities import AgentSoulConfig
logger = logging.getLogger(__name__)
class AgentAppRunner:
"""Runs one Agent App conversation turn against the Agent backend."""
def __init__(
self,
*,
request_builder: AgentAppRuntimeRequestBuilder,
agent_backend_client: AgentBackendRunClient,
event_adapter: AgentBackendRunEventAdapter,
session_store: AgentAppRuntimeSessionStore,
) -> None:
self._request_builder = request_builder
self._agent_backend_client = agent_backend_client
self._event_adapter = event_adapter
self._session_store = session_store
def run(
self,
*,
dify_context: DifyRunContext,
agent_id: str,
agent_config_snapshot_id: str,
agent_soul: AgentSoulConfig,
conversation_id: str,
query: str,
message_id: str,
model_name: str,
queue_manager: AppQueueManager,
) -> None:
scope = AgentAppSessionScope(
tenant_id=dify_context.tenant_id,
app_id=dify_context.app_id,
conversation_id=conversation_id,
agent_id=agent_id,
)
session_snapshot = self._session_store.load_active_snapshot(scope)
runtime = self._request_builder.build(
AgentAppRuntimeBuildContext(
dify_context=dify_context,
agent_id=agent_id,
agent_config_snapshot_id=agent_config_snapshot_id,
agent_soul=agent_soul,
conversation_id=conversation_id,
user_query=query,
idempotency_key=message_id,
session_snapshot=session_snapshot,
)
)
create_response = self._agent_backend_client.create_run(runtime.request)
terminal = self._consume_stream(create_response.run_id)
if not isinstance(terminal, AgentBackendRunSucceededInternalEvent):
error = getattr(terminal, "error", None) or "Agent backend run did not complete successfully."
raise AgentBackendError(str(error))
answer = self._extract_answer(terminal.output)
self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer)
self._save_session(scope=scope, backend_run_id=terminal.run_id, snapshot=terminal.session_snapshot)
def _consume_stream(self, run_id: str):
terminal = None
for public_event in self._agent_backend_client.stream_events(run_id):
for internal_event in self._event_adapter.adapt(public_event):
if internal_event.type in (
AgentBackendInternalEventType.RUN_STARTED,
AgentBackendInternalEventType.STREAM_EVENT,
):
# Stream deltas are accumulated by the backend into the
# terminal output; token-level forwarding is an S3 refinement.
if isinstance(internal_event, AgentBackendStreamInternalEvent):
continue
continue
terminal = internal_event
break
if terminal is not None:
break
return terminal
def _publish_answer(self, *, queue_manager: AppQueueManager, model_name: str, answer: str) -> None:
# MVP: emit the full answer as a single chunk + message-end. The chat
# task pipeline streams the chunk over SSE and persists the message.
chunk = LLMResultChunk(
model=model_name,
prompt_messages=[],
delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=answer)),
)
queue_manager.publish(QueueLLMChunkEvent(chunk=chunk), PublishFrom.APPLICATION_MANAGER)
queue_manager.publish(
QueueMessageEndEvent(
llm_result=LLMResult(
model=model_name,
prompt_messages=[],
message=AssistantPromptMessage(content=answer),
usage=LLMUsage.empty_usage(),
),
),
PublishFrom.APPLICATION_MANAGER,
)
def _save_session(self, *, scope: AgentAppSessionScope, backend_run_id: str, snapshot: Any) -> None:
try:
self._session_store.save_active_snapshot(scope=scope, backend_run_id=backend_run_id, snapshot=snapshot)
except Exception:
logger.warning(
"Failed to persist Agent App conversation session snapshot: "
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s",
scope.tenant_id,
scope.app_id,
scope.conversation_id,
scope.agent_id,
exc_info=True,
)
@staticmethod
def _extract_answer(output: JsonValue) -> str:
"""Normalize the backend's terminal output to assistant text.
Free-text Agent Apps return a plain string; if a structured output is
configured the value is a JSON object, which we serialize so the chat
message always has a string body.
"""
if isinstance(output, str):
return output
if isinstance(output, dict):
text = output.get("text")
if isinstance(text, str):
return text
return json.dumps(output, ensure_ascii=False)
return json.dumps(output, ensure_ascii=False)
__all__ = ["AgentAppRunner"]

View File

@ -0,0 +1,15 @@
"""Response converter for the Agent App type.
The Agent App streams the same chatbot response shape as the chat / agent-chat
app types, so it reuses that converter wholesale; kept as a distinct subclass so
the app type owns its converter and can diverge later.
"""
from core.app.apps.agent_chat.generate_response_converter import AgentChatAppGenerateResponseConverter
class AgentAppGenerateResponseConverter(AgentChatAppGenerateResponseConverter):
pass
__all__ = ["AgentAppGenerateResponseConverter"]

View File

@ -0,0 +1,177 @@
"""Build dify-agent run requests for one Agent App conversation turn.
Mirrors the workflow ``WorkflowAgentRuntimeRequestBuilder`` but for the Agent
App surface: the user prompt is the chat message (no workflow-node job / no
previous-node context), and multi-turn continuity flows through the
conversation-keyed ``session_snapshot`` plus the history layer.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Protocol, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from dify_agent.protocol import CreateRunRequest
from clients.agent_backend import (
AgentBackendAgentAppRunInput,
AgentBackendModelConfig,
AgentBackendRunRequestBuilder,
redact_for_agent_backend_log,
)
from core.app.entities.app_invoke_entities import DifyRunContext
from core.workflow.nodes.agent_v2.plugin_tools_builder import (
WorkflowAgentPluginToolsBuilder,
WorkflowAgentPluginToolsBuildError,
)
from models.agent_config_entities import AgentSoulConfig
from models.provider_ids import ModelProviderID
class AgentAppRuntimeRequestBuildError(ValueError):
"""Raised when Agent App state cannot be mapped to a valid run request."""
def __init__(self, error_code: str, message: str) -> None:
self.error_code = error_code
super().__init__(message)
class CredentialsProvider(Protocol):
def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]: ...
@dataclass(frozen=True, slots=True)
class AgentAppRuntimeBuildContext:
dify_context: DifyRunContext
agent_id: str
agent_config_snapshot_id: str
agent_soul: AgentSoulConfig
conversation_id: str
user_query: str
idempotency_key: str
session_snapshot: CompositorSessionSnapshot | None = None
@dataclass(frozen=True, slots=True)
class AgentAppRuntimeRequest:
request: CreateRunRequest
redacted_request: dict[str, Any]
metadata: dict[str, Any]
class AgentAppRuntimeRequestBuilder:
"""Build dify-agent run requests from Agent App conversation state."""
def __init__(
self,
*,
credentials_provider: CredentialsProvider,
request_builder: AgentBackendRunRequestBuilder | None = None,
plugin_tools_builder: WorkflowAgentPluginToolsBuilder | None = None,
) -> None:
self._credentials_provider = credentials_provider
self._request_builder = request_builder or AgentBackendRunRequestBuilder()
self._plugin_tools_builder = plugin_tools_builder or WorkflowAgentPluginToolsBuilder()
def build(self, context: AgentAppRuntimeBuildContext) -> AgentAppRuntimeRequest:
agent_soul = context.agent_soul
if agent_soul.model is None:
raise AgentAppRuntimeRequestBuildError(
"agent_model_not_configured",
"Agent App requires the Agent Soul model to be configured.",
)
metadata = self._build_metadata(context)
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
try:
tools_layer = self._plugin_tools_builder.build(
tenant_id=context.dify_context.tenant_id,
app_id=context.dify_context.app_id,
user_id=context.dify_context.user_id,
tools=agent_soul.tools,
invoke_from=context.dify_context.invoke_from,
)
except WorkflowAgentPluginToolsBuildError as error:
raise AgentAppRuntimeRequestBuildError(error.error_code, str(error)) from error
if tools_layer is not None:
metadata["agent_tools"] = {
"dify_tool_count": len(tools_layer.tools),
"dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools],
}
request = self._request_builder.build_for_agent_app(
AgentBackendAgentAppRunInput(
model=AgentBackendModelConfig(
plugin_id=self._plugin_daemon_plugin_id(
plugin_id=agent_soul.model.plugin_id,
model_provider=agent_soul.model.model_provider,
),
model_provider=self._plugin_daemon_provider_name(agent_soul.model.model_provider),
model=agent_soul.model.model,
credentials=self._normalize_credentials(credentials),
model_settings=agent_soul.model.model_settings,
),
execution_context=DifyExecutionContextLayerConfig(
tenant_id=context.dify_context.tenant_id,
user_id=context.dify_context.user_id,
app_id=context.dify_context.app_id,
conversation_id=context.conversation_id,
agent_id=context.agent_id,
agent_config_version_id=context.agent_config_snapshot_id,
invoke_from="agent_app",
),
agent_soul_prompt=agent_soul.prompt.system_prompt or None,
user_prompt=context.user_query,
tools=tools_layer,
session_snapshot=context.session_snapshot,
idempotency_key=context.idempotency_key,
metadata=metadata,
)
)
redacted = cast(dict[str, Any], redact_for_agent_backend_log(request))
return AgentAppRuntimeRequest(request=request, redacted_request=redacted, metadata=metadata)
@staticmethod
def _build_metadata(context: AgentAppRuntimeBuildContext) -> dict[str, Any]:
return {
"tenant_id": context.dify_context.tenant_id,
"app_id": context.dify_context.app_id,
"conversation_id": context.conversation_id,
"agent_id": context.agent_id,
"agent_config_snapshot_id": context.agent_config_snapshot_id,
}
@staticmethod
def _plugin_daemon_plugin_id(*, plugin_id: str, model_provider: str) -> str:
"""Return the transport plugin id expected by plugin-daemon headers."""
if plugin_id.count("/") == 1:
return plugin_id
if plugin_id:
return ModelProviderID(plugin_id).plugin_id
return ModelProviderID(model_provider).plugin_id
@staticmethod
def _plugin_daemon_provider_name(model_provider: str) -> str:
"""Return the provider name expected by plugin-daemon dispatch payloads."""
return ModelProviderID(model_provider).provider_name
@staticmethod
def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]:
normalized: dict[str, str | int | float | bool | None] = {}
for key, value in credentials.items():
if isinstance(value, str | int | float | bool) or value is None:
normalized[key] = value
else:
normalized[key] = str(value)
return normalized
__all__ = [
"AgentAppRuntimeBuildContext",
"AgentAppRuntimeRequest",
"AgentAppRuntimeRequestBuildError",
"AgentAppRuntimeRequestBuilder",
]

View File

@ -0,0 +1,103 @@
"""Conversation-keyed Agent backend session store for the Agent App type.
Shares the unified ``agent_runtime_sessions`` table with the workflow Agent
Node store, but owns rows with ``owner_type = conversation``: one Agent App
conversation maps to one Agent session, so multi-turn chat re-enters the same
``session_snapshot``. Cross-conversation memory (PRD Global / Per app) is a
phase-2 concern and not modeled here.
"""
from __future__ import annotations
from dataclasses import dataclass
from agenton.compositor import CompositorSessionSnapshot
from sqlalchemy import select
from core.db.session_factory import session_factory
from libs.datetime_utils import naive_utc_now
from models.agent import (
AgentRuntimeSession,
AgentRuntimeSessionOwnerType,
AgentRuntimeSessionStatus,
)
@dataclass(frozen=True, slots=True)
class AgentAppSessionScope:
"""Identity of one Agent App conversation session."""
tenant_id: str
app_id: str
conversation_id: str
agent_id: str
class AgentAppRuntimeSessionStore:
"""Persists Agent backend session snapshots for Agent App conversations."""
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None:
with session_factory.create_session() as session:
row = session.scalar(self._active_stmt(scope))
if row is None:
return None
return CompositorSessionSnapshot.model_validate_json(row.session_snapshot)
def save_active_snapshot(
self,
*,
scope: AgentAppSessionScope,
backend_run_id: str,
snapshot: CompositorSessionSnapshot | None,
) -> None:
if snapshot is None:
return
snapshot_json = snapshot.model_dump_json()
with session_factory.create_session() as session:
row = session.scalar(self._scope_stmt(scope))
if row is None:
row = AgentRuntimeSession(
tenant_id=scope.tenant_id,
app_id=scope.app_id,
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
agent_id=scope.agent_id,
conversation_id=scope.conversation_id,
backend_run_id=backend_run_id,
session_snapshot=snapshot_json,
composition_layer_specs="[]",
status=AgentRuntimeSessionStatus.ACTIVE,
)
session.add(row)
else:
row.backend_run_id = backend_run_id
row.session_snapshot = snapshot_json
row.status = AgentRuntimeSessionStatus.ACTIVE
row.cleaned_at = None
session.commit()
def mark_cleaned(self, *, scope: AgentAppSessionScope, backend_run_id: str | None = None) -> None:
with session_factory.create_session() as session:
row = session.scalar(self._active_stmt(scope))
if row is None:
return
if backend_run_id is not None:
row.backend_run_id = backend_run_id
row.status = AgentRuntimeSessionStatus.CLEANED
row.cleaned_at = naive_utc_now()
session.commit()
@staticmethod
def _scope_stmt(scope: AgentAppSessionScope):
return select(AgentRuntimeSession).where(
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
AgentRuntimeSession.tenant_id == scope.tenant_id,
AgentRuntimeSession.conversation_id == scope.conversation_id,
AgentRuntimeSession.agent_id == scope.agent_id,
)
@classmethod
def _active_stmt(cls, scope: AgentAppSessionScope):
return cls._scope_stmt(scope).where(AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE)
__all__ = ["AgentAppRuntimeSessionStore", "AgentAppSessionScope"]

View File

@ -200,6 +200,19 @@ class AgentChatAppGenerateEntity(ConversationAppGenerateEntity, EasyUIBasedAppGe
pass
class AgentAppGenerateEntity(ConversationAppGenerateEntity, EasyUIBasedAppGenerateEntity):
"""
Agent App (new Agent app type) Generate Entity.
Rides the EasyUI chat pipeline, but the answer is produced by the dify-agent
backend rather than an in-process LLM call. ``model_conf`` is synthesized
from the bound Agent Soul model so the chat task pipeline can persist usage.
"""
agent_id: str
agent_config_snapshot_id: str
class AdvancedChatAppGenerateEntity(ConversationAppGenerateEntity):
"""
Advanced Chat Application Generate Entity.

View File

@ -10,6 +10,7 @@ from clients.agent_backend.request_builder import CleanupLayerSpec
from core.db.session_factory import session_factory
from libs.datetime_utils import naive_utc_now
from models.agent import (
AgentRuntimeSessionOwnerType,
WorkflowAgentRuntimeSession,
WorkflowAgentRuntimeSessionStatus,
)
@ -125,6 +126,7 @@ class WorkflowAgentRuntimeSessionStore:
row = WorkflowAgentRuntimeSession(
tenant_id=scope.tenant_id,
app_id=scope.app_id,
owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN,
workflow_id=scope.workflow_id,
workflow_run_id=scope.workflow_run_id,
node_id=scope.node_id,

192
api/fields/agent_fields.py Normal file
View File

@ -0,0 +1,192 @@
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

@ -0,0 +1,140 @@
"""unify agent runtime sessions table
Revision ID: 121e7346074d
Revises: 7885bd53f9a9
Create Date: 2026-05-29 10:54:19.400054
Unifies the workflow-only ``workflow_agent_runtime_sessions`` table into an
owner-agnostic ``agent_runtime_sessions`` table that serves both workflow
Agent Node runs (owner_type=workflow_run) and Agent App conversations
(owner_type=conversation). The feature is unreleased, so the old table is
dropped rather than migrated (no data to preserve).
"""
import sqlalchemy as sa
from alembic import op
import models as models
# revision identifiers, used by Alembic.
revision = "121e7346074d"
down_revision = "7885bd53f9a9"
branch_labels = None
depends_on = None
def _is_pg() -> bool:
return op.get_bind().dialect.name == "postgresql"
def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column:
kwargs: dict[str, object] = {"nullable": nullable, "primary_key": primary_key}
if primary_key and _is_pg():
kwargs["server_default"] = sa.text("uuidv7()")
return sa.Column(name, models.types.StringUUID(), **kwargs)
def upgrade() -> None:
# Drop the unreleased workflow-only table; recreate as the unified table.
op.drop_table("workflow_agent_runtime_sessions")
op.create_table(
"agent_runtime_sessions",
_uuid_column("id", primary_key=True),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("app_id", models.types.StringUUID(), nullable=False),
sa.Column("owner_type", sa.String(length=32), nullable=False),
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
sa.Column("backend_run_id", sa.String(length=255), nullable=True),
sa.Column("session_snapshot", models.types.LongText(), nullable=False),
# Workflow-owner columns (NULL for conversation owner).
sa.Column("workflow_id", models.types.StringUUID(), nullable=True),
sa.Column("workflow_run_id", models.types.StringUUID(), nullable=True),
sa.Column("node_id", sa.String(length=255), nullable=True),
sa.Column("node_execution_id", sa.String(length=255), nullable=True),
sa.Column("binding_id", models.types.StringUUID(), nullable=True),
sa.Column("agent_config_snapshot_id", models.types.StringUUID(), nullable=True),
# MySQL rejects defaults on TEXT; the ORM always supplies this value.
sa.Column("composition_layer_specs", models.types.LongText(), nullable=False),
# Conversation-owner column (NULL for workflow owner).
sa.Column("conversation_id", models.types.StringUUID(), nullable=True),
sa.Column("status", sa.String(length=32), server_default=sa.text("'active'"), nullable=False),
sa.Column("cleaned_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("agent_runtime_session_pkey")),
)
with op.batch_alter_table("agent_runtime_sessions", schema=None) as batch_op:
batch_op.create_index(
"agent_runtime_session_workflow_scope_unique",
["tenant_id", "workflow_run_id", "node_id", "binding_id", "agent_id"],
unique=True,
postgresql_where=sa.text("workflow_run_id IS NOT NULL"),
)
batch_op.create_index(
"agent_runtime_session_conversation_scope_unique",
["tenant_id", "conversation_id", "agent_id"],
unique=True,
postgresql_where=sa.text("conversation_id IS NOT NULL"),
)
batch_op.create_index(
"agent_runtime_session_workflow_lookup_idx",
["tenant_id", "workflow_run_id", "node_id", "status"],
)
batch_op.create_index(
"agent_runtime_session_conversation_lookup_idx",
["tenant_id", "conversation_id", "status"],
)
batch_op.create_index("agent_runtime_session_backend_run_idx", ["backend_run_id"])
def downgrade() -> None:
with op.batch_alter_table("agent_runtime_sessions", schema=None) as batch_op:
batch_op.drop_index("agent_runtime_session_backend_run_idx")
batch_op.drop_index("agent_runtime_session_conversation_lookup_idx")
batch_op.drop_index("agent_runtime_session_workflow_lookup_idx")
batch_op.drop_index(
"agent_runtime_session_conversation_scope_unique",
postgresql_where=sa.text("conversation_id IS NOT NULL"),
)
batch_op.drop_index(
"agent_runtime_session_workflow_scope_unique",
postgresql_where=sa.text("workflow_run_id IS NOT NULL"),
)
op.drop_table("agent_runtime_sessions")
op.create_table(
"workflow_agent_runtime_sessions",
_uuid_column("id", primary_key=True),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("app_id", models.types.StringUUID(), nullable=False),
sa.Column("workflow_id", models.types.StringUUID(), nullable=False),
sa.Column("workflow_run_id", models.types.StringUUID(), nullable=False),
sa.Column("node_id", sa.String(length=255), nullable=False),
sa.Column("node_execution_id", sa.String(length=255), nullable=True),
sa.Column("binding_id", models.types.StringUUID(), nullable=False),
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
sa.Column("agent_config_snapshot_id", models.types.StringUUID(), nullable=False),
sa.Column("backend_run_id", sa.String(length=255), nullable=True),
sa.Column("session_snapshot", models.types.LongText(), nullable=False),
sa.Column("composition_layer_specs", models.types.LongText(), nullable=False),
sa.Column("status", sa.String(length=32), server_default=sa.text("'active'"), nullable=False),
sa.Column("cleaned_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("workflow_agent_runtime_session_pkey")),
sa.UniqueConstraint(
"tenant_id",
"workflow_run_id",
"node_id",
"binding_id",
"agent_id",
name=op.f("workflow_agent_runtime_session_scope_unique"),
),
)
with op.batch_alter_table("workflow_agent_runtime_sessions", schema=None) as batch_op:
batch_op.create_index(
"workflow_agent_runtime_session_lookup_idx",
["tenant_id", "workflow_run_id", "node_id", "status"],
)
batch_op.create_index("workflow_agent_runtime_session_backend_run_idx", ["backend_run_id"])

View File

@ -15,6 +15,9 @@ from .agent import (
AgentConfigSnapshot,
AgentIconType,
AgentKind,
AgentRuntimeSession,
AgentRuntimeSessionOwnerType,
AgentRuntimeSessionStatus,
AgentScope,
AgentSource,
AgentStatus,
@ -146,6 +149,9 @@ __all__ = [
"AgentConfigSnapshot",
"AgentIconType",
"AgentKind",
"AgentRuntimeSession",
"AgentRuntimeSessionOwnerType",
"AgentRuntimeSessionStatus",
"AgentScope",
"AgentSource",
"AgentStatus",

View File

@ -92,15 +92,33 @@ class WorkflowAgentBindingType(StrEnum):
INLINE_AGENT = "inline_agent"
class WorkflowAgentRuntimeSessionStatus(StrEnum):
"""Lifecycle state of an Agent backend session snapshot owned by a workflow run."""
class AgentRuntimeSessionStatus(StrEnum):
"""Lifecycle state of an Agent backend session snapshot.
# Snapshot can be reused by a later Agent run in the same workflow run.
Owner-agnostic: applies both to workflow Agent Node runs (owner =
workflow_run) and to Agent App conversations (owner = conversation).
"""
# Snapshot can be reused by a later Agent run in the same session.
ACTIVE = "active"
# Snapshot has been retired and must not be submitted to Agent backend again.
CLEANED = "cleaned"
class AgentRuntimeSessionOwnerType(StrEnum):
"""Which product surface owns an Agent runtime session row."""
# Owned by one workflow Agent Node execution scope.
WORKFLOW_RUN = "workflow_run"
# Owned by one Agent App conversation (multi-turn chat).
CONVERSATION = "conversation"
# Back-compat alias: the workflow lifecycle code (shipped in PR #36724) imports
# the old name. Kept so unifying the table does not churn that path.
WorkflowAgentRuntimeSessionStatus = AgentRuntimeSessionStatus
class Agent(DefaultFieldsMixin, Base):
"""Workspace-scoped Agent identity used by Agent Roster and workflow-only agents."""
@ -284,54 +302,88 @@ class WorkflowAgentNodeBinding(DefaultFieldsMixin, Base):
return dict(self.node_job_config)
class WorkflowAgentRuntimeSession(DefaultFieldsMixin, Base):
"""Persisted Agent backend session snapshot for one workflow Agent node execution scope.
class AgentRuntimeSession(DefaultFieldsMixin, Base):
"""Persisted Agent backend session snapshot, owner-agnostic.
The snapshot is runtime state returned by Agent backend. It is intentionally
separate from Agent Soul snapshots and workflow node-job config.
One unified table serves both owners (decision Q2):
- workflow Agent Node runs: ``owner_type = workflow_run``; the
``workflow_id / workflow_run_id / node_id / binding_id /
agent_config_snapshot_id / composition_layer_specs`` columns are set.
- Agent App conversations: ``owner_type = conversation``; the
``conversation_id`` column is set and the workflow columns stay NULL.
The snapshot is runtime state returned by Agent backend, kept separate from
Agent Soul snapshots and workflow node-job config.
"""
__tablename__ = "workflow_agent_runtime_sessions"
__tablename__ = "agent_runtime_sessions"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="workflow_agent_runtime_session_pkey"),
UniqueConstraint(
sa.PrimaryKeyConstraint("id", name="agent_runtime_session_pkey"),
# Workflow owner uniqueness (partial: only rows with a workflow_run_id).
Index(
"agent_runtime_session_workflow_scope_unique",
"tenant_id",
"workflow_run_id",
"node_id",
"binding_id",
"agent_id",
name="workflow_agent_runtime_session_scope_unique",
unique=True,
postgresql_where=sa.text("workflow_run_id IS NOT NULL"),
),
# Conversation owner uniqueness (partial: only rows with a conversation_id).
Index(
"agent_runtime_session_conversation_scope_unique",
"tenant_id",
"conversation_id",
"agent_id",
unique=True,
postgresql_where=sa.text("conversation_id IS NOT NULL"),
),
Index(
"workflow_agent_runtime_session_lookup_idx",
"agent_runtime_session_workflow_lookup_idx",
"tenant_id",
"workflow_run_id",
"node_id",
"status",
),
Index("workflow_agent_runtime_session_backend_run_idx", "backend_run_id"),
Index(
"agent_runtime_session_conversation_lookup_idx",
"tenant_id",
"conversation_id",
"status",
),
Index("agent_runtime_session_backend_run_idx", "backend_run_id"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
workflow_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
workflow_run_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
node_id: Mapped[str] = mapped_column(String(255), nullable=False)
node_execution_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
binding_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
owner_type: Mapped[AgentRuntimeSessionOwnerType] = mapped_column(
EnumText(AgentRuntimeSessionOwnerType, length=32), nullable=False
)
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_config_snapshot_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
backend_run_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
session_snapshot: Mapped[str] = mapped_column(LongText, nullable=False)
# JSON-encoded list of ``WorkflowAgentSessionLayerSpec`` ({name, type, deps,
# config}). Drives Agent backend cleanup-only runs: the agenton compositor
# rejects a session snapshot whose layer names do not match the cleanup
# composition, so we must replay the same layer graph (minus credential-
# bearing plugin layers) when issuing the cleanup request.
# Workflow-owner columns (NULL for conversation owner).
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
node_execution_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
agent_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
# JSON-encoded list of cleanup layer specs ({name, type, deps, config}).
# Drives Agent backend cleanup-only runs: the agenton compositor rejects a
# session snapshot whose layer names do not match the cleanup composition,
# so we replay the same layer graph (minus credential-bearing plugin layers).
composition_layer_specs: Mapped[str] = mapped_column(LongText, nullable=False, server_default="[]")
status: Mapped[WorkflowAgentRuntimeSessionStatus] = mapped_column(
EnumText(WorkflowAgentRuntimeSessionStatus, length=32),
# Conversation-owner column (NULL for workflow owner).
conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
status: Mapped[AgentRuntimeSessionStatus] = mapped_column(
EnumText(AgentRuntimeSessionStatus, length=32),
nullable=False,
default=WorkflowAgentRuntimeSessionStatus.ACTIVE,
default=AgentRuntimeSessionStatus.ACTIVE,
)
cleaned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Back-compat alias for the shipped workflow lifecycle code (PR #36724).
WorkflowAgentRuntimeSession = AgentRuntimeSession

View File

@ -366,6 +366,10 @@ class AppMode(StrEnum):
CHAT = "chat"
ADVANCED_CHAT = "advanced-chat"
AGENT_CHAT = "agent-chat"
# New Agent App type backed by the Dify Agent runtime (distinct from the
# legacy ``agent-chat`` ReAct app). The app is bound 1:1 to a roster Agent
# via ``Agent.app_id``; its configuration lives in the Agent Soul snapshot.
AGENT = "agent"
CHANNEL = "channel"
RAG_PIPELINE = "rag-pipeline"
@ -458,6 +462,27 @@ class App(Base):
return None
@property
def bound_agent_id(self) -> str | None:
"""For an Agent App (mode=agent), the roster Agent it is backed by.
Resolved via ``Agent.app_id`` so the console can open the Composer in
roster-detail mode from the app id. ``None`` for non-agent apps.
"""
if self.mode != AppMode.AGENT:
return None
from .agent import Agent, AgentScope, AgentSource, AgentStatus
agent = db.session.scalar(
select(Agent).where(
Agent.app_id == self.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
)
return agent.id if agent else None
@property
def api_base_url(self) -> str:
base = dify_config.SERVICE_API_URL or request.host_url.rstrip("/")

View File

@ -343,11 +343,19 @@ 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 |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent roster list | [AgentRosterListResponse](#agentrosterlistresponse) |
#### POST
##### Parameters
@ -358,18 +366,27 @@ Check if activation token is valid
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Agent created | [AgentRosterResponse](#agentrosterresponse) |
### /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 |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent invite options | [AgentInviteOptionsResponse](#agentinviteoptionsresponse) |
### /agents/{agent_id}
@ -384,7 +401,7 @@ Check if activation token is valid
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| 204 | Agent archived |
#### GET
##### Parameters
@ -395,9 +412,9 @@ Check if activation token is valid
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent detail | [AgentRosterResponse](#agentrosterresponse) |
#### PATCH
##### Parameters
@ -409,9 +426,9 @@ Check if activation token is valid
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent updated | [AgentRosterResponse](#agentrosterresponse) |
### /agents/{agent_id}/versions
@ -424,9 +441,9 @@ Check if activation token is valid
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent versions | [AgentConfigSnapshotListResponse](#agentconfigsnapshotlistresponse) |
### /agents/{agent_id}/versions/{version_id}
@ -440,9 +457,9 @@ Check if activation token is valid
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent version detail | [AgentConfigSnapshotDetailResponse](#agentconfigsnapshotdetailresponse) |
### /all-workspaces
@ -978,9 +995,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer state | [AgentAppComposerResponse](#agentappcomposerresponse) |
#### PUT
##### Parameters
@ -992,9 +1009,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer saved | [AgentAppComposerResponse](#agentappcomposerresponse) |
### /apps/{app_id}/agent-composer/candidates
@ -1007,9 +1024,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) |
### /apps/{app_id}/agent-composer/validate
@ -1023,9 +1040,31 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) |
### /apps/{app_id}/agent-features
#### POST
##### Description
Update an Agent App's presentation features (opener, follow-up, citations, ...)
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| payload | body | | Yes | [AgentAppFeaturesRequest](#agentappfeaturesrequest) |
| app_id | path | Application ID | Yes | string |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Features updated successfully | [SimpleResultResponse](#simpleresultresponse) |
| 400 | Invalid configuration | |
| 404 | App not found | |
### /apps/{app_id}/agent/logs
@ -3224,9 +3263,9 @@ Run draft workflow loop node
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer state | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
#### PUT
##### Parameters
@ -3239,9 +3278,9 @@ Run draft workflow loop node
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer saved | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates
@ -3255,9 +3294,9 @@ Run draft workflow loop node
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact
@ -3268,12 +3307,13 @@ Run draft workflow loop node
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| node_id | path | | Yes | string |
| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer impact | [AgentComposerImpactResponse](#agentcomposerimpactresponse) |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster
@ -3288,9 +3328,9 @@ Run draft workflow loop node
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer saved to roster | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/validate
@ -3305,9 +3345,9 @@ Run draft workflow loop node
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run
@ -10651,6 +10691,167 @@ 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 |
#### AgentAppFeaturesRequest
Presentation features configurable on an Agent App.
All fields are optional; an omitted field is reset to its disabled/empty
default (the config form sends the full desired feature state on save).
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| opening_statement | string | Conversation opener shown before the first turn | No |
| retriever_resource | object | Citations / attributions config, e.g. {'enabled': true} | No |
| sensitive_word_avoidance | object | Content moderation config | No |
| speech_to_text | object | Speech-to-text config | No |
| suggested_questions | [ string ] | Preset questions shown alongside the opener | No |
| suggested_questions_after_answer | object | Follow-up suggestions config, e.g. {'enabled': true} | No |
| text_to_speech | object | Text-to-speech config | No |
#### 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.
@ -10665,6 +10866,35 @@ 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 |
@ -10674,6 +10904,27 @@ 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 |
@ -10687,6 +10938,50 @@ Supported icon storage formats for Agent roster entries.
| 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 |
@ -10821,6 +11116,22 @@ 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 |
@ -11073,6 +11384,7 @@ Enum class for api provider schema type.
| access_mode | string | | No |
| api_base_url | string | | No |
| app_model_config | [ModelConfig](#modelconfig) | | No |
| bound_agent_id | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
| deleted_tools | [ [DeletedTool](#deletedtool) ] | | No |
@ -11403,7 +11715,7 @@ Button styles for user actions.
| conversation_id | string | Conversation ID | No |
| files | [ ] | Uploaded files | No |
| inputs | object | | Yes |
| model_config | object | | Yes |
| model_config | object | | No |
| parent_message_id | string | Parent message ID | No |
| query | string | User query | Yes |
| response_mode | string | Response mode<br>*Enum:* `"blocking"`, `"streaming"` | No |
@ -11499,7 +11811,7 @@ Button styles for user actions.
| ---- | ---- | ----------- | -------- |
| files | [ ] | Uploaded files | No |
| inputs | object | | Yes |
| model_config | object | | Yes |
| model_config | object | | No |
| query | string | Query text | No |
| response_mode | string | Response mode<br>*Enum:* `"blocking"`, `"streaming"` | No |
| retriever_from | string | Retriever source | No |
@ -11528,6 +11840,12 @@ 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 |
@ -15369,6 +15687,32 @@ 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

@ -15,6 +15,7 @@ from models.agent import (
AgentStatus,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import AgentSoulConfig
from models.workflow import Workflow
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import (
@ -203,6 +204,83 @@ class AgentRosterService:
raise AgentNameConflictError() from exc
return agent
def create_backing_agent_for_app(
self,
*,
tenant_id: str,
account_id: str,
app_id: str,
name: str,
description: str = "",
icon_type: Any = None,
icon: str | None = None,
icon_background: str | None = None,
) -> Agent:
"""Create the roster Agent that backs an Agent App, linked via ``app_id``.
Unlike :meth:`create_roster_agent`, this does not commit: the caller
(``AppService.create_app``) owns the surrounding transaction so the App
row and its backing Agent are persisted atomically. A default (empty)
Agent Soul is seeded; the user configures model/prompt/tools afterward in
the Composer.
"""
agent = Agent(
tenant_id=tenant_id,
name=name,
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
app_id=app_id,
created_by=account_id,
updated_by=account_id,
)
self._session.add(agent)
try:
self._session.flush()
except IntegrityError as exc:
self._session.rollback()
raise AgentNameConflictError() from exc
version = AgentConfigSnapshot(
tenant_id=tenant_id,
agent_id=agent.id,
version=1,
config_snapshot=AgentSoulConfig(),
created_by=account_id,
)
self._session.add(version)
self._session.flush()
revision = AgentConfigRevision(
tenant_id=tenant_id,
agent_id=agent.id,
current_snapshot_id=version.id,
revision=1,
operation=AgentConfigRevisionOperation.CREATE_VERSION,
created_by=account_id,
)
self._session.add(revision)
agent.active_config_snapshot_id = version.id
self._session.flush()
return agent
def get_app_backing_agent(self, *, tenant_id: str, app_id: str) -> Agent | None:
"""Return the roster Agent that backs the given Agent App, if any."""
return self._session.scalar(
select(Agent).where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
)
def get_roster_agent_detail(self, *, tenant_id: str, agent_id: str) -> dict[str, Any]:
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
active_version = self._get_version(

View File

@ -0,0 +1,96 @@
"""Validate and persist the app-level presentation features of an Agent App.
An Agent App keeps its model / prompt / tools in the bound Agent Soul; only the
PRD "Misc Legacy" presentation features — conversation opener, follow-up
suggestions, citations, content moderation and speech — live on
``app_model_config``. This service validates that feature subset and writes a
new ``app_model_config`` version, mirroring the legacy model-config save flow
but deliberately never touching model, prompt, tools, datasets or agent_mode
(those are owned by the Soul and must not be settable through this endpoint).
"""
from __future__ import annotations
from typing import Any, cast
from core.app.app_config.common.sensitive_word_avoidance.manager import SensitiveWordAvoidanceConfigManager
from core.app.app_config.features.opening_statement.manager import OpeningStatementConfigManager
from core.app.app_config.features.retrieval_resource.manager import RetrievalResourceConfigManager
from core.app.app_config.features.speech_to_text.manager import SpeechToTextConfigManager
from core.app.app_config.features.suggested_questions_after_answer.manager import (
SuggestedQuestionsAfterAnswerConfigManager,
)
from core.app.app_config.features.text_to_speech.manager import TextToSpeechConfigManager
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from models.account import Account
from models.model import App, AppModelConfig, AppModelConfigDict
class AgentAppFeatureConfigService:
"""Service for the Agent App presentation-feature config surface."""
# The only keys this surface accepts. Anything else (model, pre_prompt,
# agent_mode, tools, datasets, user_input_form, ...) is dropped so a caller
# cannot smuggle Soul-owned configuration in through the feature endpoint.
ALLOWED_KEYS = (
"opening_statement",
"suggested_questions",
"suggested_questions_after_answer",
"speech_to_text",
"text_to_speech",
"retriever_resource",
"sensitive_word_avoidance",
)
@classmethod
def validate_features(cls, tenant_id: str, config: dict[str, Any]) -> AppModelConfigDict:
"""Validate and normalize the feature subset, filling defaults."""
working = {key: config[key] for key in cls.ALLOWED_KEYS if key in config}
related_keys: list[str] = []
for validate in (
OpeningStatementConfigManager.validate_and_set_defaults,
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults,
SpeechToTextConfigManager.validate_and_set_defaults,
TextToSpeechConfigManager.validate_and_set_defaults,
RetrievalResourceConfigManager.validate_and_set_defaults,
):
working, keys = validate(working)
related_keys.extend(keys)
# Moderation needs the tenant to validate its provider configuration.
working, keys = SensitiveWordAvoidanceConfigManager.validate_and_set_defaults(tenant_id, working)
related_keys.extend(keys)
filtered = {key: working.get(key) for key in set(related_keys)}
return cast(AppModelConfigDict, filtered)
@classmethod
def update_features(cls, *, app_model: App, account: Account, config: dict[str, Any]) -> AppModelConfig:
"""Persist the presentation features as a new app_model_config version.
Returns the new ``AppModelConfig`` row (now referenced by the app); the
row carries only feature flags, with model / prompt / agent_mode left
``NULL`` so the Agent Soul remains the single source of truth for those.
"""
validated = cls.validate_features(app_model.tenant_id, config)
new_config = AppModelConfig(
app_id=app_model.id,
created_by=account.id,
updated_by=account.id,
).from_model_config_dict(validated)
db.session.add(new_config)
db.session.flush()
app_model.app_model_config_id = new_config.id
app_model.updated_by = account.id
app_model.updated_at = naive_utc_now()
db.session.commit()
return new_config
__all__ = ["AgentAppFeatureConfigService"]

View File

@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
from configs import dify_config
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
from core.app.apps.agent_app.app_generator import AgentAppGenerator
from core.app.apps.agent_chat.app_generator import AgentChatAppGenerator
from core.app.apps.chat.app_generator import ChatAppGenerator
from core.app.apps.completion.app_generator import CompletionAppGenerator
@ -140,6 +141,15 @@ class AppGenerateService:
),
request_id,
)
case AppMode.AGENT:
return rate_limit.generate(
AgentAppGenerator.convert_to_event_stream(
AgentAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id,
)
case AppMode.CHAT:
return rate_limit.generate(
ChatAppGenerator.convert_to_event_stream(

View File

@ -23,6 +23,7 @@ from graphon.model_runtime.model_providers.base.large_language_model import Larg
from libs.datetime_utils import naive_utc_now
from libs.login import current_user
from models import Account
from models.agent import AgentIconType
from models.model import App, AppMode, AppModelConfig, IconType, Site
from models.tools import ApiToolProvider
from services.billing_service import BillingService
@ -38,7 +39,7 @@ logger = logging.getLogger(__name__)
class AppListParams(BaseModel):
page: int = Field(default=1, ge=1)
limit: int = Field(default=20, ge=1, le=100)
mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "channel", "all"] = "all"
mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] = "all"
name: str | None = None
tag_ids: list[str] | None = None
is_created_by_me: bool | None = None
@ -49,7 +50,7 @@ class AppListParams(BaseModel):
class CreateAppParams(BaseModel):
name: str = Field(min_length=1)
description: str | None = None
mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
icon_type: str | None = None
icon: str | None = None
icon_background: str | None = None
@ -124,6 +125,8 @@ class AppService:
filters.append(App.mode == AppMode.ADVANCED_CHAT)
elif params.mode == "agent-chat":
filters.append(App.mode == AppMode.AGENT_CHAT)
elif params.mode == "agent":
filters.append(App.mode == AppMode.AGENT)
if params.status:
filters.append(App.status == params.status)
@ -246,6 +249,39 @@ class AppService:
db.session.flush()
app.app_model_config_id = app_model_config.id
elif app_mode == AppMode.AGENT:
# An Agent App keeps its model / prompt / tools in the bound Agent
# Soul, so the app_model_config row carries no model — only the
# app-level presentation features the PRD requires (conversation
# opener, follow-up suggestions, citations, moderation, annotation).
# They default to disabled/empty here and are read by both the
# webapp /parameters endpoint and the chat pipeline. agent_mode is
# left unset so App.is_agent stays False (this is the new Agent App
# type, not a legacy function-call/react agent).
agent_app_model_config = AppModelConfig(app_id=app.id, created_by=account.id, updated_by=account.id)
db.session.add(agent_app_model_config)
db.session.flush()
app.app_model_config_id = agent_app_model_config.id
# Agent App type is backed 1:1 by a roster Agent (linked via Agent.app_id).
# Created in the same transaction so the App and its backing Agent persist
# atomically; the Agent Soul (model/prompt/tools) is configured afterward
# in the Composer.
if app_mode == AppMode.AGENT:
from services.agent.roster_service import AgentRosterService
icon_type = AgentIconType(params.icon_type) if params.icon_type else None
AgentRosterService(db.session).create_backing_agent_for_app(
tenant_id=tenant_id,
account_id=account.id,
app_id=app.id,
name=params.name,
description=params.description or "",
icon_type=icon_type,
icon=params.icon,
icon_background=params.icon_background,
)
db.session.commit()

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;
Redis, object storage, and custom ad hoc metadata still need their own cleanup.
object storage and custom ad hoc metadata still need their own cleanup.
"""
with app.app_context():
db.session.remove()
@ -524,13 +524,27 @@ 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 state after tests that use the containerized Flask app.
Clean DB and Redis state after tests that use the containerized Flask app.
This fixture intentionally does not depend on flask_app_with_containers so
non-DB tests under this package do not start the full app/container stack.
tests under this package do not start the full app/container stack just to
run state cleanup.
"""
yield
@ -538,7 +552,10 @@ def isolate_container_database(request: pytest.FixtureRequest) -> Generator[None
return
app = request.getfixturevalue("flask_app_with_containers")
_truncate_container_database(app)
try:
_truncate_container_database(app)
finally:
_flush_container_redis(app)
@pytest.fixture(scope="package", autouse=True)

View File

@ -0,0 +1,182 @@
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

@ -0,0 +1,39 @@
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,6 +30,90 @@ 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")
@ -67,14 +151,15 @@ def test_roster_list_post_creates_agent_and_returns_detail(app, monkeypatch):
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_roster_agent_detail",
lambda _self, **kwargs: {"id": kwargs["agent_id"], "tenant_id": kwargs["tenant_id"]},
lambda _self, **kwargs: _agent_response(kwargs["agent_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", "tenant_id": "tenant-1"}
assert result["id"] == "agent-1"
assert result["agent_kind"] == "dify_agent"
def test_invite_options_get_parses_app_id(app, monkeypatch):
@ -82,14 +167,14 @@ def test_invite_options_get_parses_app_id(app, monkeypatch):
def list_invite_options(_self, **kwargs):
captured.update(kwargs)
return {"data": []}
return {"data": [], "page": kwargs["page"], "limit": kwargs["limit"], "total": 0, "has_more": False}
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": []}
assert result == {"data": [], "page": 1, "limit": 10, "total": 0, "has_more": False}
assert captured == {"tenant_id": "tenant-1", "page": 1, "limit": 10, "keyword": None, "app_id": "app-1"}
@ -100,12 +185,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: {"id": kwargs["agent_id"]},
lambda _self, **kwargs: _agent_response(kwargs["agent_id"]),
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"update_roster_agent",
lambda _self, **kwargs: {"id": kwargs["agent_id"], "description": kwargs["payload"].description},
lambda _self, **kwargs: {**_agent_response(kwargs["agent_id"]), "description": kwargs["payload"].description},
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
@ -115,12 +200,29 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"list_agent_versions",
lambda _self, **kwargs: [{"id": "version-1"}],
lambda _self, **kwargs: [_version_response()],
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_agent_version_detail",
lambda _self, **kwargs: {"id": kwargs["version_id"], "agent_id": kwargs["agent_id"]},
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,
}
],
},
)
assert _unwrap(AgentRosterDetailApi.get)(AgentRosterDetailApi(), agent_id)["id"] == agent_id
@ -128,11 +230,10 @@ 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": [{"id": "version-1"}]}
assert _unwrap(AgentRosterVersionDetailApi.get)(AgentRosterVersionDetailApi(), agent_id, version_id) == {
"id": version_id,
"agent_id": agent_id,
}
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
def test_workflow_composer_get_put_validate_candidates_impact_and_save(app, monkeypatch):
@ -145,50 +246,52 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(app, monk
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_workflow_composer",
lambda **kwargs: {"node_id": kwargs["node_id"]},
lambda **kwargs: _workflow_composer_response(node_id=kwargs["node_id"]),
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"save_workflow_composer",
lambda **kwargs: {"saved": kwargs["payload"].save_strategy.value, "account_id": kwargs["account_id"]},
lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]),
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_workflow_candidates",
lambda **kwargs: {"data": []},
lambda **kwargs: _candidates_response("workflow"),
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"calculate_impact",
lambda **kwargs: {"current_snapshot_id": kwargs["current_snapshot_id"], "workflow_node_count": 1},
lambda **kwargs: {
"current_snapshot_id": kwargs["current_snapshot_id"],
"workflow_node_count": 1,
"bindings": [],
},
)
assert _unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), app_model, "node-1") == {
"node_id": "node-1"
}
workflow_state = _unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), app_model, "node-1")
assert workflow_state["node_id"] == "node-1"
with app.test_request_context(json=payload):
assert _unwrap(WorkflowAgentComposerApi.put)(WorkflowAgentComposerApi(), app_model, "node-1") == {
"saved": "node_job_only",
"account_id": "account-1",
}
saved_state = _unwrap(WorkflowAgentComposerApi.put)(WorkflowAgentComposerApi(), app_model, "node-1")
assert saved_state["save_options"] == ["node_job_only"]
assert _unwrap(WorkflowAgentComposerValidateApi.post)(
WorkflowAgentComposerValidateApi(), app_model, "node-1"
) == {"result": "success", "errors": []}
assert _unwrap(WorkflowAgentComposerCandidatesApi.get)(
WorkflowAgentComposerCandidatesApi(), app_model, "node-1"
) == {"data": []}
assert (
_unwrap(WorkflowAgentComposerCandidatesApi.get)(WorkflowAgentComposerCandidatesApi(), app_model, "node-1")[
"variant"
]
== "workflow"
)
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"
)["saved"]
== "node_job_only"
)
assert _unwrap(WorkflowAgentComposerSaveToRosterApi.post)(
WorkflowAgentComposerSaveToRosterApi(), app_model, "node-1"
)["save_options"] == ["node_job_only"]
def test_workflow_impact_returns_empty_without_version(app):
@ -212,28 +315,26 @@ def test_agent_app_composer_get_put_validate_and_candidates(app, monkeypatch):
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_agent_app_composer",
lambda **kwargs: {"loaded": True},
lambda **kwargs: _agent_app_composer_response(),
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"save_agent_app_composer",
lambda **kwargs: {"saved": kwargs["payload"].variant.value, "account_id": kwargs["account_id"]},
lambda **kwargs: _agent_app_composer_response(),
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_agent_app_candidates",
lambda **kwargs: {"data": []},
lambda **kwargs: _candidates_response("agent_app"),
)
assert _unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), app_model) == {"loaded": True}
assert _unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), app_model)["variant"] == "agent_app"
with app.test_request_context(json=payload):
assert _unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), app_model) == {
"saved": "agent_app",
"account_id": "account-1",
}
assert _unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), app_model)["variant"] == "agent_app"
assert _unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), app_model) == {
"result": "success",
"errors": [],
}
assert _unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model) == {"data": []}
agent_app_candidates = _unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model)
assert agent_app_candidates["variant"] == "agent_app"

View File

@ -0,0 +1,70 @@
"""Unit tests for get_app_parameters — the per-app-type webapp parameters resolver.
Covers the four branches: workflow-backed apps, easy-UI apps with an
app_model_config, Agent Apps (no config, defaults), and the unavailable case.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from core.app.app_config.common.parameters_mapping import (
AppParametersUnavailableError,
get_app_parameters,
)
from models.model import AppMode
def test_agent_app_defaults_to_disabled_features():
# An Agent App has no workflow and no app_model_config; every toggle defaults
# to disabled and the input form is empty (free-form chat box).
app_model = SimpleNamespace(mode=AppMode.AGENT, workflow=None, app_model_config=None)
params = get_app_parameters(app_model) # type: ignore[arg-type]
assert params["opening_statement"] is None
assert params["user_input_form"] == []
assert params["suggested_questions_after_answer"] == {"enabled": False}
assert params["file_upload"]["image"]["enabled"] is False
def test_easy_ui_app_reads_from_app_model_config():
amc = SimpleNamespace(
to_dict=lambda: {
"opening_statement": "Hi there!",
"user_input_form": [{"text-input": {"variable": "name"}}],
"suggested_questions_after_answer": {"enabled": True},
}
)
app_model = SimpleNamespace(mode=AppMode.CHAT, workflow=None, app_model_config=amc)
params = get_app_parameters(app_model) # type: ignore[arg-type]
assert params["opening_statement"] == "Hi there!"
assert params["user_input_form"] == [{"text-input": {"variable": "name"}}]
assert params["suggested_questions_after_answer"] == {"enabled": True}
def test_workflow_app_reads_from_workflow():
workflow = SimpleNamespace(
features_dict={"opening_statement": "From workflow"},
user_input_form=lambda to_old_structure: [{"paragraph": {"variable": "q"}}],
)
app_model = SimpleNamespace(mode=AppMode.ADVANCED_CHAT, workflow=workflow, app_model_config=None)
params = get_app_parameters(app_model) # type: ignore[arg-type]
assert params["opening_statement"] == "From workflow"
assert params["user_input_form"] == [{"paragraph": {"variable": "q"}}]
def test_workflow_app_without_workflow_is_unavailable():
app_model = SimpleNamespace(mode=AppMode.WORKFLOW, workflow=None, app_model_config=None)
with pytest.raises(AppParametersUnavailableError):
get_app_parameters(app_model) # type: ignore[arg-type]
def test_easy_ui_app_without_config_is_unavailable():
# A chat app with no published config is genuinely unavailable (unlike Agent).
app_model = SimpleNamespace(mode=AppMode.CHAT, workflow=None, app_model_config=None)
with pytest.raises(AppParametersUnavailableError):
get_app_parameters(app_model) # type: ignore[arg-type]

View File

@ -0,0 +1,84 @@
"""Unit tests for AgentAppConfigManager._synthesize_config_dict — the Soul →
app_model_config-shaped dict bridge that lets an Agent App ride the chat pipeline."""
from __future__ import annotations
from types import SimpleNamespace
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
from models.agent_config_entities import AgentSoulConfig
def _soul() -> AgentSoulConfig:
return AgentSoulConfig.model_validate(
{
"model": {
"plugin_id": "langgenius/openai",
"model_provider": "langgenius/openai/openai",
"model": "gpt-4o-mini",
"model_settings": {"temperature": 0.2},
},
"prompt": {"system_prompt": "You are Iris."},
}
)
def test_model_and_prompt_come_from_soul():
d = AgentAppConfigManager._synthesize_config_dict(_soul(), None)
assert d["model"] == {
"provider": "langgenius/openai/openai",
"name": "gpt-4o-mini",
"mode": "chat",
"completion_params": {"temperature": 0.2},
}
assert d["pre_prompt"] == "You are Iris."
assert d["user_input_form"] == []
def test_feature_flags_come_from_app_model_config_when_present():
# Q3: opener/follow-up/etc. live on app_model_config; model/prompt stay from Soul.
fake_amc = SimpleNamespace(
to_dict=lambda: {
"opening_statement": "Hi, I'm Iris.",
"suggested_questions_after_answer": {"enabled": True},
"model": {"provider": "should-be-overridden", "name": "old"},
"pre_prompt": "old prompt",
}
)
d = AgentAppConfigManager._synthesize_config_dict(_soul(), fake_amc) # type: ignore[arg-type]
# feature flags preserved
assert d["opening_statement"] == "Hi, I'm Iris."
assert d["suggested_questions_after_answer"] == {"enabled": True}
# model + prompt overridden by Soul (single source of truth)
assert d["model"]["name"] == "gpt-4o-mini"
assert d["pre_prompt"] == "You are Iris."
def test_missing_soul_model_leaves_no_model_key():
d = AgentAppConfigManager._synthesize_config_dict(AgentSoulConfig(), None)
assert "model" not in d
assert d["pre_prompt"] == ""
def test_prompt_type_defaults_to_simple():
# PromptTemplateConfigManager.convert requires prompt_type; an Agent App with
# no legacy app_model_config must still get the "simple" slot synthesized.
d = AgentAppConfigManager._synthesize_config_dict(_soul(), None)
assert d["prompt_type"] == "simple"
def test_get_app_config_has_null_model_config_id_without_legacy_row():
# An Agent App has no app_model_config row; the conversation's
# app_model_config_id (a UUID column) must be NULL, not "".
app_model = SimpleNamespace(
tenant_id="11111111-1111-1111-1111-111111111111",
id="22222222-2222-2222-2222-222222222222",
mode="agent",
)
app_config = AgentAppConfigManager.get_app_config(
app_model=app_model, # type: ignore[arg-type]
agent_soul=_soul(),
app_model_config=None,
conversation=None,
)
assert app_config.app_model_config_id is None

View File

@ -0,0 +1,150 @@
"""Unit tests for the Agent App runner — verifies the agent-backend event
stream is republished as chat queue events and the conversation snapshot is
saved, using the deterministic fake backend client (no live stack)."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from agenton.compositor import CompositorSessionSnapshot
from clients.agent_backend import (
AgentBackendError,
AgentBackendRunEventAdapter,
FakeAgentBackendRunClient,
FakeAgentBackendScenario,
)
from core.app.apps.agent_app.app_runner import AgentAppRunner
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
from core.app.apps.agent_app.session_store import AgentAppSessionScope
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent
from models.agent_config_entities import AgentSoulConfig
class _FakeCredentialsProvider:
def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]:
return {"openai_api_key": "sk-test"}
class _NoToolsBuilder:
def build(self, **kwargs):
del kwargs
class _FakeQueueManager:
def __init__(self) -> None:
self.events: list[Any] = []
def publish(self, event: Any, _from: Any) -> None:
self.events.append(event)
class _FakeSessionStore:
def __init__(self, loaded: CompositorSessionSnapshot | None = None) -> None:
self.loaded = loaded
self.saved: list[tuple[AgentAppSessionScope, str, CompositorSessionSnapshot | None]] = []
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None:
return self.loaded
def save_active_snapshot(self, *, scope, backend_run_id, snapshot) -> None:
self.saved.append((scope, backend_run_id, snapshot))
def _soul() -> AgentSoulConfig:
return AgentSoulConfig.model_validate(
{
"model": {
"plugin_id": "langgenius/openai",
"model_provider": "langgenius/openai/openai",
"model": "gpt-4o-mini",
},
"prompt": {"system_prompt": "You are Iris."},
}
)
def _dify_ctx() -> Any:
return SimpleNamespace(tenant_id="tenant-1", app_id="app-1", user_id="user-1", invoke_from=InvokeFrom.WEB_APP)
def _runner(client: FakeAgentBackendRunClient, store: _FakeSessionStore) -> AgentAppRunner:
return AgentAppRunner(
request_builder=AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
),
agent_backend_client=client,
event_adapter=AgentBackendRunEventAdapter(),
session_store=store, # type: ignore[arg-type]
)
def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None:
runner.run(
dify_context=_dify_ctx(),
agent_id="agent-1",
agent_config_snapshot_id="snap-1",
agent_soul=_soul(),
conversation_id="conv-1",
query="hello",
message_id="msg-1",
model_name="gpt-4o-mini",
queue_manager=qm, # type: ignore[arg-type]
)
def test_successful_turn_publishes_chunk_and_message_end_and_saves_session():
client = FakeAgentBackendRunClient() # SUCCESS: output {"text": "hello agent"}
store = _FakeSessionStore()
qm = _FakeQueueManager()
_run(_runner(client, store), qm)
# One LLM chunk + one message-end, carrying the backend's answer text.
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
assert len(chunk_events) == 1
assert len(end_events) == 1
assert chunk_events[0].chunk.delta.message.content == "hello agent"
assert end_events[0].llm_result.message.content == "hello agent"
assert end_events[0].llm_result.model == "gpt-4o-mini"
# The conversation session snapshot is persisted for multi-turn continuity.
assert store.saved
saved_scope, saved_run_id, saved_snapshot = store.saved[0]
assert saved_scope.conversation_id == "conv-1"
assert saved_run_id == "fake-run-1"
assert saved_snapshot is not None
def test_prior_session_snapshot_is_threaded_into_request():
prior = CompositorSessionSnapshot(layers=[])
client = FakeAgentBackendRunClient()
store = _FakeSessionStore(loaded=prior)
qm = _FakeQueueManager()
_run(_runner(client, store), qm)
assert client.request is not None
assert client.request.session_snapshot is prior
def test_failed_run_raises_agent_backend_error():
client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED)
store = _FakeSessionStore()
qm = _FakeQueueManager()
with pytest.raises(AgentBackendError):
_run(_runner(client, store), qm)
# No message-end on failure; no snapshot saved.
assert not [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
assert store.saved == []
def test_extract_answer_handles_plain_string_and_dict():
assert AgentAppRunner._extract_answer("plain text") == "plain text"
assert AgentAppRunner._extract_answer({"text": "hi"}) == "hi"
assert AgentAppRunner._extract_answer({"a": 1}) == '{"a": 1}'

View File

@ -0,0 +1,144 @@
"""Unit tests for the Agent App runtime request builder + the app-shaped
``AgentBackendRunRequestBuilder.build_for_agent_app`` DTO assembler."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from clients.agent_backend import (
AgentBackendAgentAppRunInput,
AgentBackendModelConfig,
AgentBackendRunRequestBuilder,
)
from core.app.apps.agent_app.runtime_request_builder import (
AgentAppRuntimeBuildContext,
AgentAppRuntimeRequestBuilder,
AgentAppRuntimeRequestBuildError,
)
from core.app.entities.app_invoke_entities import InvokeFrom
from models.agent_config_entities import AgentSoulConfig
def _exec_ctx() -> DifyExecutionContextLayerConfig:
return DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="agent_app")
class TestBuildForAgentApp:
def test_layers_have_no_workflow_job_prompt_and_include_history(self):
request = AgentBackendRunRequestBuilder().build_for_agent_app(
AgentBackendAgentAppRunInput(
model=AgentBackendModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
execution_context=_exec_ctx(),
user_prompt="hello",
agent_soul_prompt="You are Iris.",
)
)
names = [layer.name for layer in request.composition.layers]
assert names == [
"agent_soul_prompt",
"agent_app_user_prompt",
"execution_context",
"history",
"llm",
]
assert "workflow_node_job_prompt" not in names
assert request.purpose == "agent_app"
# Agent App keeps layers alive across turns by default.
assert request.on_exit.default.value == "suspend"
def test_blank_user_prompt_rejected(self):
with pytest.raises(ValueError, match="must not be blank"):
AgentBackendAgentAppRunInput(
model=AgentBackendModelConfig(plugin_id="p/q", model_provider="openai", model="m"),
execution_context=_exec_ctx(),
user_prompt=" ",
)
def test_soul_prompt_optional(self):
request = AgentBackendRunRequestBuilder().build_for_agent_app(
AgentBackendAgentAppRunInput(
model=AgentBackendModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
execution_context=_exec_ctx(),
user_prompt="hi",
)
)
assert [layer.name for layer in request.composition.layers][0] == "agent_app_user_prompt"
class _FakeCredentialsProvider:
def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]:
return {"openai_api_key": "sk-test", "max": 5}
class _NoToolsBuilder:
def build(self, **kwargs):
del kwargs
def _ctx(soul: AgentSoulConfig, *, query: str = "hello") -> AgentAppRuntimeBuildContext:
dify_context = SimpleNamespace(
tenant_id="tenant-1",
app_id="app-1",
user_id="user-1",
invoke_from=InvokeFrom.WEB_APP,
)
return AgentAppRuntimeBuildContext(
dify_context=dify_context, # type: ignore[arg-type]
agent_id="agent-1",
agent_config_snapshot_id="snap-1",
agent_soul=soul,
conversation_id="conv-1",
user_query=query,
idempotency_key="msg-1",
)
def _soul_with_model() -> AgentSoulConfig:
return AgentSoulConfig.model_validate(
{
"model": {
"plugin_id": "langgenius/openai",
"model_provider": "langgenius/openai/openai",
"model": "gpt-4o-mini",
},
"prompt": {"system_prompt": "You are Iris."},
}
)
class TestAgentAppRuntimeRequestBuilder:
def test_build_maps_soul_to_run_request(self):
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(_soul_with_model()))
req = result.request
assert req.purpose == "agent_app"
names = [layer.name for layer in req.composition.layers]
assert names == ["agent_soul_prompt", "agent_app_user_prompt", "execution_context", "history", "llm"]
# plugin_id / provider normalized for plugin-daemon transport.
llm = next(layer for layer in req.composition.layers if layer.name == "llm")
assert llm.config.plugin_id == "langgenius/openai"
assert llm.config.model_provider == "openai"
# execution context carries conversation + agent_app invoke source.
exec_ctx = next(layer for layer in req.composition.layers if layer.name == "execution_context")
assert exec_ctx.config.conversation_id == "conv-1"
assert exec_ctx.config.invoke_from == "agent_app"
# credentials are redacted in the log-safe view.
assert result.redacted_request["composition"]["layers"][-1]["config"]["credentials"] == "[REDACTED]"
assert result.metadata["conversation_id"] == "conv-1"
def test_build_raises_when_model_missing(self):
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
with pytest.raises(AgentAppRuntimeRequestBuildError) as exc:
builder.build(_ctx(AgentSoulConfig()))
assert exc.value.error_code == "agent_model_not_configured"

View File

@ -0,0 +1,115 @@
"""Unit tests for the conversation-keyed Agent App session store.
Exercises the real ORM round-trip against the project's in-memory SQLite engine
(per-test create/drop of the unified ``agent_runtime_sessions`` table), so the
conversation owner path is verified without Postgres.
"""
from __future__ import annotations
from collections.abc import Generator
import pytest
from agenton.compositor import CompositorSessionSnapshot
from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers.base import LifecycleState
from sqlalchemy import delete
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope
from core.db.session_factory import session_factory
from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus
def _scope(conversation_id: str = "conv-1", agent_id: str = "agent-1") -> AgentAppSessionScope:
return AgentAppSessionScope(
tenant_id="tenant-1",
app_id="app-1",
conversation_id=conversation_id,
agent_id=agent_id,
)
def _snapshot(messages: int = 1) -> CompositorSessionSnapshot:
return CompositorSessionSnapshot(
layers=[
LayerSessionSnapshot(
name="history",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={"messages": [{"role": "user", "content": f"m{i}"} for i in range(messages)]},
)
]
)
@pytest.fixture(autouse=True)
def _create_table() -> Generator[None, None, None]:
engine = session_factory.get_session_maker().kw["bind"]
AgentRuntimeSession.__table__.create(bind=engine, checkfirst=True)
yield
with session_factory.create_session() as session:
session.execute(delete(AgentRuntimeSession))
session.commit()
AgentRuntimeSession.__table__.drop(bind=engine, checkfirst=True)
def test_load_returns_none_when_no_row():
assert AgentAppRuntimeSessionStore().load_active_snapshot(_scope()) is None
def test_save_creates_conversation_owned_row_and_round_trips():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=2))
loaded = store.load_active_snapshot(_scope())
assert loaded is not None
assert loaded.layers[0].runtime_state["messages"] == [
{"role": "user", "content": "m0"},
{"role": "user", "content": "m1"},
]
with session_factory.create_session() as session:
row = session.query(AgentRuntimeSession).one()
assert row.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION
assert row.conversation_id == "conv-1"
assert row.workflow_run_id is None # conversation owner leaves workflow cols NULL
assert row.backend_run_id == "run-1"
def test_save_is_noop_when_snapshot_missing():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(scope=_scope(), backend_run_id="run-x", snapshot=None)
with session_factory.create_session() as session:
assert session.query(AgentRuntimeSession).count() == 0
def test_second_turn_updates_same_conversation_row():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=1))
store.save_active_snapshot(scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=3))
with session_factory.create_session() as session:
rows = session.query(AgentRuntimeSession).all()
assert len(rows) == 1
assert rows[0].backend_run_id == "run-2"
def test_mark_cleaned_then_load_returns_none_and_save_resurrects():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot())
store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1")
assert store.load_active_snapshot(_scope()) is None
# Re-entry revives the row.
store.save_active_snapshot(scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=2))
with session_factory.create_session() as session:
row = session.query(AgentRuntimeSession).one()
assert row.status == AgentRuntimeSessionStatus.ACTIVE
assert row.cleaned_at is None
assert row.backend_run_id == "run-2"
def test_distinct_conversations_do_not_collide():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(scope=_scope(conversation_id="conv-A"), backend_run_id="a", snapshot=_snapshot())
store.save_active_snapshot(scope=_scope(conversation_id="conv-B"), backend_run_id="b", snapshot=_snapshot())
assert store.load_active_snapshot(_scope(conversation_id="conv-A")) is not None
assert store.load_active_snapshot(_scope(conversation_id="conv-B")) is not None
with session_factory.create_session() as session:
assert session.query(AgentRuntimeSession).count() == 2

View File

@ -662,3 +662,55 @@ def test_composer_validator_rejects_stage_4_declared_output_violations():
ComposerConfigValidator.validate_node_job_dict(
{"declared_outputs": [{"name": "matrix", "type": "array", "array_item": {"type": "array"}}]}
)
class TestAgentAppBackingAgent:
"""S1: an Agent App (mode=agent) is backed 1:1 by a roster Agent linked via
``Agent.app_id``. ``AppService.create_app`` builds the backing agent inside
its own transaction, so the helper must add+flush without committing."""
def test_create_backing_agent_for_app_links_app_and_seeds_default_soul(self):
session = FakeSession()
service = AgentRosterService(session)
agent = service.create_backing_agent_for_app(
tenant_id="tenant-1",
account_id="account-1",
app_id="app-1",
name="Iris",
description="clarifier",
)
# Agent is bound to the app and is a roster/agent_app entry.
assert agent.app_id == "app-1"
assert agent.scope == AgentScope.ROSTER
assert agent.source == AgentSource.AGENT_APP
assert agent.status == AgentStatus.ACTIVE
assert agent.agent_kind == AgentKind.DIFY_AGENT
assert agent.name == "Iris"
# A v1 snapshot + revision are seeded and wired as the active version.
snapshots = [a for a in session.added if isinstance(a, AgentConfigSnapshot)]
assert len(snapshots) == 1
assert snapshots[0].version == 1
assert agent.active_config_snapshot_id == snapshots[0].id
revisions = [
a for a in session.added if getattr(a, "operation", None) == AgentConfigRevisionOperation.CREATE_VERSION
]
assert len(revisions) == 1
# Caller (AppService.create_app) owns the commit — helper must not commit.
assert session.commits == 0
def test_get_app_backing_agent_queries_active_agent_app_agent(self):
sentinel = SimpleNamespace(id="agent-1", app_id="app-1")
session = FakeSession(scalar=[sentinel])
service = AgentRosterService(session)
result = service.get_app_backing_agent(tenant_id="tenant-1", app_id="app-1")
assert result is sentinel
def test_get_app_backing_agent_returns_none_when_unbound(self):
session = FakeSession()
service = AgentRosterService(session)
assert service.get_app_backing_agent(tenant_id="tenant-1", app_id="app-x") is None

View File

@ -0,0 +1,66 @@
"""Unit tests for AgentAppFeatureConfigService.validate_features.
The validator is the security boundary of the Agent App feature endpoint: it
must (a) drop any Soul-owned keys a caller tries to smuggle in and (b) fill
sane disabled/empty defaults for the presentation features the PRD requires.
"""
import pytest
from services.agent_app_feature_service import AgentAppFeatureConfigService
TENANT_ID = "11111111-1111-1111-1111-111111111111"
class TestValidateFeatures:
def test_empty_config_fills_disabled_defaults(self):
result = AgentAppFeatureConfigService.validate_features(TENANT_ID, {})
assert result["opening_statement"] == ""
assert result["suggested_questions"] == []
assert result["suggested_questions_after_answer"] == {"enabled": False}
assert result["retriever_resource"] == {"enabled": False}
assert result["speech_to_text"] == {"enabled": False}
assert result["text_to_speech"]["enabled"] is False
def test_opener_and_follow_up_round_trip(self):
result = AgentAppFeatureConfigService.validate_features(
TENANT_ID,
{
"opening_statement": "Hi, I'm Iris.",
"suggested_questions": ["What can you do?"],
"suggested_questions_after_answer": {"enabled": True},
"retriever_resource": {"enabled": True},
},
)
assert result["opening_statement"] == "Hi, I'm Iris."
assert result["suggested_questions"] == ["What can you do?"]
assert result["suggested_questions_after_answer"]["enabled"] is True
assert result["retriever_resource"]["enabled"] is True
def test_soul_owned_keys_are_dropped(self):
# model / pre_prompt / agent_mode / tools / user_input_form belong to the
# Agent Soul and must never be settable through the feature endpoint.
result = AgentAppFeatureConfigService.validate_features(
TENANT_ID,
{
"opening_statement": "hello",
"model": {"provider": "x", "name": "y"},
"pre_prompt": "system override",
"agent_mode": {"enabled": True, "strategy": "react"},
"tools": [{"a": 1}],
"user_input_form": [{"text-input": {}}],
},
)
for forbidden in ("model", "pre_prompt", "agent_mode", "tools", "user_input_form"):
assert forbidden not in result
def test_invalid_opening_statement_type_raises(self):
with pytest.raises(ValueError, match="opening_statement must be of string type"):
AgentAppFeatureConfigService.validate_features(TENANT_ID, {"opening_statement": 123})
def test_invalid_suggested_questions_type_raises(self):
with pytest.raises(ValueError, match="suggested_questions must be of list type"):
AgentAppFeatureConfigService.validate_features(TENANT_ID, {"suggested_questions": "nope"})

View File

@ -127,3 +127,31 @@ class TestOpenapiVisibilityHelpers:
assert out == rows
gate.assert_called_once()
mock_session.execute.assert_called_once()
class TestAgentAppType:
"""S1: new ``AppMode.AGENT`` app type wiring."""
def test_agent_mode_enum_and_template_exist(self):
from constants.model_template import default_app_templates
from models.model import AppMode
assert AppMode.AGENT.value == "agent"
assert AppMode.AGENT in default_app_templates
# Runtime config comes from the Agent Soul, so no model_config is seeded.
assert "model_config" not in default_app_templates[AppMode.AGENT]
assert default_app_templates[AppMode.AGENT]["app"]["mode"] == AppMode.AGENT
def test_create_app_params_accepts_agent_mode(self):
from services.app_service import CreateAppParams
params = CreateAppParams(name="Iris", mode="agent")
assert params.mode == "agent"
def test_bound_agent_id_is_none_for_non_agent_app(self):
"""Non-agent apps short-circuit without touching the DB."""
from models.model import App, AppMode
app = App()
app.mode = AppMode.CHAT
assert app.bound_agent_id is None

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 { CACHE_APP_INFO, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../store/manager.js'
import { FieldInfo, FieldParameters } from '../types/app-meta.js'
import { AppMetaClient } from './app-meta.js'
import { AppsClient } from './apps.js'
@ -15,17 +15,24 @@ 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: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 })
@ -40,7 +47,7 @@ describe('AppMetaClient', () => {
})
it('slim hit + full request triggers fresh fetch + merges', async () => {
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 })
@ -54,7 +61,7 @@ describe('AppMetaClient', () => {
})
it('expired cache entry refetches', async () => {
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') })
const cache = await loadAppInfoCache({ store: getCache(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') })
@ -68,7 +75,7 @@ describe('AppMetaClient', () => {
})
it('invalidate forces next get to fetch', async () => {
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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

@ -1,101 +0,0 @@
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

@ -1,99 +0,0 @@
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, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { mkdtemp, rm } 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 { HOSTS_FILE_NAME, HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
import { ENV_CONFIG_DIR } from '../store/dir.js'
import { 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 file is missing', async () => {
expect(await loadHosts(dir)).toBeUndefined()
it('returns undefined when nothing was saved', () => {
expect(loadHosts()).toBeUndefined()
})
it('round-trips bundle through YAML', async () => {
await saveHosts(dir, {
it('round-trips a fully-populated bundle', () => {
saveHosts({
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 = await loadHosts(dir)
const loaded = loadHosts()
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('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, {
it('rejects invalid input at save time', () => {
expect(() => saveHosts({
current_host: 'cloud.dify.ai',
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')
token_storage: 'cloud',
} as never)).toThrow()
})
})

View File

@ -1,10 +1,6 @@
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import yaml from 'js-yaml'
import type { Store } from '../store/store.js'
import { z } from 'zod'
import { DIR_PERM, FILE_PERM } from '../store/dir.js'
export const HOSTS_FILE_NAME = 'hosts.yml'
import { getHostStore, tokenKey } from '../store/manager.js'
const StorageModeSchema = z.enum(['keychain', 'file'])
export type StorageMode = z.infer<typeof StorageModeSchema>
@ -48,53 +44,23 @@ export const HostsBundleSchema = z.object({
})
export type HostsBundle = z.infer<typeof HostsBundleSchema>
export async function loadHosts(dir: string): Promise<HostsBundle | undefined> {
const path = join(dir, HOSTS_FILE_NAME)
let raw: string
try {
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 function loadHosts(): HostsBundle | undefined {
const raw = getHostStore().getTyped<Record<string, unknown>>()
if (raw === null)
return undefined
return HostsBundleSchema.parse(raw)
}
export async function saveHosts(dir: string, bundle: HostsBundle): Promise<void> {
await mkdir(dir, { recursive: true, mode: DIR_PERM })
export function saveHosts(bundle: HostsBundle): void {
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()}`
getHostStore().setTyped(validated)
}
export function clearLocal(bundle: HostsBundle, store: Store): void {
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
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)
store.unset(tokenKey(bundle.current_host, accountId))
}
catch { /* best-effort */ }
}
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
getHostStore().rm()
}

View File

@ -1,111 +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 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

@ -1,35 +0,0 @@
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

@ -1,75 +0,0 @@
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()
})
})

View File

@ -1,40 +0,0 @@
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 { CACHE_APP_INFO, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_APP_INFO, cachePath, getCache } from '../store/manager.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,18 +35,25 @@ 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: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const c1 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await c1.set('http://localhost:9999', 'app-1', metaInfoOnly())
const c2 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const got = c2.get('http://localhost:9999', 'app-1')
expect(got).toBeDefined()
expect(got?.meta.info?.id).toBe('app-1')
@ -55,7 +62,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: new YamlStore(cachePath(dir, CACHE_APP_INFO)), now: () => now })
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), now: () => now })
await c.set('h', 'app-1', metaInfoOnly())
const r = c.get('h', 'app-1')
expect(r).toBeDefined()
@ -66,23 +73,23 @@ describe('app-info disk cache', () => {
})
it('keys by (host, app_id) — different hosts isolate', async () => {
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await c.set('h1', 'app-1', metaInfoOnly())
expect(c.get('h2', 'app-1')).toBeUndefined()
expect(c.get('h1', 'app-1')).toBeDefined()
})
it('delete removes entry from disk', async () => {
const c1 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const c1 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await c1.set('h', 'app-1', metaInfoOnly())
await c1.delete('h', 'app-1')
const c2 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
expect(c2.get('h', 'app-1')).toBeUndefined()
})
it('writes file with 0600 permission', async () => {
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await c.set('h', 'app-1', metaInfoOnly())
const { stat } = await import('node:fs/promises')
const s = await stat(appInfoPath(dir))
@ -91,19 +98,19 @@ describe('app-info disk cache', () => {
})
it('missing cache file is not an error', async () => {
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
expect(c.get('h', 'app-1')).toBeUndefined()
})
it('corrupt cache file is treated as empty', async () => {
const { writeFile } = await import('node:fs/promises')
await writeFile(appInfoPath(dir), ': : not valid yaml', 'utf8')
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
expect(c.get('h', 'app-1')).toBeUndefined()
})
it('updates same key in place (no growth)', async () => {
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const c = await loadAppInfoCache({ store: getCache(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 { CACHE_NUDGE, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_NUDGE, cachePath, getCache } from '../store/manager.js'
import { loadNudgeStore, WARN_INTERVAL_MS } from './nudge-store.js'
function nudgeStorePath(dir: string): string {
@ -15,21 +15,28 @@ 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: new YamlStore(cachePath(dir, CACHE_NUDGE)) })
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE) })
expect(store.canWarn(HOST)).toBe(true)
})
it('canWarn=false within the silence window, true past it', async () => {
const t0 = new Date('2026-05-19T12:00:00.000Z')
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
await store.markWarned(HOST)
expect(store.canWarn(HOST, new Date('2026-05-19T18:00:00.000Z'))).toBe(false)
expect(store.canWarn(HOST, new Date('2026-05-20T12:00:00.000Z'))).toBe(true)
@ -37,7 +44,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: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
await store.markWarned(HOST)
const pastClock = new Date('2026-05-19T11:00:00.000Z') // clock moved backwards 1h
expect(store.canWarn(HOST, pastClock)).toBe(false)
@ -45,22 +52,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: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
const s1 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
await s1.markWarned(HOST)
const s2 = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
const s2 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
expect(s2.canWarn(HOST)).toBe(false)
})
it('treats a corrupt cache file as empty', async () => {
const path = nudgeStorePath(dir)
await writeCacheFile(path, '{ not valid json')
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)) })
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE) })
expect(store.canWarn(HOST)).toBe(true)
})
it('writes ISO timestamps under warned/<host> on disk', async () => {
const t = new Date('2026-05-19T12:00:00.000Z')
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
await store.markWarned(HOST)
const raw = await readFile(nudgeStorePath(dir), 'utf8')
const parsed = yaml.load(raw) as Record<string, unknown>
@ -72,11 +79,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: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
const b = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
const a = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
const b = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
await a.markWarned('https://a.example')
await b.markWarned('https://b.example')
const reread = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
const reread = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
expect(reread.canWarn('https://a.example')).toBe(false)
expect(reread.canWarn('https://b.example')).toBe(false)
})

View File

@ -12,7 +12,6 @@ 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'
@ -24,7 +23,6 @@ export type AuthedContext = {
readonly http: KyInstance
readonly host: string
readonly io: IOStreams
readonly configDir: string
readonly cache?: AppInfoCache
}
@ -38,9 +36,8 @@ export async function buildAuthedContext(
cmd: Pick<Command, 'error'>,
opts: AuthedContextOptions,
): Promise<AuthedContext> {
const configDir = resolveConfigDir()
const io = realStreams(opts.format ?? '')
const bundle = await loadHosts(configDir)
const bundle = loadHosts()
if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
const err = new BaseError({
code: ErrorCode.NotLoggedIn,
@ -61,7 +58,7 @@ export async function buildAuthedContext(
await runCompatNudge({ host, io })
return { bundle, http, host, io, configDir, cache }
return { bundle, http, host, io, 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 { TokenStore } from '../../../../auth/store.js'
import type { Key, Store } from '../../../../store/store.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -10,26 +10,23 @@ 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 TokenStore {
readonly entries = new Map<string, string>()
async put(host: string, accountId: string, token: string): Promise<void> {
this.entries.set(`${host}::${accountId}`, token)
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
}
async get(host: string, accountId: string): Promise<string | undefined> {
return this.entries.get(`${host}::${accountId}`)
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
}
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))
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
}
}
@ -93,11 +90,18 @@ 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 })
})
@ -106,11 +110,11 @@ describe('runDevicesRevoke', () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
await store.put(b.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, b)
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
saveHosts(b)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl on desktop', all: false })
await runDevicesRevoke({ 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)
})
@ -121,7 +125,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-2', all: false })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-2', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
})
@ -131,7 +135,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'web', all: false })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'web', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
})
@ -141,7 +145,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl', all: false }))
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl', all: false }))
.rejects
.toThrow(/matches multiple/)
})
@ -152,7 +156,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'nonexistent', all: false }))
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'nonexistent', all: false }))
.rejects
.toThrow(/no session matches/)
})
@ -163,7 +167,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, all: true })
await runDevicesRevoke({ io, bundle: b, http, store, all: true })
expect(io.outBuf()).toContain('Revoked 2 session(s)')
})
@ -171,20 +175,20 @@ describe('runDevicesRevoke', () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
await store.put(b.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, b)
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
saveHosts(b)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-1', all: false })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-1', all: false })
expect(store.entries.size).toBe(0)
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
await expect(readFile(join(resolveConfigDir(), '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({ configDir, io, bundle: bundleFor(mock.url), http, store, all: false }))
await expect(runDevicesRevoke({ io, bundle: bundleFor(mock.url), http, store, all: false }))
.rejects
.toThrow(/specify a device label/)
})

View File

@ -1,15 +1,14 @@
import type { SessionRow } from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../../auth/hosts.js'
import type { TokenStore } from '../../../../auth/store.js'
import type { Store } from '../../../../store/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 { HOSTS_FILE_NAME } from '../../../../auth/hosts.js'
import { clearLocal } 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'
@ -72,11 +71,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
readonly store: TokenStore
/** Optional override for tests; production code resolves via `getTokenStore`. */
readonly store?: Store
readonly target?: string
readonly all: boolean
readonly yes?: boolean
@ -104,8 +103,10 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
for (const id of ids)
await sessions.revoke(id)
if (selfHit)
await clearLocal(opts.configDir, b, opts.store)
if (selfHit) {
const tokens = opts.store ?? getTokenStore().store
clearLocal(b, tokens)
}
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
}
@ -178,18 +179,3 @@ 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,4 +1,3 @@
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'
@ -25,13 +24,10 @@ 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,5 +1,4 @@
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'
@ -31,7 +30,6 @@ 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 { TokenStore } from '../../../auth/store.js'
import type { Key, Store } from '../../../store/store.js'
import type { Clock } from './device-flow.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@ -8,6 +8,8 @@ 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'
@ -18,38 +20,38 @@ const noopClock: Clock = {
const noopBrowser = async (): Promise<void> => { /* skip OS open */ }
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)
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
}
async get(host: string, accountId: string): Promise<string | undefined> {
return this.entries.get(`${host}::${accountId}`)
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
}
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))
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
}
}
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 })
})
@ -58,7 +60,6 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -73,7 +74,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 = await store.get(bundle.current_host, 'acct-1')
const stored = store.get(tokenKey(bundle.current_host, 'acct-1'))
expect(stored).toBe('dfoa_test')
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
@ -91,7 +92,6 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -115,7 +115,6 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -135,7 +134,6 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -152,7 +150,6 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -169,7 +166,6 @@ 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, StorageMode, Workspace } from '../../../auth/hosts.js'
import type { TokenStore } from '../../../auth/store.js'
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
import type { StorageMode, Store } from '../../../store/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,21 +8,20 @@ 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: TokenStore, readonly mode: StorageMode }
readonly store?: { readonly store: Store, readonly mode: StorageMode }
readonly api?: DeviceFlowApi
readonly browserEnv?: BrowserEnv
readonly browserOpener?: BrowserOpener
@ -59,11 +58,11 @@ export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
const storeBundle = opts.store ?? await selectStore({ configDir: opts.configDir })
const storeBundle = opts.store ?? getTokenStore()
const bundle = bundleFromSuccess(host, success, storeBundle.mode)
await storeBundle.store.put(bundle.current_host, accountKey(bundle), success.token)
await saveHosts(opts.configDir, bundle)
storeBundle.store.set(tokenKey(bundle.current_host, accountKey(bundle)), success.token)
saveHosts(bundle)
renderLoggedIn(opts.io.out, cs, host, success)
return bundle

View File

@ -1,8 +1,6 @@
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'
@ -18,9 +16,7 @@ export default class Logout extends DifyCommand {
async run(argv: string[]): Promise<void> {
this.parse(Logout, argv)
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
const { store } = await selectStore({ configDir })
const bundle = loadHosts()
let http: KyInstance | undefined
if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') {
@ -34,7 +30,7 @@ export default class Logout extends DifyCommand {
const io = realStreams()
await runWithSpinner(
{ io, label: 'Signing out', enabled: true, style: 'dify-dim' },
() => runLogout({ configDir, io, bundle, http, store }),
() => runLogout({ io, bundle, http }),
)
}
}

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 { TokenStore } from '../../../auth/store.js'
import type { Key, Store } from '../../../store/store.js'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -8,28 +8,23 @@ 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 TokenStore {
readonly entries = new Map<string, string>()
async put(host: string, accountId: string, token: string): Promise<void> {
this.entries.set(`${host}::${accountId}`, token)
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
}
async get(host: string, accountId: string): Promise<string | undefined> {
return this.entries.get(`${host}::${accountId}`)
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
}
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))
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
}
}
@ -52,13 +47,20 @@ 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 })
})
@ -67,11 +69,11 @@ describe('runLogout', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
await store.put(bundle.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, bundle)
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
saveHosts(bundle)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ configDir, io, bundle, http, store })
await runLogout({ io, bundle, http, store })
expect(store.entries.size).toBe(0)
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
@ -82,7 +84,7 @@ describe('runLogout', () => {
it('not-logged-in: throws BaseError', async () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogout({ configDir, io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
await expect(runLogout({ io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
})
it('hosts.yml absent: still completes locally + emits success', async () => {
@ -91,7 +93,7 @@ describe('runLogout', () => {
const bundle = fixtureBundle(mock.url)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ configDir, io, bundle, http, store })
await runLogout({ io, bundle, http, store })
expect(io.outBuf()).toContain('Logged out of')
})
@ -100,12 +102,12 @@ describe('runLogout', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
await store.put(bundle.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, bundle)
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
saveHosts(bundle)
mock.setScenario('server-5xx')
const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })
await runLogout({ configDir, io, bundle, http, store })
await runLogout({ io, bundle, http, store })
expect(store.entries.size).toBe(0)
expect(io.errBuf()).toContain('server revoke failed')
@ -117,11 +119,11 @@ describe('runLogout', () => {
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
bundle.tokens = { bearer: 'dfp_personal_token' }
await store.put(bundle.current_host, 'acct-1', 'dfp_personal_token')
await saveHosts(configDir, bundle)
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfp_personal_token')
saveHosts(bundle)
const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' })
await runLogout({ configDir, io, bundle, http, store })
await runLogout({ io, bundle, http, store })
expect(io.errBuf()).toBe('')
expect(store.entries.size).toBe(0)
@ -131,11 +133,11 @@ describe('runLogout', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
await saveHosts(configDir, bundle)
saveHosts(bundle)
await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ configDir, io, bundle, http, store })
await runLogout({ io, bundle, http, store })
const cfg = await readFile(join(configDir, 'config.yml'), 'utf8')
expect(cfg).toContain('foo: bar')

View File

@ -1,21 +1,20 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { TokenStore } from '../../../auth/store.js'
import type { Store } from '../../../store/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 { HOSTS_FILE_NAME } from '../../../auth/hosts.js'
import { clearLocal } 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
readonly store: TokenStore
/** Optional override for tests; production code resolves via `getTokenStore`. */
readonly store?: Store
}
export async function runLogout(opts: LogoutOptions): Promise<void> {
@ -40,7 +39,8 @@ export async function runLogout(opts: LogoutOptions): Promise<void> {
}
}
await clearLocal(opts.configDir, bundle, opts.store)
const tokens = opts.store ?? getTokenStore().store
clearLocal(bundle, tokens)
if (revokeWarning !== '')
opts.io.err.write(revokeWarning)
@ -52,19 +52,3 @@ 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,6 +1,5 @@
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'
@ -21,8 +20,7 @@ export default class Status extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(Status, argv)
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
const bundle = loadHosts()
await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json })
}
}

View File

@ -1,6 +1,5 @@
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'
@ -19,8 +18,7 @@ export default class Whoami extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(Whoami, argv)
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
const bundle = loadHosts()
await runWhoami({ io: realStreams(), bundle, json: flags.json })
}
}

View File

@ -1,43 +1,49 @@
import { mkdtemp, writeFile } from 'node:fs/promises'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { isBaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { YamlStore } from '../../../store/store.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.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
})
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' })
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' })
expect(out).toBe('yaml\n')
})
it('returns empty line when key is unset (matches Go fmt.Fprintln)', () => {
const out = runConfigGet({ store: makeStore(dir), key: 'defaults.format' })
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' })
expect(out).toBe('\n')
})
it('throws BaseError(config_invalid_key) on unknown key', () => {
let caught: unknown
try {
runConfigGet({ store: makeStore(dir), key: 'bogus.key' })
runConfigGet({ store: getConfigurationStore(), key: 'bogus.key' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -45,13 +51,12 @@ describe('runConfigGet', () => {
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
})
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' })
it('returns numeric limit as string', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { limit: 75 },
})
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.limit' })
expect(out).toBe('75\n')
})
})

View File

@ -1,7 +1,8 @@
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'
@ -12,6 +13,8 @@ export default class ConfigPath extends DifyCommand {
async run(argv: string[]) {
this.parse(ConfigPath, argv)
return raw(runConfigPath({ dir: resolveConfigDir() }))
return raw(
join(resolveConfigDir(), CONFIG_FILE_NAME),
)
}
}

View File

@ -1,14 +0,0 @@
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

@ -1,10 +0,0 @@
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,35 +1,46 @@
import { mkdtemp, readFile } from 'node:fs/promises'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../../../config/config-loader.js'
import { isBaseError } from '../../../errors/base.js'
import { ErrorCode, ExitCode } from '../../../errors/codes.js'
import { YamlStore } from '../../../store/store.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.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
})
it('writes config.yml and returns "set k = v\\n"', async () => {
const out = runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'json' })
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' })
expect(out).toBe('set defaults.format = json\n')
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toContain('format: json')
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.defaults.format).toBe('json')
})
it('rejects invalid format value with config_invalid_value', async () => {
it('rejects invalid format value with config_invalid_value', () => {
let caught: unknown
try {
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' })
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -40,7 +51,7 @@ describe('runConfigSet', () => {
it('rejects unknown key with config_invalid_key', () => {
let caught: unknown
try {
runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' })
runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -48,18 +59,22 @@ describe('runConfigSet', () => {
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
})
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('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('exit code for invalid value is Usage (2)', () => {
let caught: unknown
try {
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' })
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -70,7 +85,7 @@ describe('runConfigSet', () => {
it('exit code for unknown key is Usage (2)', () => {
let caught: unknown
try {
runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' })
runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -81,7 +96,7 @@ describe('runConfigSet', () => {
it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', () => {
let caught: unknown
try {
runConfigSet({ store: makeStore(dir), key: 'defaults.limit', value: 'abc' })
runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: 'abc' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -1,48 +1,61 @@
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../../../config/config-loader.js'
import { isBaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { YamlStore } from '../../../store/store.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.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
})
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 raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).not.toContain('format:')
expect(raw).toContain('limit: 25')
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('is a no-op (writes empty config) when key was already unset', async () => {
const out = runConfigUnset({ store: makeStore(dir), key: 'defaults.format' })
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' })
expect(out).toBe('unset defaults.format\n')
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toContain('schema_version: 1')
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)
}
})
it('is a no-op (writes empty config) when key was already unset', () => {
const out = runConfigUnset({ store: getConfigurationStore(), 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)
})
it('rejects unknown key', () => {
let caught: unknown
try {
runConfigUnset({ store: makeStore(dir), key: 'bogus' })
runConfigUnset({ store: getConfigurationStore(), key: 'bogus' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -1,67 +1,69 @@
import { mkdtemp, writeFile } from 'node:fs/promises'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { YamlStore } from '../../../store/store.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.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 () => {
// tmpdir cleanup is best-effort
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
})
it('text format: empty config returns empty string', () => {
const out = runConfigView({ store: makeStore(dir) })
const out = runConfigView({ store: getConfigurationStore() })
expect(out).toBe('')
})
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) })
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() })
expect(out).toBe(
'defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n',
)
})
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) })
it('text format: skips unset keys', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'yaml' },
})
const out = runConfigView({ store: getConfigurationStore() })
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: makeStore(dir), json: true })
const out = runConfigView({ store: getConfigurationStore(), json: true })
expect(out).toBe('{}\n')
})
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 })
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 })
const parsed = JSON.parse(out) as Record<string, unknown>
expect(parsed['defaults.format']).toBe('table')
expect(parsed['defaults.limit']).toBe(100)
@ -69,7 +71,7 @@ describe('runConfigView', () => {
})
it('json format: trailing newline matches Go encoder.Encode', () => {
const out = runConfigView({ store: makeStore(dir), json: true })
const out = runConfigView({ store: getConfigurationStore(), 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 { CACHE_APP_INFO, cachePath } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { ENV_CACHE_DIR } from '../../../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
import { runDescribeApp } from './run.js'
function bundle(): HostsBundle {
@ -29,17 +29,24 @@ 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: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const data = await runDescribeApp(
opts,
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
@ -82,7 +89,7 @@ describe('runDescribeApp', () => {
})
it('refresh: bypasses cache', async () => {
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 { CACHE_APP_INFO, cachePath } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { ENV_CACHE_DIR } from '../../../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
import { bufferStreams } from '../../../sys/io/streams'
import { resumeApp } from '../../resume/app/run.js'
import { runApp } from './run.js'
@ -30,18 +30,25 @@ 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: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -52,7 +59,7 @@ describe('runApp', () => {
it('workflow: rejects positional message with usage error', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -61,7 +68,7 @@ describe('runApp', () => {
it('workflow: prints single-string output as plain text', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -71,7 +78,7 @@ describe('runApp', () => {
it('json: passes through full envelope', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -104,7 +111,7 @@ describe('runApp', () => {
it('--stream chat: streams answer to stdout and hint to stderr', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -116,7 +123,7 @@ describe('runApp', () => {
it('--stream -o json chat: aggregates into blocking-shape envelope', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -129,7 +136,7 @@ describe('runApp', () => {
it('agent-chat without --stream: collects and prints answer', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -140,7 +147,7 @@ describe('runApp', () => {
it('agent-chat with --stream: live-prints answer and thoughts to stderr', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -151,7 +158,7 @@ describe('runApp', () => {
it('--stream workflow -o json: aggregates from workflow_finished', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -164,7 +171,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: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -173,7 +180,7 @@ describe('runApp', () => {
it('--inputs-file: reads inputs from file', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const inputsFile = join(dir, 'inputs.json')
const { writeFile } = await import('node:fs/promises')
await writeFile(inputsFile, JSON.stringify({ x: 'from-file' }))
@ -197,7 +204,7 @@ describe('runApp', () => {
it('--inputs: accepts JSON object string', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -219,7 +226,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: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
let exitCode = -1
await expect(runApp(
{ appId: 'app-2', inputs: {} },
@ -248,7 +255,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: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
let exitCode = -1
await expect(runApp(
{ appId: 'app-2', inputs: {}, format: 'json' },
@ -274,7 +281,7 @@ describe('runApp', () => {
it('resume: withHistory: false completes successfully', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -285,7 +292,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: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -296,7 +303,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: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -307,7 +314,7 @@ describe('runApp', () => {
it('workflow: --file remote URL is passed as remote_url input variable', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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 },
@ -326,7 +333,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: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const filePath = join(dir, 'test.pdf')
await writeFile(filePath, 'fake pdf content')
await runApp(
@ -345,7 +352,7 @@ describe('runApp', () => {
it('workflow: --file overrides same-named key from --inputs (file wins)', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const cache = await loadAppInfoCache({ store: getCache(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,7 +22,6 @@ 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,6 +9,7 @@ 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'
@ -51,23 +52,29 @@ 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()
await saveHosts(configDir, b)
saveHosts(b)
const client = fakeClient({})
const next = await runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
@ -82,7 +89,7 @@ describe('runUseWorkspace', () => {
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Switched', role: 'normal' },
])
const reloaded = await loadHosts(configDir)
const reloaded = loadHosts()
expect(reloaded?.workspace?.id).toBe('ws-2')
expect(reloaded?.workspace?.name).toBe('Switched')
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
@ -93,15 +100,15 @@ describe('runUseWorkspace', () => {
// We expect saveHosts to record the fresh name from the server.
const io = bufferStreams()
const b = bundle()
await saveHosts(configDir, b)
saveHosts(b)
const client = fakeClient({})
await runUseWorkspace(
{ workspaceId: 'ws-2' },
{ configDir, bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
{ bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
)
const reloaded = await loadHosts(configDir)
const reloaded = loadHosts()
expect(reloaded?.workspace?.name).toBe('Switched')
expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
})
@ -109,8 +116,8 @@ describe('runUseWorkspace', () => {
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
const io = bufferStreams()
const b = bundle()
await saveHosts(configDir, b)
const before = await loadHosts(configDir)
saveHosts(b)
const before = loadHosts()
const client = fakeClient({
switch: () => Promise.reject(new Error('forbidden')),
@ -120,7 +127,6 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
@ -130,7 +136,7 @@ describe('runUseWorkspace', () => {
).rejects.toThrow(/forbidden/)
expect(client.list).not.toHaveBeenCalled()
const after = await loadHosts(configDir)
const after = loadHosts()
expect(after).toEqual(before)
expect(after?.workspace?.id).toBe('ws-1')
})
@ -138,8 +144,8 @@ describe('runUseWorkspace', () => {
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
const io = bufferStreams()
const b = bundle()
await saveHosts(configDir, b)
const before = await loadHosts(configDir)
saveHosts(b)
const before = loadHosts()
const client = fakeClient({
list: () => Promise.reject(new Error('transient list failure')),
@ -149,7 +155,6 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
@ -158,14 +163,14 @@ describe('runUseWorkspace', () => {
),
).rejects.toThrow(/transient list failure/)
const after = await loadHosts(configDir)
const after = loadHosts()
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()
await saveHosts(configDir, b)
saveHosts(b)
const client = fakeClient({
switch: () => Promise.resolve({
@ -187,7 +192,6 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-7' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,

View File

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

View File

@ -1,48 +1,52 @@
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import type { YamlStore } from '../store/store'
import { mkdtemp, rm } 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 { YamlStore } from '../store/store'
import { ENV_CONFIG_DIR } from '../store/dir'
import { getConfigurationStore } from '../store/manager'
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 () => {
await mkdir(dir, { recursive: true }).catch(() => {})
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 found:false when config.yml is missing', () => {
const r = loadConfig(makeStore(dir))
it('returns found:false when config is missing', () => {
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(false)
})
it('parses a minimal valid config.yml', async () => {
await writeFile(join(dir, FILE_NAME), 'schema_version: 1\n', 'utf8')
const r = loadConfig(makeStore(dir))
it('parses a minimal valid config', () => {
getConfigurationStore().setTyped({ schema_version: 1 })
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
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))
it('parses defaults + state', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'json', limit: 100 },
state: { current_app: 'app-1' },
})
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('json')
@ -51,11 +55,29 @@ describe('loadConfig', () => {
}
})
it('throws BaseError(config_schema_unsupported) when YAML is malformed', async () => {
await writeFile(join(dir, FILE_NAME), '::not yaml::: {{[', 'utf8')
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
let caught: unknown
try {
loadConfig(makeStore(dir))
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())
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -63,23 +85,11 @@ describe('loadConfig', () => {
expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported)
})
it('throws BaseError(config_schema_unsupported) when zod validation fails', async () => {
await writeFile(join(dir, FILE_NAME), 'defaults:\n limit: 9999\n', 'utf8')
it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', () => {
getConfigurationStore().setTyped({ schema_version: 2 })
let caught: unknown
try {
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))
loadConfig(getConfigurationStore())
}
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('FILE_NAME is config.yml', () => {
expect(FILE_NAME).toBe('config.yml')
it('CONFIG_FILE_NAME is config.yml', () => {
expect(CONFIG_FILE_NAME).toBe('config.yml')
})
it('ALLOWED_FORMATS matches Go set (json/yaml/table/wide/name/text)', () => {

View File

@ -1,7 +1,6 @@
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 17 codes (parity with internal/api/errors)', () => {
expect(ALL_ERROR_CODES).toHaveLength(17)
it('has 18 codes (parity with internal/api/errors)', () => {
expect(ALL_ERROR_CODES).toHaveLength(18)
})
it('has the expected ExitCode buckets', () => {
@ -46,6 +46,7 @@ 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,6 +15,7 @@ export const ErrorCode = {
NetworkDns: 'network_dns',
Server5xx: 'server_5xx',
Server4xxOther: 'server_4xx_other',
ClientError: 'client_error',
Unknown: 'unknown',
} as const
@ -47,6 +48,7 @@ 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,45 +1,57 @@
import { mkdtemp, readdir, readFile, stat } from 'node:fs/promises'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../config/config-loader'
import { emptyConfig, FILE_NAME } from '../config/schema'
import { platform } from '../sys'
import { emptyConfig } from '../config/schema'
import { saveConfig } from './config-writer'
import { YamlStore } from './store'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
import { ENV_CONFIG_DIR } from './dir'
import { getConfigurationStore } from './manager'
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
})
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)
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('stamps schema_version=1 even if caller passed 0', () => {
saveConfig(makeStore(dir), { ...emptyConfig() })
const r = loadConfig(makeStore(dir))
saveConfig(getConfigurationStore(), { ...emptyConfig() })
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
it('round-trips defaults + state through YAML', () => {
saveConfig(makeStore(dir), {
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(), {
schema_version: 1,
defaults: { format: 'wide', limit: 75 },
state: { current_app: 'app-xyz' },
})
const r = loadConfig(makeStore(dir))
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('wide')
@ -48,39 +60,22 @@ describe('saveConfig', () => {
}
})
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), {
it('overwrites the previous config on resave', () => {
saveConfig(getConfigurationStore(), {
schema_version: 1,
defaults: { format: 'json' },
state: {},
})
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toMatch(/^schema_version:/m)
expect(raw).toMatch(/format: json/)
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')
}
})
})

64
cli/src/store/errors.ts Normal file
View File

@ -0,0 +1,64 @@
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

@ -0,0 +1,109 @@
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

@ -0,0 +1,78 @@
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,28 +1,77 @@
import type { Store } from './store'
import type { Key, StorageMode, Store } from './store'
import { join } from 'node:path'
import { FILE_NAME } from '../config/schema'
import { resolveCacheDir, resolveConfigDir } from './dir'
import { YamlStore } from './store'
import { KeyringBasedStore, 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(resolveConfigurationPath())
return getStore(join(resolveConfigDir(), CONFIG_FILE_NAME))
}
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,9 +1,10 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync, statSync, 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 { ConcurrentAccessError, YamlStore } from './store'
import { BadYamlFormatError, ConcurrentAccessError } from './errors'
import { YamlStore } from './store'
describe('YamlStore.doGet', () => {
it('returns default when content is undefined', () => {
@ -13,33 +14,51 @@ describe('YamlStore.doGet', () => {
it('reads a flat key', () => {
const store = new YamlStore('/irrelevant')
store.raw_content = 'name: alice\n'
store.setRawContent('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.raw_content = 'user:\n id: 42\n'
store.setRawContent('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.raw_content = 'name: alice\n'
store.setRawContent('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.raw_content = 'user:\n name: bob\n'
store.setRawContent('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.raw_content = 'user: scalar\n'
store.setRawContent('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', () => {
@ -57,7 +76,7 @@ describe('YamlStore.doSet', () => {
it('overwrites an existing key without disturbing siblings', () => {
const store = new YamlStore('/irrelevant')
store.raw_content = 'name: alice\nage: 30\n'
store.setRawContent('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)
@ -65,7 +84,7 @@ describe('YamlStore.doSet', () => {
it('replaces a scalar intermediate with an object when path deepens', () => {
const store = new YamlStore('/irrelevant')
store.raw_content = 'user: scalar\n'
store.setRawContent('user: scalar\n')
store.doSet({ key: 'user.id', default: 0 }, 99)
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(99)
})
@ -132,12 +151,12 @@ describe('YamlStore persistence', () => {
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.raw_content = ''
s1.setRawContent('')
s1.doSet({ key: 'workspace', default: '' }, 'ws-123')
writeFileSync(path, s1.raw_content ?? '')
writeFileSync(path, s1.getRawContent() ?? '')
const s2 = new YamlStore(path)
s2.raw_content = readFileSync(path, 'utf8')
s2.setRawContent(readFileSync(path, 'utf8'))
expect(s2.doGet({ key: 'workspace', default: '' })).toBe('ws-123')
})
@ -146,12 +165,12 @@ describe('YamlStore persistence', () => {
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.raw_content = ''
s1.setRawContent('')
s1.doSet({ key: 'a.b.c', default: '' }, 'deep')
writeFileSync(path, s1.raw_content ?? '')
writeFileSync(path, s1.getRawContent() ?? '')
const s2 = new YamlStore(path)
s2.raw_content = readFileSync(path, 'utf8')
s2.setRawContent(readFileSync(path, 'utf8'))
expect(s2.doGet({ key: 'a.b.c', default: '' })).toBe('deep')
})
@ -160,17 +179,17 @@ describe('YamlStore persistence', () => {
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.raw_content = ''
s1.setRawContent('')
s1.doSet({ key: 'x', default: '' }, 'first')
writeFileSync(path, s1.raw_content ?? '')
writeFileSync(path, s1.getRawContent() ?? '')
const s2 = new YamlStore(path)
s2.raw_content = readFileSync(path, 'utf8')
s2.setRawContent(readFileSync(path, 'utf8'))
s2.doSet({ key: 'y', default: '' }, 'second')
writeFileSync(path, s2.raw_content ?? '')
writeFileSync(path, s2.getRawContent() ?? '')
const s3 = new YamlStore(path)
s3.raw_content = readFileSync(path, 'utf8')
s3.setRawContent(readFileSync(path, 'utf8'))
expect(s3.doGet({ key: 'x', default: '' })).toBe('first')
expect(s3.doGet({ key: 'y', default: '' })).toBe('second')
})
@ -186,8 +205,28 @@ describe('YamlStore persistence', () => {
const raw = readFileSync(path, 'utf8')
const store2 = new YamlStore(path)
store2.raw_content = raw
store2.setRawContent(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,14 +1,16 @@
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
type Key<T> = {
export type Key<T> = {
default: T
key: string
}
@ -16,38 +18,43 @@ 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 class ConcurrentAccessError extends Error {
constructor(filePath: string) {
super(`Another process is modifying the file ${filePath}. remove ${filePath}.lock to reset lock.`)
}
}
export type StorageMode = 'keychain' | 'file'
abstract class FileBasedStore implements Store {
file_path: string
raw_content: string | undefined
filePath: string
private rawContent: string | undefined
private readonly platform: Platform
private dirty: boolean = false
constructor(file_path: string) {
this.file_path = file_path
constructor(filePath: string) {
this.filePath = filePath
this.platform = resolvePlatform()
fs.mkdirSync(dirname(this.file_path), { recursive: true, mode: DIR_PERM })
}
unlock(): void {
lockfile.unlockSync(`${this.file_path}.lock`)
lockfile.unlockSync(`${this.filePath}.lock`)
}
/**
* atomically write raw_content (if any)
*/
flush(): void {
if (this.raw_content !== undefined) {
const tmp = `${this.file_path}.tmp.${pid()}.${Date.now()}`
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()}`
try {
fs.writeFileSync(tmp, this.raw_content, { mode: FILE_PERM })
this.platform.atomicReplace(tmp, this.file_path)
fs.writeFileSync(tmp, this.rawContent, { mode: FILE_PERM })
this.platform.atomicReplace(tmp, this.filePath)
}
catch (err) {
try {
@ -57,16 +64,20 @@ abstract class FileBasedStore implements Store {
throw err
}
}
this.dirty = false
}
lock(): void {
try {
lockfile.lockSync(`${this.file_path}.lock`)
lockfile.lockSync(`${this.filePath}.lock`, {
stale: 30_000,
})
}
catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EEXIST') {
throw new ConcurrentAccessError(this.file_path)
throw new ConcurrentAccessError(this.filePath)
}
throw err
}
@ -74,7 +85,8 @@ abstract class FileBasedStore implements Store {
load(): void {
try {
this.raw_content = fs.readFileSync(this.file_path, 'utf8')
this.rawContent = fs.readFileSync(this.filePath, 'utf8')
this.dirty = false
}
catch (err) {
const code = (err as NodeJS.ErrnoException).code
@ -84,10 +96,18 @@ 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 {
@ -96,18 +116,44 @@ abstract class FileBasedStore implements Store {
}
get<T>(key: Key<T>): T {
return this.withLock(() => this.doGet(key))
return this.withLock(() => {
this.load()
return 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 {
@ -116,7 +162,7 @@ export class YamlStore extends FileBasedStore {
}
doGet<T>(key: Key<T>): T {
const data = loadYaml(this.raw_content)
const data = loadYaml(this.getRawContent(), this.filePath)
const parts = key.key.split('.')
let current: unknown = data
for (const part of parts) {
@ -130,19 +176,20 @@ export class YamlStore extends FileBasedStore {
getTyped<T>(): T | null {
return this.withLock(() => {
this.load()
return loadYaml(this.raw_content) as T
return loadYaml(this.getRawContent(), this.filePath) as T
})
}
setTyped<T>(data: T): void {
this.withLock(() => {
this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true })
this.load()
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
this.flush()
})
}
doSet<T>(key: Key<T>, value: T): void {
const data = loadYaml(this.raw_content) || {}
const data = loadYaml(this.getRawContent(), this.filePath) || {}
const parts = key.key.split('.')
const lastKey = parts.pop()
if (lastKey === undefined)
@ -154,12 +201,74 @@ export class YamlStore extends FileBasedStore {
current = current[part] as Record<string, unknown>
}
current[lastKey] = value
this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true })
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 }))
}
}
function loadYaml(raw: string | undefined): Record<string, unknown> | null {
function loadYaml(raw: string | undefined, file_path: string): Record<string, unknown> | null {
if (raw === undefined)
return null
return (yaml.load(raw) ?? {}) as Record<string, unknown>
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 */ }
}
}

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 { CACHE_NUDGE, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_NUDGE, getCache } from '../store/manager.js'
import { maybeNudgeCompat } from './nudge.js'
const HOST = 'https://cloud.dify.ai'
@ -44,11 +44,18 @@ describe('maybeNudgeCompat', () => {
let dir: string
let store: NudgeStore
let prevCacheDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-'))
store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: fixedNow })
prevCacheDir = process.env[ENV_CACHE_DIR]
process.env[ENV_CACHE_DIR] = dir
store = await loadNudgeStore({ store: getCache(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 })
})
@ -78,12 +85,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: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => yesterday })
const tStore = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => yesterday })
await tStore.markWarned(HOST)
const probe = vi.fn(async () => UNSUPPORTED)
const { emit, lines } = emitterSpy()
const freshStore = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: fixedNow })
const freshStore = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow })
await maybeNudgeCompat(HOST, baseDeps({ store: freshStore, probe, emit }))
expect(probe).toHaveBeenCalledOnce()

View File

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

View File

@ -5,7 +5,6 @@ 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'
@ -48,7 +47,7 @@ export type RunVersionProbeOptions = {
readonly probe?: MetaProbe
}
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts(resolveConfigDir())
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts()
const defaultProbe: MetaProbe = async (endpoint) => {
const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })

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