From 529250173d051b189e79f26fa872bc1c5bfc9b3d Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Fri, 23 Oct 2026 23:45:50 +0800 Subject: [PATCH] F1: add reusable task backend resources and migrate task tools --- backend/app.py | 2 + backend/db/models.py | 29 ++- .../20261023_0027_task_backend_resources.py | 84 +++++++++ backend/models.py | 16 ++ backend/routes/assistants.py | 28 ++- backend/routes/task_backends.py | 109 +++++++++++ backend/routes/tools.py | 4 +- backend/schemas.py | 37 +--- backend/services/config_resolver.py | 3 + backend/services/interface_catalog.py | 5 +- backend/services/system_tools.py | 2 +- backend/services/task_backend_resources.py | 171 ++++++++++++++++++ backend/task_backend_schemas.py | 109 +++++++++++ backend/tests/test_task_backend_resources.py | 159 ++++++++++++++++ implement-stage/task-backends/ASSUMPTIONS.md | 4 + implement-stage/task-backends/BUILD_NOTE.md | 3 +- .../task-backends/database-smoke.json | 19 ++ 17 files changed, 741 insertions(+), 43 deletions(-) create mode 100644 backend/migrations/versions/20261023_0027_task_backend_resources.py create mode 100644 backend/routes/task_backends.py create mode 100644 backend/services/task_backend_resources.py create mode 100644 backend/task_backend_schemas.py create mode 100644 backend/tests/test_task_backend_resources.py create mode 100644 implement-stage/task-backends/database-smoke.json diff --git a/backend/app.py b/backend/app.py index 529de29..f625895 100644 --- a/backend/app.py +++ b/backend/app.py @@ -50,6 +50,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from routes import ( assistants, background_tasks, + task_backends, auth, conversations, health, @@ -198,6 +199,7 @@ app.include_router(test_assets.router) app.include_router(test_runs.router) app.include_router(tools.router) app.include_router(background_tasks.router) +app.include_router(task_backends.router) app.include_router(voice_webrtc.router) app.include_router(voice_ws.router) diff --git a/backend/db/models.py b/backend/db/models.py index 326bc1f..99247f3 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -177,6 +177,7 @@ class Assistant(Base): prompt: Mapped[str] = mapped_column(Text, default="") # prompt dynamic_variable_definitions: Mapped[dict] = mapped_column(JSON, default=dict) built_in_tools: Mapped[dict] = mapped_column(JSONB, default=dict) + task_backend: Mapped[dict | None] = mapped_column(JSONB, nullable=True) analysis_config: Mapped[dict] = mapped_column(JSONB, default=dict) api_url: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt api_key: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt(打码/哨兵) @@ -237,6 +238,27 @@ class McpServer(Base): ) +class TaskBackendResource(Base): + """Reusable connection and execution policy, separate from ordinary tools.""" + + __tablename__ = "task_backend_resources" + __table_args__ = ( + CheckConstraint("type IN ('reasoner', 'opencode')", name="ck_task_backend_type"), + ) + + id: Mapped[str] = mapped_column(String(40), primary_key=True) + name: Mapped[str] = mapped_column(String(128)) + type: Mapped[str] = mapped_column(String(16)) + description: Mapped[str] = mapped_column(String(2048), default="") + config: Mapped[dict] = mapped_column(JSONB, default=dict) + secrets: Mapped[dict] = mapped_column(JSONB, default=dict) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + class Tool(Base): """Reusable LLM tool definition; supported types are executed at runtime.""" @@ -248,7 +270,7 @@ class Tool(Base): name="uq_tools_mcp_server_remote_name", ), CheckConstraint( - "type IN ('http', 'mcp', 'client', 'task')", + "type IN ('http', 'mcp', 'client')", name="ck_tools_external_type", ), ) @@ -655,7 +677,10 @@ class BackgroundTask(Base): id: Mapped[str] = mapped_column(String(40), primary_key=True) assistant_id: Mapped[str] = mapped_column(String(40), index=True) conversation_id: Mapped[str] = mapped_column(String(80), index=True) - tool_id: Mapped[str] = mapped_column(String(40)) + tool_id: Mapped[str] = mapped_column(String(40), default="") + backend_id: Mapped[str] = mapped_column(String(40), default="") + backend_type: Mapped[str] = mapped_column(String(16), default="opencode") + deadline_at: Mapped[str | None] = mapped_column(String(40), nullable=True) instruction: Mapped[str] = mapped_column(Text) origin_key: Mapped[str] = mapped_column(String(256), unique=True) connection: Mapped[dict] = mapped_column(JSONB) diff --git a/backend/migrations/versions/20261023_0027_task_backend_resources.py b/backend/migrations/versions/20261023_0027_task_backend_resources.py new file mode 100644 index 0000000..08954c8 --- /dev/null +++ b/backend/migrations/versions/20261023_0027_task_backend_resources.py @@ -0,0 +1,84 @@ +"""Move task tools into task backend resources without replaying existing tasks.""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision = "20261023_0027" +down_revision = "20261023_0026" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "task_backend_resources", + sa.Column("id", sa.String(40), primary_key=True), + sa.Column("name", sa.String(128), nullable=False), + sa.Column("type", sa.String(16), nullable=False), + sa.Column("description", sa.String(2048), nullable=False), + sa.Column("config", postgresql.JSONB(), nullable=False), + sa.Column("secrets", postgresql.JSONB(), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.CheckConstraint("type IN ('reasoner', 'opencode')", name="ck_task_backend_type"), + ) + op.add_column("assistants", sa.Column("task_backend", postgresql.JSONB(), nullable=True)) + op.add_column("background_tasks", sa.Column("backend_id", sa.String(40), nullable=False, server_default="")) + op.add_column("background_tasks", sa.Column("backend_type", sa.String(16), nullable=False, server_default="opencode")) + # NULL preserves pre-migration observation-window semantics for historical tasks. + op.add_column("background_tasks", sa.Column("deadline_at", sa.String(40), nullable=True)) + op.alter_column("background_tasks", "tool_id", server_default="") + op.execute(""" + DO $$ BEGIN + IF EXISTS ( + SELECT b.assistant_id FROM assistant_tool_bindings b JOIN tools t ON t.id=b.tool_id + WHERE t.type='task' GROUP BY b.assistant_id HAVING count(*) > 1 + ) THEN RAISE EXCEPTION 'Task backend migration: an assistant has multiple task bindings; resolve before retrying'; END IF; + IF EXISTS ( + SELECT 1 FROM assistant_tool_bindings b JOIN tools t ON t.id=b.tool_id + JOIN assistants a ON a.id=b.assistant_id + WHERE t.type='task' AND (a.type != 'prompt' OR a.runtime_mode != 'pipeline') + ) THEN RAISE EXCEPTION 'Task backend migration: task bindings require Prompt Pipeline'; END IF; + END $$ + """) + # Resource IDs may retain the old tool prefix: IDs are local to each resource table. + op.execute(""" + INSERT INTO task_backend_resources (id,name,type,description,config,secrets,enabled,created_at,updated_at) + SELECT id,name,'opencode',description, + jsonb_build_object( + 'url',definition->'config'->'url', + 'directory',definition->'config'->'directory', + 'username',COALESCE(definition->'config'->'username','\"opencode\"'::jsonb), + 'timeout_seconds',COALESCE(definition->'config'->'timeout_seconds',definition->'config'->'timeoutSeconds','600'::jsonb), + 'max_concurrency',1,'instructions','','agent','','model_provider_id','','model_id','','skills_note','' + ), secrets,status='active',created_at,updated_at + FROM tools WHERE type='task' + """) + op.execute(""" + UPDATE assistants a SET task_backend=jsonb_build_object( + 'backend_id', b.tool_id, + 'instruction_source',COALESCE( + b.config->'argument_bindings'->'instruction', + b.config->'argumentBindings'->'instruction', + '{"source":"model"}'::jsonb + ), + 'instructions','' + ) + FROM assistant_tool_bindings b JOIN tools t ON t.id=b.tool_id + WHERE a.id=b.assistant_id AND t.type='task' + """) + op.execute("UPDATE background_tasks SET backend_id=tool_id, backend_type='opencode'") + op.execute("DELETE FROM assistant_tool_bindings WHERE tool_id IN (SELECT id FROM tools WHERE type='task')") + op.execute("DELETE FROM tools WHERE type='task'") + op.drop_constraint("ck_tools_external_type", "tools", type_="check") + op.create_check_constraint("ck_tools_external_type", "tools", "type IN ('http', 'mcp', 'client')") + + +def downgrade(): + raise RuntimeError( + "Task backend resources cannot be downgraded safely to task tools. " + "Restore a pre-migration database backup to roll back this resource migration." + ) diff --git a/backend/models.py b/backend/models.py index 20cc259..a799c4c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -70,6 +70,21 @@ class RuntimeKnowledgeBase(BaseModel): description: str = "" +class RuntimeTaskBackend(BaseModel): + """Resolved task backend, frozen server-side when a task is accepted.""" + + id: str + name: str = "" + type: Literal["reasoner", "opencode"] + config: dict = Field(default_factory=dict) + secrets: dict = Field(default_factory=dict) + model: RuntimeModelResource | None = None + tools: list[RuntimeTool] = Field(default_factory=list) + tool_bindings: list[RuntimeToolBinding] = Field(default_factory=list) + instruction_source: dict = Field(default_factory=lambda: {"source": "model"}) + instructions: str = "" + + class AssistantConfig(BaseModel): """运行时配置:前端可见部分(name/prompt/...) + 服务端注入部分(*_api_key/*_base_url)。""" @@ -138,6 +153,7 @@ class AssistantConfig(BaseModel): # and result mappings; platform built-ins are configured separately. tools: list[RuntimeTool] = Field(default_factory=list) tool_bindings: list[RuntimeToolBinding] = Field(default_factory=list) + task_backend: RuntimeTaskBackend | None = None knowledge_base_id: str | None = None knowledge_base_name: str = "" knowledge_base_description: str = "" diff --git a/backend/routes/assistants.py b/backend/routes/assistants.py index 1449a86..5f016f7 100644 --- a/backend/routes/assistants.py +++ b/backend/routes/assistants.py @@ -26,6 +26,7 @@ from services.state_expressions import StateExpressionError, validate_update_rul from services.system_tools import RESERVED_TOOL_NAMES from services.tool_policy import policy_for_tool from services.tool_executor import ToolExecutor +from services.task_backend_resources import validate_backend_binding from services.workflow_engine import WorkflowEngine from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -242,11 +243,12 @@ async def _validate_tool_bindings( missing = sorted(tool_ids - set(tools)) if missing: raise HTTPException(400, "工具不存在或未启用: " + ", ".join(missing)) - task_tools = [tool for tool in tools.values() if tool.type == "task"] - if task_tools and (body.type != "prompt" or body.runtime_mode != "pipeline"): - raise HTTPException(400, "后台任务工具仅支持提示词 Pipeline") - if len(task_tools) > 1: - raise HTTPException(400, "每个助手最多绑定一个后台任务工具") + if any(tool.type not in {"http", "mcp", "client"} for tool in tools.values()): + for scope, raw in raw_bindings: + tool = tools.get(raw.get("tool_id") or raw.get("toolId")) + if scope.startswith(("Action ", "Startup ")) and tool and tool.type == "task": + raise HTTPException(400, "任务后端不支持启动 Action") + raise HTTPException(400, "普通工具仅支持 HTTP/MCP/Client;任务请配置任务后端") reserved = sorted( tool.function_name for tool in tools.values() @@ -287,10 +289,6 @@ async def _validate_tool_bindings( type=tool.type, definition=tool.definition or {}, ) - if tool.type == "task" and binding.result_assignments: - raise HTTPException(400, "后台任务结果通过任务通知返回,不支持结果变量映射") - if tool.type == "task" and scope.startswith(("Action ", "Startup ")): - raise HTTPException(400, "后台任务工具不支持启动 Action") properties, required = ToolExecutor.input_schema(runtime_tool) unknown = sorted(set(binding.argument_bindings) - set(properties)) if unknown: @@ -506,6 +504,13 @@ async def _validate_knowledge_base(session: AsyncSession, body: AssistantUpsert) raise HTTPException(400, "知识库不存在") +async def _validate_task_backend(session: AsyncSession, body: AssistantUpsert) -> None: + try: + await validate_backend_binding(session, body) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + async def _sync_bindings( session: AsyncSession, assistant_id: str, resource_ids: dict[str, str] ) -> None: @@ -641,6 +646,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut: else [] ), built_in_tools=assistant.built_in_tools or {}, + task_backend=getattr(assistant, "task_backend", None), prompt=assistant.prompt, dynamic_variable_definitions=assistant.dynamic_variable_definitions or {}, analysis_config=assistant.analysis_config or {}, @@ -681,6 +687,7 @@ async def create_assistant( await _validate_vision_model(session, body) await _validate_analysis_model(session, body) await _validate_knowledge_base(session, body) + await _validate_task_backend(session, body) data = body.model_dump() resource_ids = data.pop("model_resource_ids") tool_bindings = body.tool_bindings @@ -721,6 +728,7 @@ async def duplicate_assistant( raise HTTPException(404, "助手不存在") if source.type not in SUPPORTED_ASSISTANT_TYPES: raise HTTPException(410, "该助手类型已下线") + await _validate_task_backend(session, source) assistant = Assistant( id=f"asst_{uuid.uuid4().hex[:12]}", name=f"{source.name} 副本", @@ -739,6 +747,7 @@ async def duplicate_assistant( prompt=source.prompt, dynamic_variable_definitions=dict(source.dynamic_variable_definitions or {}), built_in_tools=dict(source.built_in_tools or {}), + task_backend=dict(source.task_backend) if source.task_backend else None, analysis_config=dict(source.analysis_config or {}), api_url=source.api_url, api_key=source.api_key, @@ -780,6 +789,7 @@ async def update_assistant( await _validate_vision_model(session, body) await _validate_analysis_model(session, body) await _validate_knowledge_base(session, body) + await _validate_task_backend(session, body) data = body.model_dump() resource_ids = data.pop("model_resource_ids") tool_bindings = body.tool_bindings diff --git a/backend/routes/task_backends.py b/backend/routes/task_backends.py new file mode 100644 index 0000000..5118e0b --- /dev/null +++ b/backend/routes/task_backends.py @@ -0,0 +1,109 @@ +"""Administrator CRUD for reusable task backends.""" + +import uuid + +from db.models import TaskBackendResource +from db.session import get_session +from fastapi import APIRouter, Depends, HTTPException +from services.auth import require_admin +from services.masking import merge_secrets +from services.task_backend_resources import ( + backend_consumers, + task_backend_to_out, + validate_backend_binding, + validate_backend_resource, +) +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from task_backend_schemas import TaskBackendOut, TaskBackendUpsert + + +router = APIRouter( + prefix="/api/task-backends", + tags=["task-backends"], + dependencies=[Depends(require_admin)], +) + + +def _payload(body: TaskBackendUpsert, stored=None) -> dict: + stored_secrets = stored.secrets or {} if stored and stored.type == body.type else {} + return { + "name": body.name, "type": body.type, "description": body.description.strip(), + "config": body.config.model_dump(), "enabled": body.enabled, + "secrets": merge_secrets(body.secrets, stored_secrets) if body.type == "opencode" else {}, + } + + +async def _validate(session, body: TaskBackendUpsert) -> None: + if not body.enabled: + return + try: + await validate_backend_resource(session, body) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.get("", response_model=list[TaskBackendOut]) +async def list_task_backends(session: AsyncSession = Depends(get_session)): + rows = (await session.execute(select(TaskBackendResource).order_by(TaskBackendResource.updated_at.desc()))).scalars().all() + return [task_backend_to_out(row) for row in rows] + + +@router.post("", response_model=TaskBackendOut) +async def create_task_backend(body: TaskBackendUpsert, session: AsyncSession = Depends(get_session)): + await _validate(session, body) + resource = TaskBackendResource(id=f"backend_{uuid.uuid4().hex[:12]}", **_payload(body)) + session.add(resource) + await session.commit() + await session.refresh(resource) + return task_backend_to_out(resource) + + +async def _get(session, backend_id: str) -> TaskBackendResource: + resource = await session.get(TaskBackendResource, backend_id) + if resource is None: + raise HTTPException(404, "任务后端不存在") + return resource + + +@router.get("/{backend_id}", response_model=TaskBackendOut) +async def get_task_backend(backend_id: str, session: AsyncSession = Depends(get_session)): + return task_backend_to_out(await _get(session, backend_id)) + + +@router.put("/{backend_id}", response_model=TaskBackendOut) +async def update_task_backend(backend_id: str, body: TaskBackendUpsert, session: AsyncSession = Depends(get_session)): + resource = await _get(session, backend_id) + await _validate(session, body) + payload = _payload(body, resource) + proposed = TaskBackendResource(id=resource.id, **payload) + consumers = await backend_consumers(session, backend_id) + # Disabling remains reversible; valid enabled configurations must work for all users. + if proposed.enabled: + try: + for assistant in consumers: + await validate_backend_binding(session, assistant, resource=proposed) + except ValueError as exc: + raise HTTPException(400, f"助手 {assistant.name} 的任务配置无效: {exc}") from exc + for key, value in payload.items(): + setattr(resource, key, value) + await session.commit() + await session.refresh(resource) + return task_backend_to_out(resource) + + +@router.post("/{backend_id}/duplicate", response_model=TaskBackendOut) +async def duplicate_task_backend(backend_id: str, session: AsyncSession = Depends(get_session)): + source = await _get(session, backend_id) + body = TaskBackendUpsert(name=f"{source.name[:124]} 副本", type=source.type, description=source.description, config=source.config, secrets=source.secrets or {}, enabled=source.enabled) + return await create_task_backend(body, session) + + +@router.delete("/{backend_id}") +async def delete_task_backend(backend_id: str, session: AsyncSession = Depends(get_session)): + resource = await _get(session, backend_id) + if await backend_consumers(session, backend_id): + raise HTTPException(409, "任务后端正被助手引用,请先解绑") + await session.delete(resource) + await session.commit() + return {"ok": True} diff --git a/backend/routes/tools.py b/backend/routes/tools.py index 19cb628..4fce771 100644 --- a/backend/routes/tools.py +++ b/backend/routes/tools.py @@ -100,7 +100,7 @@ def _payload( _normalize_http_template_inputs(definition) secrets = ( merge_secrets(body.secrets, stored_secrets or {}) - if definition["type"] in {"http", "task"} + if definition["type"] == "http" else {} ) secret_variables = (secrets.get("dynamic_variables") or {}) if secrets else {} @@ -154,7 +154,7 @@ async def list_tools(session: AsyncSession = Depends(get_session)): rows = ( await session.execute( select(Tool) - .where(Tool.type.in_(("http", "mcp", "client", "task"))) + .where(Tool.type.in_(("http", "mcp", "client"))) .order_by(Tool.updated_at.desc()) ) ).scalars().all() diff --git a/backend/schemas.py b/backend/schemas.py index 62cc8f3..8ff44c2 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -21,7 +21,7 @@ AssistantType = Literal["prompt", "workflow", "dify", "fastgpt"] SUPPORTED_ASSISTANT_TYPES = get_args(AssistantType) TurnEndStrategy = Literal["silence", "smart_turn"] KnowledgeRetrievalMode = Literal["automatic", "on_demand"] -ToolType = Literal["http", "mcp", "client", "task"] +ToolType = Literal["http", "mcp", "client"] ToolStatus = Literal["active", "archived", "draft"] McpServerStatus = Literal["active", "archived", "draft"] McpTransport = Literal["streamable_http", "sse"] @@ -180,6 +180,12 @@ class ToolBinding(CamelModel): result_assignments: dict[str, str] = Field(default_factory=dict) +class AssistantTaskBackendBinding(CamelModel): + backend_id: str = Field(min_length=1, max_length=40) + instruction_source: ValueSource = Field(default_factory=ModelValueSource) + instructions: str = Field(default="", max_length=12000) + + class StartupAction(CamelModel): id: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_-]*$") phase: Literal["preflight", "opening"] = "opening" @@ -233,6 +239,7 @@ class AssistantUpsert(CamelModel): default_factory=KnowledgeRetrievalConfig ) tool_bindings: list[ToolBinding] = Field(default_factory=list) + task_backend: AssistantTaskBackendBinding | None = None built_in_tools: "BuiltInTools" = Field(default_factory=lambda: BuiltInTools()) # 瘦类型专属(真列);按 type 取用,无关字段写入时清零 @@ -262,6 +269,8 @@ class AssistantUpsert(CamelModel): @model_validator(mode="after") def _strip_irrelevant_fields(self): + if self.task_backend and (self.type != "prompt" or self.runtime_mode != "pipeline"): + raise ValueError("任务后端仅支持提示词 Pipeline") allowed = ALLOWED_FIELDS[self.type] for field in ("prompt", "api_url", "api_key", "app_id"): if field not in allowed: @@ -474,37 +483,11 @@ class McpToolDefinition(CamelModel): config: McpToolConfig -class TaskToolConfig(CamelModel): - provider: Literal["opencode"] = "opencode" - url: str = Field(min_length=1, max_length=2048) - directory: str = Field(min_length=1, max_length=2048) - username: str = Field(default="opencode", max_length=128) - timeout_seconds: int = Field(default=600, ge=30, le=7200) - allow_interruptions: Literal[True] = True - execution_mode: Literal["immediate"] = "immediate" - - @field_validator("url") - @classmethod - def validate_task_url(cls, value: str) -> str: - from urllib.parse import urlsplit - parsed = urlsplit(value) - if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment: - raise ValueError("任务服务须使用不含凭证、查询参数的 HTTP(S) 地址") - return value.rstrip("/") - - -class TaskToolDefinition(CamelModel): - schema_version: int = 2 - type: Literal["task"] = "task" - config: TaskToolConfig - - ToolDefinition = Annotated[ Union[ HttpToolDefinition, ClientToolDefinition, McpToolDefinition, - TaskToolDefinition, ], Field(discriminator="type"), ] diff --git a/backend/services/config_resolver.py b/backend/services/config_resolver.py index 7130255..ce4c436 100644 --- a/backend/services/config_resolver.py +++ b/backend/services/config_resolver.py @@ -22,6 +22,7 @@ from models import ( RuntimeToolBinding, ) from services.node_specs import copy_graph, graph_references +from services.task_backend_resources import resolve_task_backend from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -174,6 +175,7 @@ async def _tools_for( .where( or_(*identity_filters), Tool.status == "active", + Tool.type.in_(("http", "mcp", "client")), ) ) ).scalars().all() @@ -323,6 +325,7 @@ async def resolve_runtime_config( built_in_tools=assistant.built_in_tools or {}, tools=runtime_tools, tool_bindings=prompt_tool_bindings, + task_backend=await resolve_task_backend(session, assistant), knowledge_base_id=assistant.knowledge_base_id, knowledge_base_name=knowledge_base.name if knowledge_base else "", knowledge_base_description=knowledge_base.description if knowledge_base else "", diff --git a/backend/services/interface_catalog.py b/backend/services/interface_catalog.py index 8bb0a34..0eb0219 100644 --- a/backend/services/interface_catalog.py +++ b/backend/services/interface_catalog.py @@ -52,7 +52,10 @@ INTERFACE_DEFINITIONS: list[dict] = [ "name": "OpenAI Compatible LLM", "capability": "LLM", "fields": OPENAI_COMMON - + [field("temperature", "Temperature", type_="number", default=0.7)], + + [ + field("temperature", "Temperature", type_="number", default=0.7), + field("supportsReasoningEffort", "Supports reasoning_effort", type_="boolean", default=False), + ], }, { "interface_type": "openai-asr", diff --git a/backend/services/system_tools.py b/backend/services/system_tools.py index aed6741..d2037f6 100644 --- a/backend/services/system_tools.py +++ b/backend/services/system_tools.py @@ -34,7 +34,7 @@ SYSTEM_TOOL_SPECS: dict[str, dict[str, str]] = { SYSTEM_TOOL_KINDS = frozenset(SYSTEM_TOOL_SPECS) RESERVED_TOOL_NAMES = frozenset( spec["function_name"] for spec in SYSTEM_TOOL_SPECS.values() -) | frozenset({"get_background_task", "cancel_background_task", "send_background_task_message"}) +) | frozenset({"delegate_task", "get_background_task", "cancel_background_task", "send_background_task_message", "complete_task", "ask_user"}) def system_tool_kind(definition: Mapping[str, Any] | None) -> str | None: diff --git a/backend/services/task_backend_resources.py b/backend/services/task_backend_resources.py new file mode 100644 index 0000000..003de43 --- /dev/null +++ b/backend/services/task_backend_resources.py @@ -0,0 +1,171 @@ +"""Validate reusable task backends and resolve their server-only snapshots.""" + +import json +import re + +from db.models import Assistant, McpServer, ModelResource, TaskBackendResource, Tool +from models import ( + RuntimeModelResource, + RuntimeTaskBackend, + RuntimeTool, + RuntimeToolBinding, +) +from schemas import AssistantTaskBackendBinding +from services.masking import mask_secrets +from services.system_tools import RESERVED_TOOL_NAMES +from services.tool_executor import ToolExecutor +from sqlalchemy import or_, select +from task_backend_schemas import ( + ReasonerBackendConfig, + TaskBackendOut, + TaskBackendUpsert, +) + + +PLACEHOLDER = re.compile(r"{{\s*([A-Za-z][A-Za-z0-9_]*)\s*}}") +SYSTEM_VARIABLES = { + "system__agent_id", "system__conversation_id", "system__time", "system__time_utc", + "system__timezone", "system__agent_turns", "system__conversation_history", + "system__last_action_status", "system__last_action_invocation_id", "system__last_action_duration_ms", + "system__last_action_error", "system__last_action_error_code", "system__handoff_status", +} + + +def task_backend_to_out(resource: TaskBackendResource) -> TaskBackendOut: + return TaskBackendOut( + id=resource.id, name=resource.name, type=resource.type, + description=resource.description, config=resource.config, + secrets=mask_secrets(resource.secrets or {}), enabled=resource.enabled, + updated_at=resource.updated_at.isoformat() if resource.updated_at else None, + ) + + +def validate_source_variables(source: dict, variables: set[str]) -> None: + names = set() + if source.get("source") == "variable": + names.add(str(source.get("variable_name") or source.get("variableName") or "")) + elif source.get("source") == "template": + names.update(PLACEHOLDER.findall(json.dumps(source.get("value"), ensure_ascii=False))) + unknown = names - variables - SYSTEM_VARIABLES + if unknown: + raise ValueError("任务配置引用了未声明变量: " + ", ".join(sorted(unknown))) + + +async def validate_backend_resource( + session, + body: TaskBackendUpsert, + *, + variables: set[str] | None = None, +) -> None: + if body.type != "reasoner": + return + config = body.config + assert isinstance(config, ReasonerBackendConfig) + model = await session.get(ModelResource, config.model_resource_id) + if ( + not model + or not model.enabled + or model.capability != "LLM" + or model.interface_type not in {"openai-llm", "dashscope-llm"} + ): + raise ValueError("Reasoner 必须引用已启用的 OpenAI-compatible LLM 模型") + if ( + config.reasoning_effort != "provider_default" + and (model.values or {}).get("supportsReasoningEffort") is not True + ): + raise ValueError("该模型未声明支持 reasoning_effort,不能设置推理强度") + names = set() + for binding in config.tool_bindings: + tool = await session.get(Tool, binding.tool_id) + if not tool or tool.status != "active" or tool.type not in {"http", "mcp"}: + raise ValueError("Reasoner 仅允许已启用的 HTTP/MCP 服务端工具: " + binding.tool_id) + if tool.function_name in RESERVED_TOOL_NAMES or tool.function_name in names: + raise ValueError("Reasoner 工具函数名重复或属于平台保留名称: " + tool.function_name) + names.add(tool.function_name) + if tool.type == "mcp": + server = await session.get(McpServer, tool.mcp_server_id) if tool.mcp_server_id else None + if not server or server.status != "active": + raise ValueError("Reasoner MCP 工具的 Server 不存在或未启用") + runtime_tool = RuntimeTool( + id=tool.id, + name=tool.name, + function_name=tool.function_name, + type=tool.type, + definition=tool.definition or {}, + ) + properties, required = ToolExecutor.input_schema(runtime_tool) + if set(binding.argument_bindings) - set(properties): + raise ValueError("Reasoner 工具绑定引用了未知参数") + if set(required) - set(binding.argument_bindings): + raise ValueError("Reasoner 工具绑定缺少必填参数") + template_inputs = ((tool.definition or {}).get("config") or {}).get("template_inputs") or [] + template_names = {item["name"] for item in template_inputs if item.get("name")} + required_templates = {item["name"] for item in template_inputs if item.get("name") and item.get("required", True)} + if set(binding.template_bindings) - template_names or required_templates - set(binding.template_bindings): + raise ValueError("Reasoner 工具模板输入缺失或引用了未知模板输入") + if variables is not None: + for source in [*binding.argument_bindings.values(), *binding.template_bindings.values()]: + validate_source_variables(source.model_dump(), variables) + unknown_targets = set(binding.result_assignments) - variables + if unknown_targets: + raise ValueError("Reasoner 结果映射目标未声明: " + ", ".join(sorted(unknown_targets))) + + +async def backend_consumers(session, backend_id: str) -> list[Assistant]: + return list((await session.execute(select(Assistant).where(or_( + Assistant.task_backend["backend_id"].astext == backend_id, + Assistant.task_backend["backendId"].astext == backend_id, + )))).scalars().all()) + + +async def validate_backend_binding( + session, assistant, *, resource=None +) -> TaskBackendResource | None: + raw = getattr(assistant, "task_backend", None) + if not raw: + return None + if assistant.type != "prompt" or getattr(assistant, "runtime_mode", "pipeline") != "pipeline": + raise ValueError("任务后端仅支持提示词 Pipeline") + binding = ( + raw + if isinstance(raw, AssistantTaskBackendBinding) + else AssistantTaskBackendBinding.model_validate(raw) + ) + resource = resource or await session.get(TaskBackendResource, binding.backend_id) + if resource is None or not resource.enabled: + raise ValueError("任务后端不存在或未启用") + variables = set(assistant.dynamic_variable_definitions or {}) + validate_source_variables(binding.instruction_source.model_dump(), variables) + validate_source_variables({"source": "template", "value": binding.instructions}, variables) + body = TaskBackendUpsert(name=resource.name, type=resource.type, config=resource.config) + await validate_backend_resource(session, body, variables=variables) + return resource + + +async def resolve_task_backend(session, assistant) -> RuntimeTaskBackend | None: + resource = await validate_backend_binding(session, assistant) + if resource is None: + return None + binding = AssistantTaskBackendBinding.model_validate(assistant.task_backend) + body = TaskBackendUpsert(name=resource.name, type=resource.type, config=resource.config) + config = body.config.model_dump() + model = None + tool_bindings = [] + tools = [] + if resource.type == "reasoner": + from services.config_resolver import _tools_for + + row = await session.get(ModelResource, config["model_resource_id"]) + model = RuntimeModelResource( + id=row.id, name=row.name, capability=row.capability, interface_type=row.interface_type, + values=row.values or {}, secrets=row.secrets or {}, support_image_input=bool(row.support_image_input), + ) + tool_bindings = [RuntimeToolBinding(**item) for item in config["tool_bindings"]] + tools = await _tools_for(session, assistant, [item.tool_id for item in tool_bindings]) + if len(tools) != len(tool_bindings): + raise ValueError("Reasoner 工具解析失败,请检查工具与 MCP Server") + return RuntimeTaskBackend( + id=resource.id, name=resource.name, type=resource.type, config=config, + secrets=resource.secrets or {}, model=model, tools=tools, tool_bindings=tool_bindings, + instruction_source=binding.instruction_source.model_dump(), instructions=binding.instructions, + ) diff --git a/backend/task_backend_schemas.py b/backend/task_backend_schemas.py new file mode 100644 index 0000000..6806441 --- /dev/null +++ b/backend/task_backend_schemas.py @@ -0,0 +1,109 @@ +"""Public task backend configuration; runtime credentials stay server-side.""" + +from typing import Any, Literal +from urllib.parse import urlsplit + +from pydantic import ConfigDict, Field, field_validator, model_validator +from schemas import CamelModel, ToolBinding + + +class TaskBackendConfig(CamelModel): + model_config = ConfigDict(extra="forbid") + + instructions: str = Field(default="", max_length=16000) + timeout_seconds: int = Field(default=600, ge=30, le=7200) + max_concurrency: Literal[1] = 1 + + +class TaskSkill(CamelModel): + model_config = ConfigDict(extra="forbid") + name: str = Field(min_length=1, max_length=128) + content: str = Field(min_length=1, max_length=16000) + + @field_validator("name", "content") + @classmethod + def require_nonempty(cls, value: str) -> str: + if not value.strip(): + raise ValueError("技能名称和正文不能为空") + return value.strip() + + +class ReasonerBackendConfig(TaskBackendConfig): + model_resource_id: str = Field(min_length=1, max_length=40) + reasoning_effort: Literal["provider_default", "low", "medium", "high"] = "provider_default" + tool_bindings: list[ToolBinding] = Field(default_factory=list, max_length=50) + skills: list[TaskSkill] = Field(default_factory=list, max_length=20) + + @model_validator(mode="after") + def unique_bindings_and_skills(self): + ids = [binding.tool_id for binding in self.tool_bindings] + if len(ids) != len(set(ids)): + raise ValueError("Reasoner 工具绑定不能重复") + names = [skill.name for skill in self.skills] + if len(names) != len(set(names)): + raise ValueError("技能名称不能重复") + return self + + +class OpenCodeBackendConfig(TaskBackendConfig): + url: str = Field(min_length=1, max_length=2048) + directory: str = Field(min_length=1, max_length=2048) + username: str = Field(default="opencode", max_length=128) + agent: str = Field(default="", max_length=128) + model_provider_id: str = Field(default="", max_length=128) + model_id: str = Field(default="", max_length=256) + skills_note: str = Field(default="", max_length=4000) + + @field_validator("url") + @classmethod + def validate_url(cls, value: str) -> str: + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment: + raise ValueError("任务服务须使用不含凭证、查询参数的 HTTP(S) 地址") + return value.rstrip("/") + + @field_validator("directory") + @classmethod + def require_directory(cls, value: str) -> str: + if not value.strip(): + raise ValueError("必须填写远端工作目录") + return value.strip() + + @model_validator(mode="after") + def require_model_pair(self): + if bool(self.model_provider_id) != bool(self.model_id): + raise ValueError("远端模型的 provider ID 和 model ID 必须同时填写") + return self + + +class TaskBackendUpsert(CamelModel): + name: str = Field(min_length=1, max_length=128) + type: Literal["reasoner", "opencode"] + description: str = Field(default="", max_length=2048) + config: ReasonerBackendConfig | OpenCodeBackendConfig + secrets: dict[str, Any] = Field(default_factory=dict) + enabled: bool = True + + @model_validator(mode="before") + @classmethod + def select_config(cls, value): + if isinstance(value, dict) and value.get("type") in {"reasoner", "opencode"}: + value = dict(value) + config_class = ReasonerBackendConfig if value["type"] == "reasoner" else OpenCodeBackendConfig + raw = value.get("config") or {} + if isinstance(raw, CamelModel): + raw = raw.model_dump() + value["config"] = config_class.model_validate(raw) + return value + + @field_validator("name") + @classmethod + def require_name(cls, value: str) -> str: + if not value.strip(): + raise ValueError("后端名称不能为空") + return value.strip() + + +class TaskBackendOut(TaskBackendUpsert): + id: str + updated_at: str | None = None diff --git a/backend/tests/test_task_backend_resources.py b/backend/tests/test_task_backend_resources.py new file mode 100644 index 0000000..5d759bd --- /dev/null +++ b/backend/tests/test_task_backend_resources.py @@ -0,0 +1,159 @@ +from copy import deepcopy +from types import SimpleNamespace +import unittest + +from db.models import Assistant, ModelResource, TaskBackendResource, Tool +from fastapi import FastAPI, HTTPException +from httpx import ASGITransport, AsyncClient +from routes import task_backends as routes +from schemas import AssistantOut, AssistantUpsert, ToolUpsert +from services.auth import require_admin +from services.task_backend_resources import resolve_task_backend, validate_backend_binding, validate_backend_resource +from task_backend_schemas import TaskBackendUpsert + + +def opencode_body(**changes): + return TaskBackendUpsert(name="远端任务", type="opencode", config={"url": "https://agent.test", "directory": "/work"}, **changes) + + +class MemorySession: + """Explicit test-only repository for API/config boundary tests.""" + + def __init__(self, *rows): + self.rows = {(type(row), row.id): row for row in rows} + + async def get(self, model, key): + return self.rows.get((model, key)) + + def add(self, row): + self.rows[type(row), row.id] = row + + async def delete(self, row): + self.rows.pop((type(row), row.id)) + + async def commit(self): + pass + + async def refresh(self, row): + pass + + async def execute(self, statement): + model = statement.column_descriptions[0]["entity"] + rows = [row for (kind, _), row in self.rows.items() if kind is model] + return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows)) + + +class TaskBackendSchemaTests(unittest.TestCase): + def test_task_is_not_an_ordinary_tool_resource(self): + with self.assertRaises(ValueError): + ToolUpsert(name="旧工具", functionName="coding_task", definition={"type": "task", "config": {"url": "https://agent.test", "directory": "/work"}}) + + def test_backend_config_requires_real_supported_fields(self): + with self.assertRaisesRegex(ValueError, "Extra inputs"): + TaskBackendUpsert(name="后端", type="opencode", config={"url": "https://agent.test", "directory": "/work", "grantAllPermissions": True}) + with self.assertRaisesRegex(ValueError, "同时填写"): + TaskBackendUpsert(name="后端", type="opencode", config={"url": "https://agent.test", "directory": "/work", "modelId": "model"}) + + def test_reasoner_rejects_duplicate_tools_and_skills(self): + for extra in [ + {"toolBindings": [{"toolId": "http_1"}, {"toolId": "http_1"}]}, + {"skills": [{"name": "review", "content": "a"}, {"name": " review ", "content": "b"}]}, + ]: + with self.subTest(extra=extra), self.assertRaisesRegex(ValueError, "重复"): + TaskBackendUpsert(name="推理", type="reasoner", config={"modelResourceId": "llm", **extra}) + + def test_assistant_binding_preserves_all_instruction_sources(self): + for source in [ + {"source": "model"}, {"source": "literal", "value": "固定任务"}, + {"source": "variable", "variableName": "goal"}, + {"source": "template", "value": "处理 {{goal}}"}, + ]: + with self.subTest(source=source): + body = AssistantUpsert(name="助手", taskBackend={"backendId": "legacy_tool_id", "instructions": "补充说明", "instructionSource": source}) + out = AssistantOut(id="asst", **body.model_dump()).model_dump(by_alias=True) + self.assertEqual(out["taskBackend"]["instructionSource"], source) + self.assertEqual(out["taskBackend"]["instructions"], "补充说明") + + def test_task_backend_is_prompt_pipeline_only(self): + for options in [{"type": "workflow"}, {"runtimeMode": "realtime"}, {"type": "dify"}]: + with self.subTest(options=options), self.assertRaisesRegex(ValueError, "提示词 Pipeline"): + AssistantUpsert(name="助手", taskBackend={"backendId": "backend_1"}, **options) + + +class TaskBackendValidationTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.model = ModelResource(id="llm", name="深思考", capability="LLM", interface_type="openai-llm", values={}, secrets={"apiKey": "model-secret"}, enabled=True) + self.session = MemorySession(self.model) + + async def test_reasoning_strength_requires_explicit_model_capability(self): + body = TaskBackendUpsert(name="推理", type="reasoner", config={"modelResourceId": "llm", "reasoningEffort": "high"}) + with self.assertRaisesRegex(ValueError, "未声明"): + await validate_backend_resource(self.session, body) + self.model.values = {"supportsReasoningEffort": True} + await validate_backend_resource(self.session, body) + + async def test_reasoner_rejects_client_tools_and_unknown_required_inputs(self): + tool = Tool(id="tool", name="工具", function_name="read_data", type="client", status="active", definition={"config": {}}) + self.session.add(tool) + body = TaskBackendUpsert(name="推理", type="reasoner", config={"modelResourceId": "llm", "toolBindings": [{"toolId": "tool"}]}) + with self.assertRaisesRegex(ValueError, "HTTP/MCP"): + await validate_backend_resource(self.session, body) + tool.type = "http" + tool.definition = {"config": {"parameters": [{"name": "query", "required": True, "type": "string"}]}} + with self.assertRaisesRegex(ValueError, "缺少必填"): + await validate_backend_resource(self.session, body) + + async def test_template_sources_are_checked_against_actual_assistant(self): + resource = TaskBackendResource(id="backend", **routes._payload(opencode_body())) + self.session.add(resource) + body = AssistantUpsert(name="助手", taskBackend={"backendId": "backend", "instructionSource": {"source": "template", "value": "处理 {{missing}}"}}) + with self.assertRaisesRegex(ValueError, "未声明变量"): + await validate_backend_binding(self.session, body) + body.dynamic_variable_definitions = {"missing": {"type": "string"}} + await validate_backend_binding(self.session, body) + + async def test_runtime_snapshot_resolves_backend_and_model_separately(self): + resource = TaskBackendResource(id="backend", name="推理", type="reasoner", config={"model_resource_id": "llm", "reasoning_effort": "provider_default"}, secrets={}, enabled=True) + self.session.add(resource) + assistant = Assistant(id="assistant", name="助手", type="prompt", runtime_mode="pipeline", task_backend={"backend_id": "backend", "instruction_source": {"source": "literal", "value": "固定任务"}}, dynamic_variable_definitions={}) + resolved = await resolve_task_backend(self.session, assistant) + self.assertEqual(resolved.model.id, "llm") + self.assertEqual(resolved.model.secrets["apiKey"], "model-secret") + self.assertEqual(resolved.instruction_source["value"], "固定任务") + self.assertEqual(resolved.config["max_concurrency"], 1) + + async def test_masked_secret_preserves_stored_value_and_type_switch_discards_it(self): + stored = TaskBackendResource(type="opencode", secrets={"password": "stored-secret"}) + payload = routes._payload(opencode_body(secrets={"password": "****"}), stored) + self.assertEqual(payload["secrets"]["password"], "stored-secret") + reasoner = TaskBackendUpsert(name="推理", type="reasoner", config={"modelResourceId": "llm"}) + self.assertEqual(routes._payload(reasoner, stored)["secrets"], {}) + + async def test_resource_api_requires_admin_and_masks_passwords(self): + app = FastAPI() + app.include_router(routes.router) + async def session_dependency(): + yield self.session + app.dependency_overrides[routes.get_session] = session_dependency + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + self.assertEqual((await client.get("/api/task-backends")).status_code, 401) + app.dependency_overrides[require_admin] = lambda: True + response = await client.post("/api/task-backends", json=opencode_body(secrets={"password": "keep-me-private"}).model_dump(by_alias=True)) + self.assertEqual(response.status_code, 200, response.text) + backend_id = response.json()["id"] + self.assertNotIn("keep-me-private", response.text) + data = response.json() + data["name"] = "修改后端" + response = await client.put(f"/api/task-backends/{backend_id}", json=data) + self.assertEqual(response.status_code, 200, response.text) + stored = await self.session.get(TaskBackendResource, backend_id) + self.assertEqual(stored.secrets["password"], "keep-me-private") + duplicate = await client.post(f"/api/task-backends/{backend_id}/duplicate") + self.assertEqual(duplicate.status_code, 200, duplicate.text) + self.assertNotIn("keep-me-private", duplicate.text) + self.assertNotEqual(duplicate.json()["id"], backend_id) + self.assertEqual((await client.delete(f"/api/task-backends/{backend_id}")).status_code, 200) + + +if __name__ == "__main__": + unittest.main() diff --git a/implement-stage/task-backends/ASSUMPTIONS.md b/implement-stage/task-backends/ASSUMPTIONS.md index a9e643e..5836a01 100644 --- a/implement-stage/task-backends/ASSUMPTIONS.md +++ b/implement-stage/task-backends/ASSUMPTIONS.md @@ -21,3 +21,7 @@ | B-016 | MVP执行环境 | 内置Reasoner不提供宿主机Shell/文件沙箱;编码由OpenCode处理,界面明确实际权限由执行端配置 | semantic | default | | B-017 | F0过渡 | Walking skeleton短暂兼容旧tool提交入口以保持累计检查;迁移阶段移除该过渡入口,不保留为新产品接口 | interface | default | | B-018 | 回退迁移 | 0027为数据语义迁移,downgrade明确拒绝,需备份恢复;不能把新的Reasoner配置静默回退成旧task工具 | semantic | default | +| B-019 | 切换Realtime | 已绑定任务后端时复用现有配置移除确认交互,确认切换后解除绑定,后端资源保留 | semantic | default | +| B-020 | 配置有效性 | 后端配置拒绝未知字段;资源编辑验证已绑定助手,模型/工具失效时阻止保存和执行,不静默漏掉能力 | semantic | default | +| B-021 | 类型与凭据 | 后端资源可切换类型,但必须提交完整合法配置;OpenCode转Reasoner清除服务凭据,同类型空值/掩码保留原凭据 | interface | default | +| B-022 | 指令模板 | 助手绑定 instructions 的 {{变量}} 使用任务接受时冻结的变量单次渲染;资源 instructions 和技能正文按字面使用,不访问密钥变量 | semantic | default | diff --git a/implement-stage/task-backends/BUILD_NOTE.md b/implement-stage/task-backends/BUILD_NOTE.md index 02c9267..371bd9f 100644 --- a/implement-stage/task-backends/BUILD_NOTE.md +++ b/implement-stage/task-backends/BUILD_NOTE.md @@ -3,13 +3,14 @@ | Rung | Feature | Acceptance command | Tier | Status | |------|---------|--------------------|------|--------| | F0 | 新后端输入→原生Job→记录主线 | cd backend && .venv/bin/python -m unittest tests.test_task_backend_spine | MUST | passed | -| F1 | 后端资源、助手配置、旧数据迁移 | cd backend && .venv/bin/python -m unittest tests.test_task_backend_resources | MUST | pending | +| F1 | 后端资源、助手配置、旧数据迁移 | cd backend && .venv/bin/python -m unittest tests.test_task_backend_resources | MUST | passed | | F2 | 原生Reasoner、OpenCode、委派桥 | cd backend && .venv/bin/python -m unittest tests.test_reasoner_backend tests.test_prompt_background_tasks tests.test_background_tasks | MUST | pending | | F3 | 任务后端资源页与提示词编辑器 | cd frontend && npx tsc --noEmit | MUST | pending | | F4 | 全量回归、隔离数据库迁移与审阅 | make test | MUST | pending | ## Run record - F0:新后端资源输入 + 原生Jobs + 旧生命周期累计测试14项,exit 0。显式模拟执行器;backend-spine-smoke.json 已生成。 +- F1:12项资源专项测试通过,连同原生主线、桥接、生命周期累计33项通过,exit 0;真实隔离 PostgreSQL 17 完成0026旧数据→0027迁移及CRUD/助手复制/运行时解析,见database-smoke.json。 ## Deferred - 真实模型/OpenCode联调:尚无服务地址与凭据。 diff --git a/implement-stage/task-backends/database-smoke.json b/implement-stage/task-backends/database-smoke.json new file mode 100644 index 0000000..d42cd53 --- /dev/null +++ b/implement-stage/task-backends/database-smoke.json @@ -0,0 +1,19 @@ +{ + "status": "passed", + "database": "isolated disposable PostgreSQL 17 + pgvector Docker container", + "production_database_touched": false, + "environment_file_modified": false, + "temporary_container_removed": true, + "migration": "empty -> 0026; seeded legacy configurations -> 0027", + "checks": [ + "model/literal/variable/template instruction sources preserved", + "backend IDs, connection configuration and service credentials preserved", + "legacy task tool rows and ordinary tool bindings removed", + "history IDs, status, sequence, connection and remote handles unchanged; backend metadata mapped; deadline NULL", + "real PostgreSQL resource create/list/update/duplicate/delete and bound-delete 409", + "real PostgreSQL assistant create/duplicate and runtime model/skill/backend resolution", + "API secrets masked; masked update preserves stored credentials" + ], + "live_model_or_opencode_called": false, + "scope": "database persistence/configuration validation only; no claims of external model or OpenCode end-to-end execution" +}