From 8c6a767113892e36edf89cce9d87e926cbff5818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=82=9F=E6=89=AC?= Date: Sat, 15 Aug 2026 22:10:05 +0800 Subject: [PATCH 1/9] feat: add agents.yaml project debugger --- .changeset/project-playground-debugger.md | 9 + README.md | 6 +- README.zh-CN.md | 6 +- apps/server/openapi.json | 4034 ++++++----------- apps/server/package.json | 3 +- apps/server/src/app.ts | 50 +- apps/server/src/index.ts | 2 +- apps/server/src/routes/operations.ts | 96 + apps/server/src/routes/project-sessions.ts | 357 ++ apps/server/src/routes/project.ts | 225 + apps/server/src/schemas/common.ts | 1 + apps/server/src/schemas/project.ts | 138 + apps/server/src/services/project-manager.ts | 363 ++ .../server/src/services/project-operations.ts | 199 + .../src/services/project-runtime-registry.ts | 118 + apps/server/src/services/project-sessions.ts | 335 ++ apps/server/tests/project-manager.test.ts | 107 + apps/server/tests/project-operations.test.ts | 51 + apps/server/tests/project-security.test.ts | 39 + apps/server/tests/project-sessions.test.ts | 92 + apps/webui/index.html | 6 +- apps/webui/package.json | 2 +- apps/webui/src/App.tsx | 1211 ++++- apps/webui/src/app/base.css | 4 +- apps/webui/src/app/project-workbench.css | 1096 +++++ apps/webui/src/app/session-preview.css | 2087 +++++++++ apps/webui/src/components/ToolChainPanel.tsx | 54 +- .../task-run-modal/InlineArtifactCard.tsx | 220 +- .../task-run-modal/RunTimelineItemView.tsx | 2 +- apps/webui/src/lib/api/generated/schema.d.ts | 2793 +++--------- .../webui/src/lib/artifact-access-context.tsx | 6 +- apps/webui/src/lib/domain/file-api.ts | 9 +- apps/webui/src/lib/file-name.ts | 13 + apps/webui/src/lib/project-api.ts | 136 + .../webui/src/lib/view/file-mention-render.ts | 4 +- apps/webui/src/lib/view/run-timeline.ts | 73 +- apps/webui/src/main.tsx | 20 +- .../AgentSessionPreviewLauncher.tsx | 68 + .../session-preview/SessionPreviewPage.tsx | 319 ++ .../SessionPreviewSidePanel.tsx | 221 + apps/webui/src/session-preview/errors.ts | 24 + apps/webui/src/session-preview/events.ts | 18 + apps/webui/src/session-preview/route.ts | 21 + .../webui/src/session-preview/sidebar-data.ts | 216 + .../useProjectSessionPreview.ts | 215 + apps/webui/tests/project-workbench.test.ts | 22 + apps/webui/tests/role-prompt-focus.test.ts | 16 - apps/webui/tests/session-preview.test.ts | 193 + apps/webui/tsconfig.json | 2 +- bun.lock | 6 +- docs/reference/cli.md | 19 +- packages/cli/src/commands/playground.ts | 137 +- packages/cli/src/program.ts | 17 +- packages/cli/tests/unit/cli-contracts.test.ts | 58 +- packages/playground/README.md | 9 +- packages/playground/package.json | 1 + packages/playground/src/server.ts | 34 +- packages/playground/tsup.config.ts | 2 +- packages/sdk/src/index.ts | 9 + .../sdk/src/internal/core/agent-runtime.ts | 125 +- .../internal/parser/resolve-project-config.ts | 56 +- packages/sdk/src/internal/types/dto.ts | 4 + packages/sdk/tests/unit/agent-runtime.test.ts | 105 + .../tests/unit/resolve-from-object.test.ts | 57 +- 64 files changed, 10585 insertions(+), 5356 deletions(-) create mode 100644 .changeset/project-playground-debugger.md create mode 100644 apps/server/src/routes/operations.ts create mode 100644 apps/server/src/routes/project-sessions.ts create mode 100644 apps/server/src/routes/project.ts create mode 100644 apps/server/src/schemas/project.ts create mode 100644 apps/server/src/services/project-manager.ts create mode 100644 apps/server/src/services/project-operations.ts create mode 100644 apps/server/src/services/project-runtime-registry.ts create mode 100644 apps/server/src/services/project-sessions.ts create mode 100644 apps/server/tests/project-manager.test.ts create mode 100644 apps/server/tests/project-operations.test.ts create mode 100644 apps/server/tests/project-security.test.ts create mode 100644 apps/server/tests/project-sessions.test.ts create mode 100644 apps/webui/src/app/project-workbench.css create mode 100644 apps/webui/src/app/session-preview.css create mode 100644 apps/webui/src/lib/file-name.ts create mode 100644 apps/webui/src/lib/project-api.ts create mode 100644 apps/webui/src/session-preview/AgentSessionPreviewLauncher.tsx create mode 100644 apps/webui/src/session-preview/SessionPreviewPage.tsx create mode 100644 apps/webui/src/session-preview/SessionPreviewSidePanel.tsx create mode 100644 apps/webui/src/session-preview/errors.ts create mode 100644 apps/webui/src/session-preview/events.ts create mode 100644 apps/webui/src/session-preview/route.ts create mode 100644 apps/webui/src/session-preview/sidebar-data.ts create mode 100644 apps/webui/src/session-preview/useProjectSessionPreview.ts create mode 100644 apps/webui/tests/project-workbench.test.ts delete mode 100644 apps/webui/tests/role-prompt-focus.test.ts create mode 100644 apps/webui/tests/session-preview.test.ts diff --git a/.changeset/project-playground-debugger.md b/.changeset/project-playground-debugger.md new file mode 100644 index 0000000..e933de6 --- /dev/null +++ b/.changeset/project-playground-debugger.md @@ -0,0 +1,9 @@ +--- +"@openagentpack/sdk": minor +"@openagentpack/playground": minor +"@openagentpack/cli": minor +--- + +Replace the fixed Playbook showcase with an `agents.yaml` project debugger. Playground now watches project inputs, performs fingerprint-protected per-Agent Plan and Apply operations, streams operation and Session events, and manages explicit temporary attachment cleanup without mutating YAML or Deployment declarations. + +Add SDK source-path tracking, runtime-scoped Agent planning, stable plan fingerprints, and stale-plan enforcement. The CLI now launches Preview with `agents playground -f/--file [--agent ]`, exposes the project console separately through `agents workbench`, and falls back to the diagnostic Workbench for missing, invalid, empty, or unselected multi-Agent projects; the former `playground --provider` option is removed. diff --git a/README.md b/README.md index ddd6dbb..fa4c946 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ agents apply -y # apply changes agents destroy # tear down managed resources ``` -Run `agents playground` to launch the local WebUI, and use `--provider` to target `bailian`, `qoder`, `ark`, or `claude`. You can switch providers on the same declaration, run real sessions, and observe tool calls and artifacts. +Run `agents playground -f agents.yaml` to open an Agent directly in Preview; single-Agent projects are selected automatically, while multi-Agent projects accept `--agent ` or open the Workbench for selection. Use `agents workbench -f agents.yaml` to open the project console without creating a Session. Playground reads every Agent and Provider from YAML, watches local dependencies, and keeps YAML read-only while you Plan, Apply, run Sessions, and inspect events and artifacts. Missing, invalid, or empty projects open the diagnostic Workbench. ▶ [Watch the full Playground demo](https://github.com/user-attachments/assets/bf51b8d8-f2ed-464b-bca9-0709fefcc44d) @@ -186,14 +186,14 @@ See the [SDK reference](./docs/reference/sdk.md) for the public API surface. ## WebUI -`apps/webui` is a Vite single-page app for browsing playbooks and driving agent sessions; `apps/server` exposes the SDK over an OpenAPI surface. Run both from the repo root: +`apps/webui` is a Vite single-page project workbench for inspecting and debugging the Agents declared in `agents.yaml`; `apps/server` exposes the SDK over an OpenAPI surface. Run both from the repo root: ```bash bun install bun run dev # server + webui together ``` -Or launch a packaged local UI with `agents playground --provider `. +Or launch the packaged local UI with `agents playground -f ` for Preview or `agents workbench -f ` for the project console. Provider, model, tools, memory, skills, and resources come from YAML; the UI does not override them. Deployment declarations are displayed read-only. ## Contributing diff --git a/README.zh-CN.md b/README.zh-CN.md index b941e1e..dbf22bd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -84,7 +84,7 @@ agents apply -y # 执行变更 agents destroy # 销毁托管资源 ``` -运行 `agents playground` 可启动本地 WebUI,并通过 `--provider` 指定 `bailian`、`qoder`、`ark` 或 `claude`。你可以在同一份声明上切换 Provider、运行真实 Session,并观察工具调用和 Artifact。 +运行 `agents playground -f agents.yaml` 会直接打开 Agent Preview:单 Agent 项目自动选择,多 Agent 项目可传 `--agent `,未指定时进入 Workbench 选择。使用 `agents workbench -f agents.yaml` 可直接打开项目控制台且不创建 Session。Playground 从 YAML 读取全部 Agent 和 Provider,监听本地依赖文件;YAML 保持只读,你可以执行 Plan、Apply、Session,并查看实时事件和 Artifact。配置缺失、非法或没有 Agent 时进入诊断 Workbench。 ▶ [观看 Playground 完整演示](https://github.com/user-attachments/assets/bf51b8d8-f2ed-464b-bca9-0709fefcc44d) @@ -186,14 +186,14 @@ console.log(plan); ## WebUI -`apps/webui` 是一个 Vite 单页应用,用于浏览 playbook 和驱动 Agent Session;`apps/server` 通过 OpenAPI 暴露 SDK。从仓库根目录同时启动两者: +`apps/webui` 是一个 Vite 单页项目工作台,用于检查和调试 `agents.yaml` 中声明的 Agent;`apps/server` 通过 OpenAPI 暴露 SDK。从仓库根目录同时启动两者: ```bash bun install bun run dev # 同时启动 server + webui ``` -或用 `agents playground --provider ` 启动打包的本地 UI。 +也可以用 `agents playground -f ` 打开打包后的 Preview,或用 `agents workbench -f ` 打开项目控制台。Provider、模型、工具、memory、skills 和资源全部来自 YAML,UI 不提供覆盖;Deployment 声明仅只读展示。 ## 参与贡献 diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 1bc1a56..7c216e9 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -6,2499 +6,964 @@ }, "components": { "schemas": { - "AgentsConfigSnapshot": { + "ProjectSummary": { "type": "object", "properties": { - "AGENTS_PROVIDER": { + "status": { "type": "string", - "enum": ["bailian", "qoder", "ark", "claude"] - } - }, - "additionalProperties": { - "type": "string" - } - }, - "ErrorResponse": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["error"] - }, - "AgentsConfigReady": { - "type": "object", - "properties": { - "ready": { - "type": "boolean" + "enum": ["loading", "valid", "invalid", "missing"] }, - "provider": { - "type": "string", - "enum": ["bailian", "qoder", "ark", "claude"] - } - }, - "required": ["ready"] - }, - "AgentsConfig": { - "type": "object", - "properties": { - "AGENTS_PROVIDER": { - "type": "string", - "enum": ["bailian", "qoder", "ark", "claude"] - } - }, - "required": ["AGENTS_PROVIDER"], - "additionalProperties": { - "type": "string" - } - }, - "SaveAgentsConfigBody": { - "type": "object", - "properties": { - "AGENTS_PROVIDER": { - "type": "string", - "enum": ["bailian", "qoder", "ark", "claude"] - } - }, - "required": ["AGENTS_PROVIDER"], - "additionalProperties": { - "type": "string" - } - }, - "SessionListResponse": { - "type": "object", - "properties": { - "data": { + "config_file": { + "type": "string" + }, + "project_name": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "diagnostics": { "type": "array", "items": { "type": "object", "properties": { - "session_id": { - "type": "string" + "severity": { + "type": "string", + "enum": ["error", "warning", "info"] }, - "status": { + "code": { "type": "string" }, - "title": { + "message": { "type": "string" }, - "agent": { + "resource": { "type": "object", "properties": { - "agent_id": { - "type": "string" + "type": { + "type": "string", + "enum": [ + "environment", + "vault", + "memory_store", + "skill", + "agent", + "template", + "deployment", + "file", + "identity", + "channel" + ] }, "name": { "type": "string" }, - "version": { - "type": "number" + "provider": { + "type": "string" } }, - "additionalProperties": { - "nullable": true - } - }, - "environment_id": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "required": ["type", "name", "provider"] } }, - "required": ["session_id"] + "required": ["severity", "code", "message"] } }, - "next_page_token": { - "type": "string", - "nullable": true - } - }, - "required": ["data"] - }, - "SessionDetailResponse": { - "type": "object", - "properties": { - "session": { - "type": "object", - "properties": { - "session_id": { - "type": "string" - }, - "status": { - "type": "string" - }, - "title": { - "type": "string" - }, - "agent": { - "type": "object", - "properties": { - "agent_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "version": { - "type": "number" - } - }, - "additionalProperties": { - "nullable": true - } - }, - "environment_id": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["session_id"] - }, - "events": { + "agents": { "type": "array", "items": { "type": "object", "properties": { - "event_id": { - "type": "string" - }, - "type": { - "type": "string" - }, - "role": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "content": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "text": { + "agent": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "agentName": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "description": { + "type": "string" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + ] + }, + "environment": { + "type": "string" + }, + "tools": { + "nullable": true + }, + "skills": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["custom", "official"] + }, + "id": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": ["type", "id"] + } + }, + "mcpServers": { + "type": "array", + "items": { "type": "string" - }, - "data": { - "nullable": true } }, - "required": ["type"], - "additionalProperties": { - "nullable": true + "metadata": { + "type": "object", + "additionalProperties": { + "nullable": true + } } - } + }, + "required": ["id", "agentName", "provider", "skills", "mcpServers"] }, - "metadata": { + "readiness": { "type": "object", - "additionalProperties": { - "nullable": true - } - }, - "is_error": { - "type": "boolean", - "nullable": true - }, - "code": { - "type": "string", - "nullable": true - }, - "message": { - "type": "string", - "nullable": true - } - }, - "required": ["type"] - } - }, - "events_next_page_token": { - "type": "string", - "nullable": true - } - }, - "required": ["session", "events"] - }, - "SessionDeleteResponse": { - "type": "object", - "properties": { - "session_id": { - "type": "string" - }, - "deleted": { - "type": "boolean" - } - }, - "required": ["session_id", "deleted"] - }, - "SessionEventsPageResponse": { - "type": "object", - "properties": { - "events": { - "type": "array", - "items": { - "type": "object", - "properties": { - "event_id": { - "type": "string" - }, - "type": { + "properties": { + "status": { + "type": "string", + "enum": ["ready", "missing", "creating", "updating", "invalid", "drifted", "unavailable", "error"] + }, + "agentId": { + "type": "string" + }, + "driftSeverity": { + "type": "string", + "enum": ["blocking", "non_blocking"] + }, + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "severity": { + "type": "string", + "enum": ["error", "warning", "info"] + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "resource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "environment", + "vault", + "memory_store", + "skill", + "agent", + "template", + "deployment", + "file", + "identity", + "channel" + ] + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + } + }, + "required": ["type", "name", "provider"] + } + }, + "required": ["severity", "code", "message"] + } + }, + "missing": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "environment", + "vault", + "memory_store", + "skill", + "agent", + "template", + "deployment", + "file", + "identity", + "channel" + ] + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + } + }, + "required": ["type", "name", "provider"] + } + }, + "plannedActions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "update", "delete", "no-op"] + }, + "address": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "environment", + "vault", + "memory_store", + "skill", + "agent", + "template", + "deployment", + "file", + "identity", + "channel" + ] + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + } + }, + "required": ["type", "name", "provider"] + }, + "previousAddress": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "environment", + "vault", + "memory_store", + "skill", + "agent", + "template", + "deployment", + "file", + "identity", + "channel" + ] + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + } + }, + "required": ["type", "name", "provider"] + }, + "reason": { + "type": "string" + }, + "driftKind": { + "type": "string", + "enum": ["none", "local", "remote", "both"] + }, + "readinessImpact": { + "type": "string", + "enum": ["none", "non_blocking", "blocking"] + }, + "changedPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "before": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "after": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "dependencies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "environment", + "vault", + "memory_store", + "skill", + "agent", + "template", + "deployment", + "file", + "identity", + "channel" + ] + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + } + }, + "required": ["type", "name", "provider"] + } + } + }, + "required": ["action", "address", "reason", "dependencies"] + } + } + }, + "required": ["status", "agentId", "diagnostics", "missing", "plannedActions"] + }, + "details": { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "vault": { + "type": "string" + }, + "memory_stores": { + "type": "array", + "items": { + "type": "string" + } + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "mount_path": { + "type": "string" + } + }, + "required": ["type"] + } + } + }, + "required": ["memory_stores", "resources"] + } + }, + "required": ["agent", "readiness", "details"] + } + }, + "deployments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "role": { + "agent": { "type": "string" }, - "created_at": { + "provider": { "type": "string" }, - "content": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "text": { - "type": "string" - }, - "data": { - "nullable": true - } + "description": { + "type": "string" + }, + "schedule": { + "type": "object", + "properties": { + "expression": { + "type": "string" }, - "required": ["type"], - "additionalProperties": { - "nullable": true + "timezone": { + "type": "string" } - } + }, + "required": ["expression", "timezone"] }, - "metadata": { - "type": "object", - "additionalProperties": { - "nullable": true + "initial_event_types": { + "type": "array", + "items": { + "type": "string" } }, - "is_error": { - "type": "boolean", - "nullable": true - }, - "code": { - "type": "string", - "nullable": true - }, - "message": { - "type": "string", - "nullable": true + "resource_types": { + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["type"] + "required": ["id", "agent", "initial_event_types", "resource_types"] } - }, - "events_next_page_token": { - "type": "string", - "nullable": true } }, - "required": ["events"] + "required": ["status", "config_file", "project_name", "diagnostics", "agents", "deployments"] }, - "ProviderFileInfo": { + "ErrorResponse": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "mime_type": { - "type": "string" - }, - "size_bytes": { - "type": "number" - }, - "created_at": { - "type": "string" - }, - "downloadable": { - "type": "boolean" - }, - "status": { - "type": "string" - }, - "purpose": { - "type": "string" - }, - "available": { - "type": "boolean" + "error": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] } }, - "required": ["id", "filename", "mime_type", "size_bytes", "created_at"] + "required": ["error"] }, - "ProviderSkillInfo": { + "AgentPlanResponse": { "type": "object", "properties": { - "id": { + "agent_id": { "type": "string" }, - "name": { + "provider": { "type": "string" }, - "description": { + "project_revision": { "type": "string" }, - "source": { - "type": "string", - "enum": ["custom", "official"] - }, - "status": { - "type": "string", - "enum": ["checking", "active", "rejected", "deleted"] - }, - "latest_version": { + "plan_token": { "type": "string" }, - "created_at": { + "expires_at": { "type": "string" }, - "updated_at": { + "fingerprint": { "type": "string" - } - }, - "required": ["id", "name", "source", "status"] - } - }, - "parameters": {} - }, - "paths": { - "/api/config": { - "get": { - "responses": { - "200": { - "description": "Read local OpenAgentPack playground config (~/.agents/config.json)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentsConfigSnapshot" - } - } - } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "put": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SaveAgentsConfigBody" - } - } - } - }, - "responses": { - "200": { - "description": "Save local OpenAgentPack playground config (~/.agents/config.json)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentsConfig" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/config/ready": { - "get": { - "responses": { - "200": { - "description": "Whether runtime provider credentials are configured in the server process", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentsConfigReady" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/deployments": { - "get": { - "responses": { - "200": { - "description": "List managed deployments", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "deployments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "playbookId": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "prompt": { - "type": "string" - }, - "schedule": { - "type": "object", - "properties": { - "expression": { - "type": "string" - }, - "timezone": { - "type": "string" - } - }, - "required": ["expression", "timezone"] - }, - "status": { - "type": "string" - }, - "remoteId": { - "type": "string", - "nullable": true - } - }, - "required": ["id", "name", "playbookId", "provider", "prompt", "schedule", "status", "remoteId"] - } - } - }, - "required": ["deployments"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1 - }, - "playbookId": { - "type": "string", - "minLength": 1 - }, - "prompt": { - "type": "string", - "minLength": 1 - }, - "expression": { - "type": "string", - "minLength": 1 - }, - "timezone": { - "type": "string", - "minLength": 1, - "default": "Asia/Shanghai" - } - }, - "required": ["name", "playbookId", "prompt", "expression"] - } - } - } - }, - "responses": { - "201": { - "description": "Create a native deployment", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "playbookId": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "prompt": { - "type": "string" - }, - "schedule": { - "type": "object", - "properties": { - "expression": { - "type": "string" - }, - "timezone": { - "type": "string" - } - }, - "required": ["expression", "timezone"] - }, - "status": { - "type": "string" - }, - "remoteId": { - "type": "string", - "nullable": true - } - }, - "required": ["id", "name", "playbookId", "provider", "prompt", "schedule", "status", "remoteId"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/deployments/{id}/paused": { - "put": { - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1 - }, - "required": true, - "name": "id", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "paused": { - "type": "boolean" - } - }, - "required": ["paused"] - } - } - } - }, - "responses": { - "200": { - "description": "Pause or resume a deployment", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string" - } - }, - "required": ["id", "status"], - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/deployments/{id}/runs": { - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1 - }, - "required": true, - "name": "id", - "in": "path" - } - ], - "responses": { - "201": { - "description": "Trigger a deployment run", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "result": { - "type": "object", - "properties": { - "run_id": { - "type": "string" - }, - "session_id": { - "type": "string", - "nullable": true - }, - "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"] - } - }, - "required": ["session_id"] - } - }, - "required": ["name", "provider", "result"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/deployments/{id}": { - "delete": { - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1 - }, - "required": true, - "name": "id", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Delete a deployment", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "deleted": { - "type": "boolean" - } - }, - "required": ["deleted"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/agents": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": false, - "name": "agentId", - "in": "query" - } - ], - "responses": { - "200": { - "description": "List agents with readiness", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "agents": { - "type": "array", - "items": { - "type": "object", - "properties": { - "agent": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "agentName": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "description": { - "type": "string" - }, - "model": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - ] - }, - "environment": { - "type": "string" - }, - "tools": { - "nullable": true - }, - "skills": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["custom", "official"] - }, - "id": { - "type": "string" - }, - "version": { - "type": "string" - } - }, - "required": ["type", "id"] - } - }, - "mcpServers": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - }, - "required": ["id", "agentName", "provider", "skills", "mcpServers"] - }, - "readiness": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "ready", - "missing", - "creating", - "updating", - "invalid", - "drifted", - "unavailable", - "error" - ] - }, - "agentId": { - "type": "string" - }, - "driftSeverity": { - "type": "string", - "enum": ["blocking", "non_blocking"] - }, - "diagnostics": { - "type": "array", - "items": { - "type": "object", - "properties": { - "severity": { - "type": "string", - "enum": ["error", "warning", "info"] - }, - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "resource": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "environment", - "vault", - "memory_store", - "skill", - "agent", - "template", - "deployment", - "file", - "identity", - "channel" - ] - }, - "name": { - "type": "string" - }, - "provider": { - "type": "string" - } - }, - "required": ["type", "name", "provider"] - } - }, - "required": ["severity", "code", "message"] - } - }, - "missing": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "environment", - "vault", - "memory_store", - "skill", - "agent", - "template", - "deployment", - "file", - "identity", - "channel" - ] - }, - "name": { - "type": "string" - }, - "provider": { - "type": "string" - } - }, - "required": ["type", "name", "provider"] - } - }, - "plannedActions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["create", "update", "delete", "no-op"] - }, - "address": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "environment", - "vault", - "memory_store", - "skill", - "agent", - "template", - "deployment", - "file", - "identity", - "channel" - ] - }, - "name": { - "type": "string" - }, - "provider": { - "type": "string" - } - }, - "required": ["type", "name", "provider"] - }, - "previousAddress": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "environment", - "vault", - "memory_store", - "skill", - "agent", - "template", - "deployment", - "file", - "identity", - "channel" - ] - }, - "name": { - "type": "string" - }, - "provider": { - "type": "string" - } - }, - "required": ["type", "name", "provider"] - }, - "reason": { - "type": "string" - }, - "driftKind": { - "type": "string", - "enum": ["none", "local", "remote", "both"] - }, - "readinessImpact": { - "type": "string", - "enum": ["none", "non_blocking", "blocking"] - }, - "changedPaths": { - "type": "array", - "items": { - "type": "string" - } - }, - "before": { - "type": "object", - "additionalProperties": { - "nullable": true - } - }, - "after": { - "type": "object", - "additionalProperties": { - "nullable": true - } - }, - "dependencies": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "environment", - "vault", - "memory_store", - "skill", - "agent", - "template", - "deployment", - "file", - "identity", - "channel" - ] - }, - "name": { - "type": "string" - }, - "provider": { - "type": "string" - } - }, - "required": ["type", "name", "provider"] - } - } - }, - "required": ["action", "address", "reason", "dependencies"] - } - } - }, - "required": ["status", "agentId", "diagnostics", "missing", "plannedActions"] - } - }, - "required": ["agent", "readiness"] - } - } - }, - "required": ["agents"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/cloud-agents": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": false, - "name": "prefix", - "in": "query" - } - ], - "responses": { - "200": { - "description": "List raw cloud agents (the resource center's source of truth)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "agents": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "model": { - "nullable": true - }, - "system": { - "type": "string" - }, - "tools": { - "nullable": true - }, - "skills": { - "nullable": true - }, - "mcp_servers": { - "nullable": true - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "version": { - "type": "number" - }, - "type": { - "type": "string" - }, - "workspace_id": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "archived_at": { - "type": "string", - "nullable": true - } - }, - "required": ["id"] - } - } - }, - "required": ["agents"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/cloud-agents/{agentId}/archive": { - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1 - }, - "required": true, - "name": "agentId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Archive a cloud agent (soft delete → status=archived)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - } - }, - "required": ["ok"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/cloud-agents/{agentId}": { - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1 - }, - "required": true, - "name": "agentId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "model": { - "type": "string", - "minLength": 1 - } - }, - "required": ["model"] - } - } - } - }, - "responses": { - "200": { - "description": "Update a playbook agent's config (model switch → sync-override)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - } - }, - "required": ["ok"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/environments": { - "get": { - "responses": { - "200": { - "description": "List raw cloud environments (the shared base sandbox resource)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "environments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "config": { - "nullable": true - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "scope": { - "type": "string" - }, - "version": { - "type": "number" - }, - "type": { - "type": "string" - }, - "workspace_id": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "archived_at": { - "type": "string", - "nullable": true - } - }, - "required": ["id"] - } - } - }, - "required": ["environments"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1 - }, - "description": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["name"] - } - } - } - }, - "responses": { - "200": { - "description": "Create a base cloud environment (cloud + unrestricted networking)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "environment": { - "type": "object", - "properties": { - "id": { - "type": "string", - "nullable": true - }, - "type": { - "type": "string" - }, - "version": { - "type": "number" - } - }, - "required": ["id", "type"] - } - }, - "required": ["environment"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/environments/{environmentId}": { - "delete": { - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1 - }, - "required": true, - "name": "environmentId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Delete a cloud environment by remote id", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": ["id", "type"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/vaults": { - "get": { - "responses": { - "200": { - "description": "List raw cloud vaults (the shared credential store resource)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "vaults": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "type": { - "type": "string" - }, - "created_at": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "nullable": true - }, - "archived_at": { - "type": "string", - "nullable": true - } - }, - "required": ["id"] - } - } - }, - "required": ["vaults"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1 - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "key": { - "type": "string", - "minLength": 1 - } + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "update", "delete", "no-op"] }, - "required": ["name"] - } - } - } - }, - "responses": { - "200": { - "description": "Create a base cloud vault holding the user-supplied DASHSCOPE_API_KEY", - "content": { - "application/json": { - "schema": { + "address": { "type": "object", "properties": { - "id": { + "type": { "type": "string", - "nullable": true + "enum": [ + "environment", + "vault", + "memory_store", + "skill", + "agent", + "template", + "deployment", + "file", + "identity", + "channel" + ] }, - "type": { + "name": { "type": "string" }, - "version": { - "type": "number" + "provider": { + "type": "string" } }, - "required": ["id", "type"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/vaults/{vaultId}": { - "delete": { - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1 - }, - "required": true, - "name": "vaultId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Delete a cloud vault by remote id", - "content": { - "application/json": { - "schema": { + "required": ["type", "name", "provider"] + }, + "previousAddress": { "type": "object", "properties": { - "id": { + "type": { + "type": "string", + "enum": [ + "environment", + "vault", + "memory_store", + "skill", + "agent", + "template", + "deployment", + "file", + "identity", + "channel" + ] + }, + "name": { "type": "string" }, - "type": { + "provider": { "type": "string" } }, - "required": ["id", "type"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/sessions": { - "get": { - "parameters": [ - { - "schema": { - "type": "integer", - "nullable": true - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "agentId", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "pageToken", - "in": "query" - } - ], - "responses": { - "200": { - "description": "List sessions for an agent", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionListResponse" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "required": ["type", "name", "provider"] + }, + "reason": { + "type": "string" + }, + "driftKind": { + "type": "string", + "enum": ["none", "local", "remote", "both"] + }, + "readinessImpact": { + "type": "string", + "enum": ["none", "non_blocking", "blocking"] + }, + "changedPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "before": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "after": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "dependencies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "environment", + "vault", + "memory_store", + "skill", + "agent", + "template", + "deployment", + "file", + "identity", + "channel" + ] + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + } + }, + "required": ["type", "name", "provider"] + } } - } + }, + "required": ["action", "address", "reason", "dependencies"] } }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "severity": { + "type": "string", + "enum": ["error", "warning", "info"] + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "resource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "environment", + "vault", + "memory_store", + "skill", + "agent", + "template", + "deployment", + "file", + "identity", + "channel" + ] + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + } + }, + "required": ["type", "name", "provider"] } - } + }, + "required": ["severity", "code", "message"] } }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "destructive": { + "type": "boolean" + } + }, + "required": [ + "agent_id", + "provider", + "project_revision", + "plan_token", + "expires_at", + "fingerprint", + "actions", + "diagnostics", + "destructive" + ] + }, + "AgentApplyResponse": { + "type": "object", + "properties": { + "operation_id": { + "type": "string" }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "status": { + "type": "string", + "enum": ["queued"] } - } + }, + "required": ["operation_id", "status"] }, - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { + "CreateProjectSessionResponse": { + "type": "object", + "properties": { + "session": { + "type": "object", + "properties": { + "session_id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "agent": { "type": "object", "properties": { - "agentId": { + "agent_id": { "type": "string" }, - "prompt": { - "type": "string", - "minLength": 1 - }, - "environmentId": { - "type": "string", - "minLength": 1 + "name": { + "type": "string" }, - "vaultIds": { - "type": "array", - "items": { - "type": "string" + "version": { + "type": "number" + } + }, + "additionalProperties": { + "nullable": true + } + }, + "environment_id": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["session_id"] + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "role": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "text": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["type"], + "additionalProperties": { + "nullable": true } - }, - "title": { + } + }, + "metadata": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "is_error": { + "type": "boolean", + "nullable": true + }, + "code": { + "type": "string", + "nullable": true + }, + "message": { + "type": "string", + "nullable": true + } + }, + "required": ["type"] + } + }, + "provider": { + "type": "string" + }, + "agent_id": { + "type": "string" + }, + "agent_name": { + "type": "string" + }, + "agent_details": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "agentName": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "description": { + "type": "string" + }, + "model": { + "anyOf": [ + { "type": "string" }, - "files": { - "type": "array", - "items": { - "type": "object", - "properties": { - "fileId": { - "type": "string" - }, - "mountPath": { - "type": "string" - } - }, - "required": ["fileId", "mountPath"] + { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + ] + }, + "environment": { + "type": "string" + }, + "tools": { + "nullable": true + }, + "skills": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["custom", "official"] + }, + "id": { + "type": "string" + }, + "version": { + "type": "string" } }, - "model": { - "type": "string" - } - }, - "required": ["agentId", "prompt", "environmentId"] + "required": ["type", "id"] + } + }, + "mcpServers": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "additionalProperties": { + "nullable": true + } } - } + }, + "required": ["id", "agentName", "provider", "skills", "mcpServers"] } }, - "responses": { - "201": { - "description": "Session created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionDetailResponse" - } - } - } + "required": ["session", "events", "provider", "agent_id", "agent_name", "agent_details"] + }, + "ProjectSessionArtifactDownload": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "expires_at": { + "type": "string" + } + }, + "required": ["url"] + }, + "OperationResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "type": { + "type": "string", + "enum": ["agent.apply"] }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "agent_id": { + "type": "string" }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "status": { + "type": "string", + "enum": ["queued", "running", "completed", "failed", "interrupted"] + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "minimum": 0 + }, + "type": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "data": { + "nullable": true } - } + }, + "required": ["index", "type", "timestamp"] } + }, + "result": { + "nullable": true + }, + "error": { + "type": "string" } - } + }, + "required": ["id", "type", "agent_id", "status", "created_at", "updated_at", "events"] } }, - "/api/sessions/{sessionId}": { + "parameters": {} + }, + "paths": { + "/api/project": { "get": { "parameters": [ { "schema": { - "type": "string" - }, - "required": true, - "name": "sessionId", - "in": "path" - }, - { - "schema": { - "type": "string" + "type": "string", + "enum": ["true", "false"] }, "required": false, - "name": "agentId", + "name": "refresh", "in": "query" } ], "responses": { "200": { - "description": "Session detail with events", + "description": "Current agents.yaml project, validation, readiness, and deployment declarations", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionDetailResponse" + "$ref": "#/components/schemas/ProjectSummary" } } } @@ -2533,6 +998,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2544,33 +1019,17 @@ } } } - }, - "delete": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "sessionId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "agentId", - "in": "query" - } - ], + } + }, + "/api/project/events": { + "get": { "responses": { "200": { - "description": "Session deleted", + "description": "Project reload and validation events", "content": { - "application/json": { + "text/event-stream": { "schema": { - "$ref": "#/components/schemas/SessionDeleteResponse" + "type": "string" } } } @@ -2605,6 +1064,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2618,50 +1087,40 @@ } } }, - "/api/sessions/{sessionId}/events": { - "get": { + "/api/project/agents/{agentId}/plan": { + "post": { "parameters": [ { "schema": { - "type": "string" + "type": "string", + "minLength": 1 }, "required": true, - "name": "sessionId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": false, "name": "agentId", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "pageToken", - "in": "query" - }, - { - "schema": { - "type": "integer", - "nullable": true - }, - "required": false, - "name": "limit", - "in": "query" + "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "refresh": { + "type": "boolean" + } + } + } + } + } + }, "responses": { "200": { - "description": "Paginated session events (newest page first; pass pageToken for older pages)", + "description": "Scoped plan for one Agent and its runtime dependencies", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionEventsPageResponse" + "$ref": "#/components/schemas/AgentPlanResponse" } } } @@ -2696,6 +1155,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2709,15 +1178,16 @@ } } }, - "/api/sessions/{sessionId}/messages": { + "/api/project/agents/{agentId}/apply": { "post": { "parameters": [ { "schema": { - "type": "string" + "type": "string", + "minLength": 1 }, "required": true, - "name": "sessionId", + "name": "agentId", "in": "path" } ], @@ -2727,26 +1197,26 @@ "schema": { "type": "object", "properties": { - "agentId": { - "type": "string" - }, - "message": { + "plan_token": { "type": "string", "minLength": 1 + }, + "confirm_destructive": { + "type": "boolean" } }, - "required": ["message"] + "required": ["plan_token"] } } } }, "responses": { - "200": { - "description": "Message sent; updated session with events", + "202": { + "description": "Agent apply accepted as an asynchronous operation", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionDetailResponse" + "$ref": "#/components/schemas/AgentApplyResponse" } } } @@ -2781,6 +1251,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2794,42 +1274,53 @@ } } }, - "/api/sessions/{sessionId}/stream": { - "get": { + "/api/project/agents/{agentId}/sessions": { + "post": { "parameters": [ { "schema": { - "type": "string" + "type": "string", + "minLength": 1 }, "required": true, - "name": "sessionId", + "name": "agentId", "in": "path" - }, - { - "schema": { - "type": "integer", - "nullable": true - }, - "required": false, - "name": "after", - "in": "query" } - ], + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "prompt": { + "type": "string" + }, + "title": { + "type": "string" + }, + "attachment_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, "responses": { - "200": { - "description": "Stream session events as Server-Sent Events", + "201": { + "description": "Session created from the selected agents.yaml Agent", "content": { - "text/event-stream": { + "application/json": { "schema": { - "type": "string", - "description": "SSE frames with event types \"event\", \"done\", and \"ping\"." + "$ref": "#/components/schemas/CreateProjectSessionResponse" } } } }, - "204": { - "description": "No active event buffer; caller should fetch the session detail once" - }, "400": { "description": "Bad request", "content": { @@ -2860,6 +1351,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2873,33 +1374,42 @@ } } }, - "/api/files": { + "/api/sessions/{sessionId}/messages": { "post": { - "operationId": "uploadFile", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "sessionId", + "in": "path" + } + ], "requestBody": { - "required": true, "content": { - "multipart/form-data": { + "application/json": { "schema": { "type": "object", "properties": { - "file": { + "message": { "type": "string", - "format": "binary" + "minLength": 1 } }, - "required": ["file"] + "required": ["message"] } } } }, "responses": { - "201": { - "description": "Upload a workspace file", + "200": { + "description": "Follow up in a project Session using its pinned runtime", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderFileInfo" + "$ref": "#/components/schemas/CreateProjectSessionResponse" } } } @@ -2934,8 +1444,8 @@ } } }, - "413": { - "description": "File exceeds the upload size limit", + "422": { + "description": "Unprocessable entity", "content": { "application/json": { "schema": { @@ -2955,25 +1465,28 @@ } } } - }, + } + }, + "/api/sessions/{sessionId}": { "get": { - "operationId": "listFiles", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "sessionId", + "in": "path" + } + ], "responses": { "200": { - "description": "List workspace files", + "description": "Project Session detail, history, and artifacts carried by events", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProviderFileInfo" - } - } - }, - "required": ["files"] + "$ref": "#/components/schemas/CreateProjectSessionResponse" } } } @@ -3008,6 +1521,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3021,56 +1544,35 @@ } } }, - "/api/files/status": { - "post": { - "operationId": "getFileStatuses", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "fileIds": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["fileIds"] - } - } + "/api/sessions/{sessionId}/artifacts/{fileId}/download": { + "get": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "sessionId", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "fileId", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Get file scan statuses", + "description": "Resolve a short-lived download URL for an artifact delivered by this Session", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "files": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "status": { - "type": "string" - }, - "available": { - "type": "boolean" - } - }, - "required": ["id"] - } - } - }, - "required": ["files"] + "$ref": "#/components/schemas/ProjectSessionArtifactDownload" } } } @@ -3105,6 +1607,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3118,9 +1630,8 @@ } } }, - "/api/files/{id}/download": { - "get": { - "operationId": "downloadFile", + "/api/sessions/{sessionId}/cancel": { + "post": { "parameters": [ { "schema": { @@ -3128,26 +1639,27 @@ "minLength": 1 }, "required": true, - "name": "id", + "name": "sessionId", "in": "path" } ], "responses": { "200": { - "description": "Resolve a short-lived file download URL", + "description": "Terminate/delete the provider Session", "content": { "application/json": { "schema": { "type": "object", "properties": { - "url": { + "session_id": { "type": "string" }, - "expires_at": { - "type": "string" + "cancelled": { + "type": "boolean", + "enum": [true] } }, - "required": ["url"] + "required": ["session_id", "cancelled"] } } } @@ -3182,6 +1694,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3195,9 +1717,8 @@ } } }, - "/api/files/{id}": { - "delete": { - "operationId": "deleteFile", + "/api/sessions/{sessionId}/events": { + "get": { "parameters": [ { "schema": { @@ -3205,13 +1726,32 @@ "minLength": 1 }, "required": true, - "name": "id", + "name": "sessionId", "in": "path" + }, + { + "schema": { + "type": "integer", + "nullable": true + }, + "required": false, + "name": "after", + "in": "query" } ], "responses": { + "200": { + "description": "Replay and stream Session events", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, "204": { - "description": "File deleted" + "description": "Session is unavailable" }, "400": { "description": "Bad request", @@ -3243,6 +1783,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3256,9 +1806,19 @@ } } }, - "/api/skills/upload-file": { + "/api/project/agents/{agentId}/attachments": { "post": { - "operationId": "uploadSkillFile", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "agentId", + "in": "path" + } + ], "requestBody": { "required": true, "content": { @@ -3278,57 +1838,47 @@ }, "responses": { "201": { - "description": "Upload a skill archive as a file pending audit", + "description": "Ad-hoc Session attachment uploaded", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderFileInfo" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "413": { - "description": "Skill archive exceeds the upload size limit", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "agent_id": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "remote_file_id": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "mime_type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "available": { + "type": "boolean" + }, + "created_at": { + "type": "string" + } + }, + "required": ["id", "agent_id", "provider", "remote_file_id", "filename", "available", "created_at"] } } } }, - "500": { - "description": "Server error", + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { @@ -3336,43 +1886,19 @@ } } } - } - } - } - }, - "/api/skills": { - "post": { - "operationId": "createSkill", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "fileId": { - "type": "string", - "minLength": 1 - } - }, - "required": ["fileId"] - } - } - } - }, - "responses": { - "201": { - "description": "Create a skill from an audited file", + }, + "404": { + "description": "Not found", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderSkillInfo" + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "400": { - "description": "Bad request", + "409": { + "description": "Conflict", "content": { "application/json": { "schema": { @@ -3381,8 +1907,8 @@ } } }, - "404": { - "description": "Not found", + "413": { + "description": "File exceeds the upload limit", "content": { "application/json": { "schema": { @@ -3391,8 +1917,8 @@ } } }, - "409": { - "description": "Conflict", + "422": { + "description": "Unprocessable entity", "content": { "application/json": { "schema": { @@ -3414,34 +1940,71 @@ } }, "get": { - "operationId": "listSkills", "parameters": [ { "schema": { "type": "string", - "enum": ["custom", "official"] + "minLength": 1 }, - "required": false, - "name": "source", - "in": "query" + "required": true, + "name": "agentId", + "in": "path" } ], "responses": { "200": { - "description": "List custom or official skills", + "description": "List ad-hoc attachments", "content": { "application/json": { "schema": { "type": "object", "properties": { - "skills": { + "attachments": { "type": "array", "items": { - "$ref": "#/components/schemas/ProviderSkillInfo" + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "agent_id": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "remote_file_id": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "mime_type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "available": { + "type": "boolean" + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "id", + "agent_id", + "provider", + "remote_file_id", + "filename", + "available", + "created_at" + ] } } }, - "required": ["skills"] + "required": ["attachments"] } } } @@ -3476,6 +2039,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3489,44 +2062,36 @@ } } }, - "/api/skills/warm": { - "post": { - "operationId": "warmSkill", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1 - }, - "url": { - "type": "string", - "minLength": 1 - } - }, - "required": ["name", "url"] - } - } + "/api/attachments/{attachmentId}": { + "delete": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "attachmentId", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Warm a custom skill until it is active", + "description": "Remote attachment deleted", "content": { "application/json": { "schema": { "type": "object", "properties": { - "ok": { + "attachment_id": { + "type": "string" + }, + "deleted": { "type": "boolean", "enum": [true] } }, - "required": ["ok"] + "required": ["attachment_id", "deleted"] } } } @@ -3561,6 +2126,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3574,54 +2149,26 @@ } } }, - "/api/skills/status": { - "post": { - "operationId": "getSkillStatuses", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "skillIds": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["skillIds"] - } - } + "/api/operations/{operationId}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "operationId", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Get skill scan statuses", + "description": "Current asynchronous operation state", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "skills": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["checking", "active", "rejected", "deleted"] - } - }, - "required": ["id"] - } - } - }, - "required": ["skills"] + "$ref": "#/components/schemas/OperationResponse" } } } @@ -3656,6 +2203,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3669,9 +2226,8 @@ } } }, - "/api/skills/{id}": { - "delete": { - "operationId": "deleteSkill", + "/api/operations/{operationId}/events": { + "get": { "parameters": [ { "schema": { @@ -3679,13 +2235,29 @@ "minLength": 1 }, "required": true, - "name": "id", + "name": "operationId", "in": "path" + }, + { + "schema": { + "type": "integer", + "nullable": true + }, + "required": false, + "name": "after", + "in": "query" } ], "responses": { - "204": { - "description": "Skill deleted" + "200": { + "description": "Replay and stream asynchronous operation events", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } }, "400": { "description": "Bad request", @@ -3717,78 +2289,8 @@ } } }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/models": { - "get": { - "responses": { - "200": { - "description": "List the active provider's available models", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "models": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "is_enabled": { - "type": "boolean" - }, - "is_new": { - "type": "boolean" - } - }, - "required": ["id", "display_name"] - } - } - }, - "required": ["models"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", + "422": { + "description": "Unprocessable entity", "content": { "application/json": { "schema": { diff --git a/apps/server/package.json b/apps/server/package.json index d8f0ae3..0a3db8e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,11 +16,12 @@ }, "dependencies": { "@hono/zod-openapi": "^1.4.0", - "@openagentpack/playbooks": "workspace:*", "@openagentpack/sdk": "workspace:*", + "chokidar": "^4.0.3", "hono": "^4.12.28" }, "devDependencies": { + "@openagentpack/playbooks": "workspace:*", "@types/bun": "^1.3.14", "@types/node": "^25.9.3", "typescript": "^6.0.3" diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 9432e38..bca92bd 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,15 +1,10 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import { cors } from "hono/cors"; import { jsonError } from "@/lib/http-error"; -import { agentsRoute } from "@/routes/agents"; -import { configRoute } from "@/routes/config"; -import { deploymentsRoute } from "@/routes/deployments"; -import { environmentsRoute } from "@/routes/environments"; -import { filesRoute } from "@/routes/files"; -import { modelsRoute } from "@/routes/models"; -import { sessionsRoute } from "@/routes/sessions"; -import { skillsRoute } from "@/routes/skills"; -import { vaultsRoute } from "@/routes/vaults"; +import { operationsRoute } from "@/routes/operations"; +import { projectRoute } from "@/routes/project"; +import { projectSessionsRoute } from "@/routes/project-sessions"; +import { projectRuntimeManager } from "@/services/project-manager"; export const app = new OpenAPIHono(); @@ -18,22 +13,30 @@ app.use( "/*", cors({ origin: process.env.CORS_ORIGIN?.split(",") ?? ["http://localhost:3000"], - allowMethods: ["GET", "POST", "PUT", "OPTIONS"], - allowHeaders: ["Content-Type"], + allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], + allowHeaders: ["Content-Type", "X-Agents-Playground-Token"], maxAge: 86400, }), ); +app.use("/api/*", async (context, next) => { + const expected = process.env.AGENTS_PLAYGROUND_TOKEN?.trim(); + if ( + expected && + context.req.method !== "GET" && + context.req.method !== "HEAD" && + context.req.method !== "OPTIONS" && + context.req.header("X-Agents-Playground-Token") !== expected + ) { + return context.json({ error: { message: "Invalid Playground access token." } }, 403); + } + await next(); +}); + // Routes -app.route("/api", configRoute); -app.route("/api", deploymentsRoute); -app.route("/api", agentsRoute); -app.route("/api", environmentsRoute); -app.route("/api", vaultsRoute); -app.route("/api", sessionsRoute); -app.route("/api", filesRoute); -app.route("/api", skillsRoute); -app.route("/api", modelsRoute); +app.route("/api", projectRoute); +app.route("/api", projectSessionsRoute); +app.route("/api", operationsRoute); // OpenAPI document app.doc("/openapi.json", { @@ -45,7 +48,12 @@ app.doc("/openapi.json", { }); // Health check -app.get("/health", (c) => c.json({ status: "ok" })); +app.get("/health", (c) => + c.json({ + status: "ok", + project: { id: projectRuntimeManager.projectId, config_path: projectRuntimeManager.configPath }, + }), +); // Centralized error formatting: routes throw, this maps to { error: { message } }. app.onError((error) => jsonError(error)); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 2db3c33..efb6fb6 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -4,4 +4,4 @@ import { app } from "@/app"; const port = Number(process.env.PORT ?? 4000); console.log(`server listening on :${port}`); -export default { port, fetch: app.fetch, idleTimeout: 0 }; +export default { port, hostname: "127.0.0.1", fetch: app.fetch, idleTimeout: 0 }; diff --git a/apps/server/src/routes/operations.ts b/apps/server/src/routes/operations.ts new file mode 100644 index 0000000..e2475f6 --- /dev/null +++ b/apps/server/src/routes/operations.ts @@ -0,0 +1,96 @@ +import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; +import { errorResponses } from "@/schemas/common"; +import { OperationParamsSchema, OperationResponseSchema, StreamAfterQuerySchema } from "@/schemas/project"; +import { type OperationEvent, projectOperationStore } from "@/services/project-operations"; + +export const operationsRoute = new OpenAPIHono(); + +const getOperationRoute = createRoute({ + method: "get", + path: "/operations/{operationId}", + request: { params: OperationParamsSchema }, + responses: { + 200: { + description: "Current asynchronous operation state", + content: { "application/json": { schema: OperationResponseSchema } }, + }, + ...errorResponses, + }, +}); + +operationsRoute.openapi(getOperationRoute, (context) => { + const { operationId } = context.req.valid("param"); + return context.json(projectOperationStore.get(operationId), 200); +}); + +const streamOperationRoute = createRoute({ + method: "get", + path: "/operations/{operationId}/events", + request: { params: OperationParamsSchema, query: StreamAfterQuerySchema }, + responses: { + 200: { + description: "Replay and stream asynchronous operation events", + content: { "text/event-stream": { schema: z.string() } }, + }, + ...errorResponses, + }, +}); + +operationsRoute.openapi(streamOperationRoute, (context) => { + const { operationId } = context.req.valid("param"); + const { after } = context.req.valid("query"); + const lastEventId = Number(context.req.header("Last-Event-ID")); + const replayAfter = Number.isInteger(lastEventId) ? Math.max(after ?? -1, lastEventId) : (after ?? -1); + const operation = projectOperationStore.get(operationId); + const encoder = new TextEncoder(); + let unsubscribe: (() => void) | undefined; + let ping: ReturnType | undefined; + let closed = false; + const stream = new ReadableStream({ + start(controller) { + const send = (type: string, data: unknown, id?: number) => { + if (closed) return; + controller.enqueue( + encoder.encode(`${id === undefined ? "" : `id: ${id}\n`}event: ${type}\ndata: ${JSON.stringify(data)}\n\n`), + ); + }; + const sendEvent = (event: OperationEvent) => send("event", event, event.index); + for (const event of operation.events.slice(replayAfter + 1)) sendEvent(event); + if (isTerminal(operation.status)) { + send("done", { status: operation.status, error: operation.error ?? null }); + closed = true; + controller.close(); + return; + } + unsubscribe = projectOperationStore.subscribe(operationId, (event) => { + if (event) sendEvent(event); + else { + const latest = projectOperationStore.get(operationId); + send("done", { status: latest.status, error: latest.error ?? null }); + closed = true; + unsubscribe?.(); + if (ping) clearInterval(ping); + controller.close(); + } + }); + ping = setInterval(() => send("ping", {}), 15_000); + }, + cancel() { + closed = true; + unsubscribe?.(); + if (ping) clearInterval(ping); + }, + }); + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +}); + +function isTerminal(status: string): boolean { + return status === "completed" || status === "failed" || status === "interrupted"; +} diff --git a/apps/server/src/routes/project-sessions.ts b/apps/server/src/routes/project-sessions.ts new file mode 100644 index 0000000..5b55c33 --- /dev/null +++ b/apps/server/src/routes/project-sessions.ts @@ -0,0 +1,357 @@ +import { randomUUID } from "node:crypto"; +import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; +import { + deleteFile, + getAgent, + getFileInfo, + readProjectRuntime, + type SessionEvent, + uploadFile, +} from "@openagentpack/sdk"; +import { ErrorResponseSchema, errorResponses } from "@/schemas/common"; +import { UploadFileFormSchema } from "@/schemas/files"; +import { + AttachmentDeleteResponseSchema, + AttachmentListResponseSchema, + AttachmentParamsSchema, + AttachmentSchema, + CreateProjectSessionBodySchema, + CreateProjectSessionResponseSchema, + ProjectAgentParamsSchema, + ProjectSessionArtifactDownloadSchema, + ProjectSessionArtifactParamsSchema, + ProjectSessionParamsSchema, + SendProjectSessionMessageBodySchema, + StreamAfterQuerySchema, +} from "@/schemas/project"; +import { projectRuntimeManager } from "@/services/project-manager"; +import { projectRuntimeRegistry } from "@/services/project-runtime-registry"; +import { + cancelProjectSession, + getProjectSessionArtifactDownload, + getProjectSessionDetail, + reconstructProjectSessionBuffer, + sendProjectSessionMessage, + startProjectSession, +} from "@/services/project-sessions"; +import { getEventBuffer, subscribeEvents } from "@/services/sessions/event-buffer"; +import { sanitizeSessionEvent, sanitizeSessionEvents } from "@/services/sessions/event-sanitizer"; + +export const projectSessionsRoute = new OpenAPIHono(); +const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; + +const createSessionRoute = createRoute({ + method: "post", + path: "/project/agents/{agentId}/sessions", + request: { + params: ProjectAgentParamsSchema, + body: { content: { "application/json": { schema: CreateProjectSessionBodySchema } } }, + }, + responses: { + 201: { + description: "Session created from the selected agents.yaml Agent", + content: { "application/json": { schema: CreateProjectSessionResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectSessionsRoute.openapi(createSessionRoute, async (context) => { + const { agentId } = context.req.valid("param"); + const { prompt, title, attachment_ids: attachmentIds } = context.req.valid("json"); + const result = await startProjectSession({ + agentId, + prompt: prompt?.trim(), + title, + attachmentIds, + }); + return context.json({ ...result, events: sanitizeSessionEvents(result.events) }, 201); +}); + +const sendMessageRoute = createRoute({ + method: "post", + path: "/sessions/{sessionId}/messages", + request: { + params: ProjectSessionParamsSchema, + body: { content: { "application/json": { schema: SendProjectSessionMessageBodySchema } } }, + }, + responses: { + 200: { + description: "Follow up in a project Session using its pinned runtime", + content: { "application/json": { schema: CreateProjectSessionResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectSessionsRoute.openapi(sendMessageRoute, async (context) => { + const { sessionId } = context.req.valid("param"); + const { message } = context.req.valid("json"); + const result = await sendProjectSessionMessage(sessionId, message.trim()); + return context.json({ ...result, events: sanitizeSessionEvents(result.events) }, 200); +}); + +const getSessionRoute = createRoute({ + method: "get", + path: "/sessions/{sessionId}", + request: { params: ProjectSessionParamsSchema }, + responses: { + 200: { + description: "Project Session detail, history, and artifacts carried by events", + content: { "application/json": { schema: CreateProjectSessionResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectSessionsRoute.openapi(getSessionRoute, async (context) => { + const { sessionId } = context.req.valid("param"); + const result = await getProjectSessionDetail(sessionId); + return context.json({ ...result, events: sanitizeSessionEvents(result.events) }, 200); +}); + +const getSessionArtifactDownloadRoute = createRoute({ + method: "get", + path: "/sessions/{sessionId}/artifacts/{fileId}/download", + request: { params: ProjectSessionArtifactParamsSchema }, + responses: { + 200: { + description: "Resolve a short-lived download URL for an artifact delivered by this Session", + content: { "application/json": { schema: ProjectSessionArtifactDownloadSchema } }, + }, + ...errorResponses, + }, +}); + +projectSessionsRoute.openapi(getSessionArtifactDownloadRoute, async (context) => { + const { sessionId, fileId } = context.req.valid("param"); + return context.json(await getProjectSessionArtifactDownload(sessionId, fileId), 200); +}); + +const cancelSessionRoute = createRoute({ + method: "post", + path: "/sessions/{sessionId}/cancel", + request: { params: ProjectSessionParamsSchema }, + responses: { + 200: { + description: "Terminate/delete the provider Session", + content: { "application/json": { schema: z.object({ session_id: z.string(), cancelled: z.literal(true) }) } }, + }, + ...errorResponses, + }, +}); + +projectSessionsRoute.openapi(cancelSessionRoute, async (context) => { + const { sessionId } = context.req.valid("param"); + await cancelProjectSession(sessionId); + return context.json({ session_id: sessionId, cancelled: true as const }, 200); +}); + +const streamSessionRoute = createRoute({ + method: "get", + path: "/sessions/{sessionId}/events", + request: { params: ProjectSessionParamsSchema, query: StreamAfterQuerySchema }, + responses: { + 200: { + description: "Replay and stream Session events", + content: { "text/event-stream": { schema: z.string() } }, + }, + 204: { description: "Session is unavailable" }, + ...errorResponses, + }, +}); + +projectSessionsRoute.openapi(streamSessionRoute, async (context) => { + const { sessionId } = context.req.valid("param"); + let buffer = getEventBuffer(sessionId); + if (!buffer && (await reconstructProjectSessionBuffer(sessionId))) buffer = getEventBuffer(sessionId); + if (!buffer) return new Response(null, { status: 204 }); + const { after } = context.req.valid("query"); + const lastEventId = Number(context.req.header("Last-Event-ID")); + const replayAfter = Number.isInteger(lastEventId) ? Math.max(after ?? -1, lastEventId) : (after ?? -1); + return streamSessionBuffer(buffer, replayAfter); +}); + +const uploadAttachmentRoute = createRoute({ + method: "post", + path: "/project/agents/{agentId}/attachments", + request: { + params: ProjectAgentParamsSchema, + body: { required: true, content: { "multipart/form-data": { schema: UploadFileFormSchema } } }, + }, + responses: { + 201: { + description: "Ad-hoc Session attachment uploaded", + content: { "application/json": { schema: AttachmentSchema } }, + }, + 413: { + description: "File exceeds the upload limit", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectSessionsRoute.openapi( + uploadAttachmentRoute, + async (context) => { + await projectRuntimeManager.ensureStarted(); + const { agentId } = context.req.valid("param"); + const { file } = context.req.valid("form"); + if (file.size === 0) return context.json({ error: { message: "file is required" } }, 400); + if (file.size > MAX_UPLOAD_BYTES) return context.json({ error: { message: "file too large" } }, 413); + const content = new Uint8Array(await file.arrayBuffer()); + const runtime = projectRuntimeManager.requireRuntimeInput(); + const provider = await readProjectRuntime(runtime, (projectContext) => getAgent(projectContext, agentId).provider); + const uploaded = await readProjectRuntime(runtime, (projectContext) => + uploadFile(projectContext, content, file.name || "upload", { + provider, + mimeType: file.type || undefined, + }), + ); + const attachment = { + id: randomUUID(), + agent_id: agentId, + provider, + remote_file_id: uploaded.id, + filename: uploaded.filename, + mime_type: uploaded.mime_type || undefined, + status: uploaded.status, + available: uploaded.available, + created_at: uploaded.created_at || new Date().toISOString(), + }; + await projectRuntimeRegistry.putAttachment(attachment); + return context.json(attachment, 201); + }, + (_result, context) => context.json({ error: { message: "file is required" } }, 400), +); + +const listAttachmentsRoute = createRoute({ + method: "get", + path: "/project/agents/{agentId}/attachments", + request: { params: ProjectAgentParamsSchema }, + responses: { + 200: { + description: "List ad-hoc attachments", + content: { "application/json": { schema: AttachmentListResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectSessionsRoute.openapi(listAttachmentsRoute, async (context) => { + const { agentId } = context.req.valid("param"); + let attachments = await projectRuntimeRegistry.listAttachments(agentId); + const snapshot = projectRuntimeManager.getSnapshot(); + if (snapshot.status === "valid" && snapshot.input) { + const runtime = snapshot.input; + attachments = await Promise.all( + attachments.map(async (attachment) => { + if (attachment.available) return attachment; + try { + const info = await readProjectRuntime(runtime, (projectContext) => + getFileInfo(projectContext, attachment.remote_file_id, { provider: attachment.provider }), + ); + const refreshed = { + ...attachment, + filename: info.filename, + mime_type: info.mime_type || attachment.mime_type, + status: info.status, + available: info.available, + }; + await projectRuntimeRegistry.putAttachment(refreshed); + return refreshed; + } catch (error) { + // Keep the local cleanup record when metadata lookup is unavailable or transiently fails. + if (error instanceof Error && error.message.includes("does not support file metadata lookup")) { + const unavailable = { ...attachment, status: "capability_unavailable" }; + await projectRuntimeRegistry.putAttachment(unavailable); + return unavailable; + } + return attachment; + } + }), + ); + } + return context.json({ attachments }, 200); +}); + +const deleteAttachmentRoute = createRoute({ + method: "delete", + path: "/attachments/{attachmentId}", + request: { params: AttachmentParamsSchema }, + responses: { + 200: { + description: "Remote attachment deleted", + content: { "application/json": { schema: AttachmentDeleteResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectSessionsRoute.openapi(deleteAttachmentRoute, async (context) => { + const { attachmentId } = context.req.valid("param"); + const attachment = await projectRuntimeRegistry.getAttachment(attachmentId); + if (!attachment) throw statusError(`Attachment '${attachmentId}' was not found.`, 404); + const runtime = projectRuntimeManager.requireRuntimeInput(); + await readProjectRuntime(runtime, (projectContext) => + deleteFile(projectContext, attachment.remote_file_id, { provider: attachment.provider }), + ); + await projectRuntimeRegistry.removeAttachment(attachmentId); + return context.json({ attachment_id: attachmentId, deleted: true as const }, 200); +}); + +function streamSessionBuffer(buffer: NonNullable>, afterIndex: number): Response { + const encoder = new TextEncoder(); + let unsubscribe: (() => void) | undefined; + let ping: ReturnType | undefined; + let closed = false; + const stream = new ReadableStream({ + start(controller) { + const send = (type: string, data: unknown) => { + if (!closed) controller.enqueue(encoder.encode(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`)); + }; + const sendEvent = (event: Parameters[0], index: number) => { + const sanitized: SessionEvent = sanitizeSessionEvent(event); + if (!closed) { + controller.enqueue(encoder.encode(`id: ${index}\nevent: event\ndata: ${JSON.stringify(sanitized)}\n\n`)); + } + }; + for (let index = afterIndex + 1; index < buffer.events.length; index++) sendEvent(buffer.events[index]!, index); + if (buffer.done) { + send("done", { error: buffer.error ?? null }); + closed = true; + controller.close(); + return; + } + unsubscribe = subscribeEvents(buffer.sessionId, (event) => { + if (event) sendEvent(event, buffer.events.length - 1); + else { + send("done", { error: buffer.error ?? null }); + closed = true; + unsubscribe?.(); + if (ping) clearInterval(ping); + controller.close(); + } + }); + ping = setInterval(() => send("ping", {}), 15_000); + }, + cancel() { + closed = true; + unsubscribe?.(); + if (ping) clearInterval(ping); + }, + }); + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} + +function statusError(message: string, status: number): Error & { status: number } { + return Object.assign(new Error(message), { status }); +} diff --git a/apps/server/src/routes/project.ts b/apps/server/src/routes/project.ts new file mode 100644 index 0000000..136dfa1 --- /dev/null +++ b/apps/server/src/routes/project.ts @@ -0,0 +1,225 @@ +import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; +import { planAgentResourcesWithStateBackend, syncAgentResourcesWithStateBackend } from "@openagentpack/sdk"; +import { errorResponses } from "@/schemas/common"; +import { + AgentApplyBodySchema, + AgentApplyResponseSchema, + AgentPlanBodySchema, + AgentPlanResponseSchema, + ProjectAgentParamsSchema, + ProjectSummarySchema, +} from "@/schemas/project"; +import { projectRuntimeManager } from "@/services/project-manager"; +import { planTokenStore, projectOperationStore } from "@/services/project-operations"; + +export const projectRoute = new OpenAPIHono(); + +projectRuntimeManager.subscribe((event) => { + if (event.type.startsWith("project.")) planTokenStore.invalidateAll(); +}); + +const getProjectRoute = createRoute({ + method: "get", + path: "/project", + request: { + query: z.object({ refresh: z.enum(["true", "false"]).optional() }), + }, + responses: { + 200: { + description: "Current agents.yaml project, validation, readiness, and deployment declarations", + content: { "application/json": { schema: ProjectSummarySchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(getProjectRoute, async (context) => { + const { refresh } = context.req.valid("query"); + return context.json(await projectRuntimeManager.getSummary({ refreshReadiness: refresh === "true" }), 200); +}); + +const streamProjectRoute = createRoute({ + method: "get", + path: "/project/events", + responses: { + 200: { + description: "Project reload and validation events", + content: { "text/event-stream": { schema: z.string() } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(streamProjectRoute, async (context) => { + await projectRuntimeManager.ensureStarted(); + const initial = projectRuntimeManager.getSnapshot(); + const encoder = new TextEncoder(); + let unsubscribe: (() => void) | undefined; + let ping: ReturnType | undefined; + const stream = new ReadableStream({ + start(controller) { + const send = (type: string, data: unknown) => { + controller.enqueue(encoder.encode(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`)); + }; + send("project.snapshot", { + status: initial.status, + revision: initial.revision, + }); + unsubscribe = projectRuntimeManager.subscribe((event) => send(event.type, event)); + ping = setInterval(() => send("ping", {}), 15_000); + }, + cancel() { + unsubscribe?.(); + if (ping) clearInterval(ping); + }, + }); + context.req.raw.signal.addEventListener("abort", () => { + unsubscribe?.(); + if (ping) clearInterval(ping); + }); + return new Response(stream, { headers: sseHeaders() }); +}); + +const planAgentRoute = createRoute({ + method: "post", + path: "/project/agents/{agentId}/plan", + request: { + params: ProjectAgentParamsSchema, + body: { content: { "application/json": { schema: AgentPlanBodySchema } } }, + }, + responses: { + 200: { + description: "Scoped plan for one Agent and its runtime dependencies", + content: { "application/json": { schema: AgentPlanResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(planAgentRoute, async (context) => { + await projectRuntimeManager.ensureStarted(); + const { agentId } = context.req.valid("param"); + const { refresh } = context.req.valid("json"); + const snapshot = projectRuntimeManager.getSnapshot(); + const input = projectRuntimeManager.requireRuntimeInput(); + const plan = await planAgentResourcesWithStateBackend(input, agentId, { + refresh: refresh ?? true, + scope: "runtime", + }); + if (plan.diagnostics.some((diagnostic) => diagnostic.severity === "error")) { + throw statusError(plan.diagnostics.find((diagnostic) => diagnostic.severity === "error")!.message, 422); + } + const token = planTokenStore.issue({ + agentId, + projectRevision: snapshot.revision!, + fingerprint: plan.fingerprint, + destructive: plan.destructiveActions.length > 0, + }); + return context.json( + { + agent_id: agentId, + provider: plan.provider, + project_revision: snapshot.revision!, + plan_token: token.token, + expires_at: new Date(token.expiresAt).toISOString(), + fingerprint: plan.fingerprint, + actions: redactForWire(plan.actions), + diagnostics: redactForWire(plan.diagnostics), + destructive: token.destructive, + }, + 200, + ); +}); + +const applyAgentRoute = createRoute({ + method: "post", + path: "/project/agents/{agentId}/apply", + request: { + params: ProjectAgentParamsSchema, + body: { content: { "application/json": { schema: AgentApplyBodySchema } } }, + }, + responses: { + 202: { + description: "Agent apply accepted as an asynchronous operation", + content: { "application/json": { schema: AgentApplyResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(applyAgentRoute, async (context) => { + await projectRuntimeManager.ensureStarted(); + const { agentId } = context.req.valid("param"); + const { plan_token: planToken, confirm_destructive: confirmDestructive } = context.req.valid("json"); + const snapshot = projectRuntimeManager.getSnapshot(); + const input = projectRuntimeManager.requireRuntimeInput(); + const token = planTokenStore.require(planToken, agentId, snapshot.revision!); + if (token.destructive && !confirmDestructive) { + throw statusError( + "This plan contains destructive actions. Set confirm_destructive to true after reviewing it.", + 422, + ); + } + + const freshPlan = await planAgentResourcesWithStateBackend(input, agentId, { + refresh: true, + scope: "runtime", + }); + if (freshPlan.fingerprint !== token.fingerprint) { + planTokenStore.consume(planToken); + throw statusError("Plan is stale because project or remote resources changed. Create a new plan.", 409); + } + const currentSnapshot = projectRuntimeManager.getSnapshot(); + try { + planTokenStore.require(planToken, agentId, currentSnapshot.revision ?? ""); + } catch { + planTokenStore.consume(planToken); + throw statusError("Plan is stale because project configuration changed. Create a new plan.", 409); + } + + const operation = projectOperationStore.create(agentId, async (reporter) => { + const run = await syncAgentResourcesWithStateBackend(input, agentId, { + refresh: true, + scope: "runtime", + expectedPlanFingerprint: token.fingerprint, + policy: confirmDestructive ? "force" : "block", + onFeedback: reporter.feedback, + }); + if (run.status !== "completed") { + throw statusError( + run.error ?? `Agent apply ended with status '${run.status}'.`, + run.reason === "plan_stale" ? 409 : 422, + ); + } + planTokenStore.invalidateAll(); + await projectRuntimeManager.refreshAfterMutation(); + return redactForWire(run); + }); + planTokenStore.consume(planToken); + return context.json({ operation_id: operation.id, status: "queued" as const }, 202); +}); + +function statusError(message: string, status: number): Error & { status: number } { + return Object.assign(new Error(message), { status }); +} + +function sseHeaders(): Record { + return { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }; +} + +const SENSITIVE_KEY = /(access[_-]?key|api[_-]?key|authorization|credential|headers?|password|secret|signature|token)/i; + +function redactForWire(value: T): T { + if (Array.isArray(value)) return value.map((item) => redactForWire(item)) as T; + if (!value || typeof value !== "object") return value; + const output: Record = {}; + for (const [key, entry] of Object.entries(value)) { + output[key] = SENSITIVE_KEY.test(key) ? "[redacted]" : redactForWire(entry); + } + return output as T; +} diff --git a/apps/server/src/schemas/common.ts b/apps/server/src/schemas/common.ts index 7d66ca4..b0479ef 100644 --- a/apps/server/src/schemas/common.ts +++ b/apps/server/src/schemas/common.ts @@ -23,5 +23,6 @@ export const errorResponses = { 400: errorResponse("Bad request"), 404: errorResponse("Not found"), 409: errorResponse("Conflict"), + 422: errorResponse("Unprocessable entity"), 500: errorResponse("Server error"), }; diff --git a/apps/server/src/schemas/project.ts b/apps/server/src/schemas/project.ts new file mode 100644 index 0000000..d00a433 --- /dev/null +++ b/apps/server/src/schemas/project.ts @@ -0,0 +1,138 @@ +import { z } from "@hono/zod-openapi"; +import { + AgentDefinitionSchema, + AgentWithReadinessSchema, + DiagnosticSchema, + PlannedActionSchema, + SessionEventSchema, + SessionSchema, +} from "@openagentpack/sdk"; + +export const ProjectStatusSchema = z.enum(["loading", "valid", "invalid", "missing"]); + +export const ProjectAgentSummarySchema = AgentWithReadinessSchema.extend({ + details: z.object({ + environment: z.string().optional(), + vault: z.string().optional(), + memory_stores: z.array(z.string()), + resources: z.array(z.object({ type: z.string(), mount_path: z.string().optional() })), + }), +}); + +export const ProjectDeploymentSummarySchema = z.object({ + id: z.string(), + agent: z.string(), + provider: z.string().optional(), + description: z.string().optional(), + schedule: z.object({ expression: z.string(), timezone: z.string() }).optional(), + initial_event_types: z.array(z.string()), + resource_types: z.array(z.string()), +}); + +export const ProjectSummarySchema = z + .object({ + status: ProjectStatusSchema, + config_file: z.string(), + project_name: z.string(), + revision: z.string().optional(), + diagnostics: z.array(DiagnosticSchema), + agents: z.array(ProjectAgentSummarySchema), + deployments: z.array(ProjectDeploymentSummarySchema), + }) + .openapi("ProjectSummary"); + +export const ProjectAgentParamsSchema = z.object({ agentId: z.string().min(1) }); + +export const AgentPlanBodySchema = z.object({ refresh: z.boolean().optional() }); + +export const AgentPlanResponseSchema = z + .object({ + agent_id: z.string(), + provider: z.string(), + project_revision: z.string(), + plan_token: z.string(), + expires_at: z.string(), + fingerprint: z.string(), + actions: z.array(PlannedActionSchema), + diagnostics: z.array(DiagnosticSchema), + destructive: z.boolean(), + }) + .openapi("AgentPlanResponse"); + +export const AgentApplyBodySchema = z.object({ + plan_token: z.string().min(1), + confirm_destructive: z.boolean().optional(), +}); + +export const AgentApplyResponseSchema = z + .object({ operation_id: z.string(), status: z.literal("queued") }) + .openapi("AgentApplyResponse"); + +export const OperationStatusSchema = z.enum(["queued", "running", "completed", "failed", "interrupted"]); +export const OperationEventSchema = z.object({ + index: z.number().int().nonnegative(), + type: z.string(), + timestamp: z.string(), + data: z.unknown(), +}); +export const OperationResponseSchema = z + .object({ + id: z.string(), + type: z.literal("agent.apply"), + agent_id: z.string(), + status: OperationStatusSchema, + created_at: z.string(), + updated_at: z.string(), + events: z.array(OperationEventSchema), + result: z.unknown().optional(), + error: z.string().optional(), + }) + .openapi("OperationResponse"); + +export const OperationParamsSchema = z.object({ operationId: z.string().min(1) }); +export const StreamAfterQuerySchema = z.object({ + after: z.preprocess((value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + }, z.number().int().optional()), +}); + +export const CreateProjectSessionBodySchema = z.object({ + prompt: z.string().optional(), + title: z.string().optional(), + attachment_ids: z.array(z.string()).optional(), +}); +export const CreateProjectSessionResponseSchema = z + .object({ + session: SessionSchema, + events: z.array(SessionEventSchema), + provider: z.string(), + agent_id: z.string(), + agent_name: z.string(), + agent_details: AgentDefinitionSchema, + }) + .openapi("CreateProjectSessionResponse"); + +export const SendProjectSessionMessageBodySchema = z.object({ message: z.string().min(1) }); +export const ProjectSessionParamsSchema = z.object({ sessionId: z.string().min(1) }); +export const ProjectSessionArtifactParamsSchema = ProjectSessionParamsSchema.extend({ + fileId: z.string().min(1), +}); +export const ProjectSessionArtifactDownloadSchema = z + .object({ url: z.string().url(), expires_at: z.string().optional() }) + .openapi("ProjectSessionArtifactDownload"); + +export const AttachmentSchema = z.object({ + id: z.string(), + agent_id: z.string(), + provider: z.string(), + remote_file_id: z.string(), + filename: z.string(), + mime_type: z.string().optional(), + status: z.string().optional(), + available: z.boolean(), + created_at: z.string(), +}); +export const AttachmentListResponseSchema = z.object({ attachments: z.array(AttachmentSchema) }); +export const AttachmentParamsSchema = z.object({ attachmentId: z.string().min(1) }); +export const AttachmentDeleteResponseSchema = z.object({ attachment_id: z.string(), deleted: z.literal(true) }); diff --git a/apps/server/src/services/project-manager.ts b/apps/server/src/services/project-manager.ts new file mode 100644 index 0000000..27cedc8 --- /dev/null +++ b/apps/server/src/services/project-manager.ts @@ -0,0 +1,363 @@ +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { readdir, readFile, realpath, stat } from "node:fs/promises"; +import { basename, dirname, resolve } from "node:path"; +import { + type AgentWithReadiness, + type BackendRuntimeInput, + type Diagnostic, + LocalFileStateBackend, + listAgentsWithReadiness, + type ResolvedProjectConfig, + readProjectRuntime, + resolveProjectConfig, + validateProjectConfig, +} from "@openagentpack/sdk"; +import { type FSWatcher, watch } from "chokidar"; + +export type ProjectStatus = "loading" | "valid" | "invalid" | "missing"; +export type ProjectChangeType = "project.reloading" | "project.valid" | "project.invalid" | "project.missing"; + +export interface ProjectChangeEvent { + type: ProjectChangeType; + revision?: string; + status: ProjectStatus; +} + +interface ProjectSnapshot { + status: ProjectStatus; + configPath: string; + projectName: string; + revision?: string; + diagnostics: Diagnostic[]; + config?: ResolvedProjectConfig; + input?: BackendRuntimeInput; + sourcePaths: string[]; +} + +export interface ProjectAgentSummary extends AgentWithReadiness { + details: { + environment?: string; + vault?: string; + memory_stores: string[]; + resources: Array<{ type: string; mount_path?: string }>; + }; +} + +export interface ProjectDeploymentSummary { + id: string; + agent: string; + provider?: string; + description?: string; + schedule?: { expression: string; timezone: string }; + initial_event_types: string[]; + resource_types: string[]; +} + +export interface ProjectSummary { + status: ProjectStatus; + config_file: string; + project_name: string; + revision?: string; + diagnostics: Diagnostic[]; + agents: ProjectAgentSummary[]; + deployments: ProjectDeploymentSummary[]; +} + +type ProjectListener = (event: ProjectChangeEvent) => void; + +export class ProjectUnavailableError extends Error { + readonly status = 422; + constructor(message: string) { + super(message); + this.name = "ProjectUnavailableError"; + } +} + +export class ProjectRuntimeManager { + readonly configPath: string; + readonly projectId: string; + private snapshot: ProjectSnapshot; + private startPromise?: Promise; + private reloadTimer?: ReturnType; + private watcher?: FSWatcher; + private readonly listeners = new Set(); + private readinessCache?: { revision: string; agents: AgentWithReadiness[] }; + + constructor(configPath = process.env.AGENTS_CONFIG_PATH?.trim() || "agents.yaml") { + this.configPath = resolve(configPath); + this.projectId = createHash("sha256").update(this.configPath).digest("hex").slice(0, 16); + this.snapshot = { + status: "loading", + configPath: this.configPath, + projectName: basename(dirname(this.configPath)), + diagnostics: [], + sourcePaths: [this.configPath], + }; + } + + async ensureStarted(): Promise { + this.startPromise ??= this.reload(); + await this.startPromise; + } + + getSnapshot(): Readonly { + return this.snapshot; + } + + requireRuntimeInput(): BackendRuntimeInput { + if (this.snapshot.status !== "valid" || !this.snapshot.input) { + throw new ProjectUnavailableError( + `Project configuration is ${this.snapshot.status}. Fix ${this.configPath} before starting a new operation.`, + ); + } + return this.snapshot.input; + } + + subscribe(listener: ProjectListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async getSummary(options: { refreshReadiness?: boolean } = {}): Promise { + await this.ensureStarted(); + const snapshot = this.snapshot; + let agents: AgentWithReadiness[] = []; + if (snapshot.config && snapshot.input && snapshot.revision) { + if (options.refreshReadiness || this.readinessCache?.revision !== snapshot.revision) { + agents = await readProjectRuntime(snapshot.input, (ctx) => + listAgentsWithReadiness(ctx, { refresh: options.refreshReadiness ?? false }), + ); + this.readinessCache = { revision: snapshot.revision, agents }; + } else { + agents = this.readinessCache.agents; + } + } + + return { + status: snapshot.status, + config_file: snapshot.configPath, + project_name: snapshot.projectName, + revision: snapshot.revision, + diagnostics: snapshot.diagnostics, + agents: agents.map((entry) => ({ + ...entry, + details: projectAgentDetails(snapshot.config, entry.agent.id), + })), + deployments: projectDeployments(snapshot.config), + }; + } + + async refreshAfterMutation(): Promise { + this.readinessCache = undefined; + await this.reload(false); + } + + scheduleReload(): void { + if (this.reloadTimer) clearTimeout(this.reloadTimer); + this.reloadTimer = setTimeout(() => { + this.reloadTimer = undefined; + void this.reload(); + }, 200); + } + + close(): void { + if (this.reloadTimer) clearTimeout(this.reloadTimer); + void this.closeWatcher(); + this.listeners.clear(); + } + + private async reload(emitReloading = true): Promise { + if (emitReloading) this.emit({ type: "project.reloading", status: "loading" }); + const previous = this.snapshot; + let next: ProjectSnapshot; + try { + if (!existsSync(this.configPath)) { + next = { + status: "missing", + configPath: this.configPath, + projectName: basename(dirname(this.configPath)), + diagnostics: [ + { + severity: "error", + code: "project.config.missing", + message: `Configuration file not found: ${this.configPath}`, + }, + ], + sourcePaths: [this.configPath], + }; + } else { + const loaded = await resolveProjectConfig(this.configPath); + const diagnostics = validateProjectConfig(loaded.config); + const revision = await computeProjectRevision(loaded.sourcePaths); + const hasErrors = diagnostics.some((diagnostic) => diagnostic.severity === "error"); + const input: BackendRuntimeInput = { + projectName: loaded.projectName, + config: loaded.config, + configPath: loaded.configPath, + providers: loaded.config.providers, + stateBackend: new LocalFileStateBackend({ configPath: loaded.configPath }), + stateScope: { projectId: loaded.projectName }, + }; + next = { + status: hasErrors ? "invalid" : "valid", + configPath: loaded.configPath, + projectName: loaded.projectName, + revision, + diagnostics, + config: loaded.config, + input, + sourcePaths: loaded.sourcePaths, + }; + } + } catch (error) { + const failedSourcePaths = + error && typeof error === "object" && "sourcePaths" in error && Array.isArray(error.sourcePaths) + ? error.sourcePaths.filter((sourcePath): sourcePath is string => typeof sourcePath === "string") + : [this.configPath]; + next = { + status: "invalid", + configPath: this.configPath, + projectName: basename(dirname(this.configPath)), + diagnostics: [ + { + severity: "error", + code: "project.config.invalid", + message: error instanceof Error ? error.message : String(error), + }, + ], + sourcePaths: failedSourcePaths, + }; + } + + this.snapshot = next; + this.readinessCache = undefined; + await this.resetWatcher(next.sourcePaths); + if (snapshotIdentity(previous) !== snapshotIdentity(next)) { + this.emit({ + type: + next.status === "valid" ? "project.valid" : next.status === "missing" ? "project.missing" : "project.invalid", + status: next.status, + revision: next.revision, + }); + } + } + + private emit(event: ProjectChangeEvent): void { + for (const listener of this.listeners) listener(event); + } + + private async resetWatcher(sourcePaths: string[]): Promise { + await this.closeWatcher(); + const watcher = watch(collectWatchPaths(sourcePaths), { + ignoreInitial: true, + usePolling: typeof Bun !== "undefined", + interval: 100, + awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }, + }); + watcher.on("all", () => this.scheduleReload()); + watcher.on("error", (error) => { + console.warn(`[project] File watcher error: ${error instanceof Error ? error.message : error}`); + }); + await new Promise((resolveReady) => watcher.once("ready", resolveReady)); + this.watcher = watcher; + } + + private async closeWatcher(): Promise { + const watcher = this.watcher; + this.watcher = undefined; + if (watcher) await watcher.close(); + } +} + +function collectWatchPaths(sourcePaths: string[]): string[] { + const watchPaths = new Set(); + for (const sourcePath of sourcePaths) { + if (existsSync(sourcePath)) { + watchPaths.add(sourcePath); + continue; + } + let existingParent = dirname(sourcePath); + while (!existsSync(existingParent)) { + const parent = dirname(existingParent); + if (parent === existingParent) break; + existingParent = parent; + } + watchPaths.add(existingParent); + } + return [...watchPaths]; +} + +function snapshotIdentity(snapshot: ProjectSnapshot): string { + return `${snapshot.status}:${snapshot.revision ?? ""}:${snapshot.diagnostics + .map((diagnostic) => `${diagnostic.severity}:${diagnostic.code}:${diagnostic.message}`) + .join("|")}`; +} + +async function computeProjectRevision(sourcePaths: string[]): Promise { + const hash = createHash("sha256"); + const visited = new Set(); + for (const sourcePath of [...sourcePaths].sort()) { + await appendPathToHash(hash, sourcePath, visited); + } + return hash.digest("hex"); +} + +async function appendPathToHash( + hash: ReturnType, + sourcePath: string, + visited: Set, +): Promise { + let sourceRealPath: string; + try { + sourceRealPath = await realpath(sourcePath); + } catch { + hash.update(`missing:${sourcePath}\n`); + return; + } + if (visited.has(sourceRealPath)) return; + visited.add(sourceRealPath); + const sourceStat = await stat(sourceRealPath); + if (sourceStat.isDirectory()) { + hash.update(`directory:${sourcePath}\n`); + for (const entry of (await readdir(sourceRealPath)).sort()) { + await appendPathToHash(hash, resolve(sourceRealPath, entry), visited); + } + return; + } + if (sourceStat.isFile()) { + hash.update(`file:${sourcePath}\n`); + hash.update(await readFile(sourceRealPath)); + hash.update("\n"); + } +} + +function projectAgentDetails( + config: ResolvedProjectConfig | undefined, + agentId: string, +): ProjectAgentSummary["details"] { + const agent = config?.agents?.[agentId]; + return { + environment: agent?.environment, + vault: agent?.vault, + memory_stores: agent?.memory_stores ?? [], + resources: (agent?.resources ?? []).map((resource) => ({ + type: resource.type, + mount_path: resource.mount_path, + })), + }; +} + +function projectDeployments(config: ResolvedProjectConfig | undefined): ProjectDeploymentSummary[] { + return Object.entries(config?.deployments ?? {}).map(([id, deployment]) => ({ + id, + agent: deployment.agent, + provider: deployment.provider, + description: deployment.description, + schedule: deployment.schedule, + initial_event_types: deployment.initial_events.map((event) => event.type), + resource_types: (deployment.resources ?? []).map((resource) => resource.type), + })); +} + +export const projectRuntimeManager = new ProjectRuntimeManager(); diff --git a/apps/server/src/services/project-operations.ts b/apps/server/src/services/project-operations.ts new file mode 100644 index 0000000..6df48ff --- /dev/null +++ b/apps/server/src/services/project-operations.ts @@ -0,0 +1,199 @@ +import { randomUUID } from "node:crypto"; +import type { RuntimeFeedbackEvent } from "@openagentpack/sdk"; + +const PLAN_TTL_MS = 10 * 60 * 1000; +const OPERATION_TTL_MS = 24 * 60 * 60 * 1000; + +export interface PlanTokenRecord { + token: string; + agentId: string; + projectRevision: string; + fingerprint: string; + destructive: boolean; + expiresAt: number; +} + +export class OperationProtocolError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "OperationProtocolError"; + } +} + +export class PlanTokenStore { + private readonly records = new Map(); + + issue(input: Omit): PlanTokenRecord { + this.evictExpired(); + const record: PlanTokenRecord = { + ...input, + token: randomUUID(), + expiresAt: Date.now() + PLAN_TTL_MS, + }; + this.records.set(record.token, record); + return record; + } + + require(token: string, agentId: string, projectRevision: string): PlanTokenRecord { + this.evictExpired(); + const record = this.records.get(token); + if (!record || record.agentId !== agentId || record.projectRevision !== projectRevision) { + throw new OperationProtocolError("Plan is stale or no longer valid. Create a new plan before applying.", 409); + } + return record; + } + + consume(token: string): void { + this.records.delete(token); + } + + invalidateAll(): void { + this.records.clear(); + } + + private evictExpired(): void { + const now = Date.now(); + for (const [token, record] of this.records) { + if (record.expiresAt <= now) this.records.delete(token); + } + } +} + +export type OperationStatus = "queued" | "running" | "completed" | "failed" | "interrupted"; + +export interface OperationEvent { + index: number; + type: string; + timestamp: string; + data: unknown; +} + +export interface ProjectOperation { + id: string; + type: "agent.apply"; + agent_id: string; + status: OperationStatus; + created_at: string; + updated_at: string; + events: OperationEvent[]; + result?: unknown; + error?: string; +} + +type OperationListener = (event: OperationEvent | null) => void; + +export interface OperationReporter { + emit(type: string, data: unknown): void; + feedback(event: RuntimeFeedbackEvent): void; +} + +export class ProjectOperationStore { + private readonly operations = new Map(); + private readonly listeners = new Map>(); + private activeOperationId?: string; + + create(agentId: string, executor: (reporter: OperationReporter) => Promise): ProjectOperation { + this.evictExpired(); + if (this.activeOperationId) { + const active = this.operations.get(this.activeOperationId); + if (active && (active.status === "queued" || active.status === "running")) { + throw new OperationProtocolError( + `Another apply operation (${active.id}) is already running for this project.`, + 409, + ); + } + } + + const now = new Date().toISOString(); + const operation: ProjectOperation = { + id: randomUUID(), + type: "agent.apply", + agent_id: agentId, + status: "queued", + created_at: now, + updated_at: now, + events: [], + }; + this.operations.set(operation.id, operation); + this.listeners.set(operation.id, new Set()); + this.activeOperationId = operation.id; + queueMicrotask(() => void this.run(operation, executor)); + return operation; + } + + get(id: string): ProjectOperation { + this.evictExpired(); + const operation = this.operations.get(id); + if (!operation) throw new OperationProtocolError(`Operation '${id}' was not found.`, 404); + return operation; + } + + subscribe(id: string, listener: OperationListener): () => void { + this.get(id); + const operationListeners = this.listeners.get(id) ?? new Set(); + operationListeners.add(listener); + this.listeners.set(id, operationListeners); + return () => operationListeners.delete(listener); + } + + private async run( + operation: ProjectOperation, + executor: (reporter: OperationReporter) => Promise, + ): Promise { + operation.status = "running"; + operation.updated_at = new Date().toISOString(); + this.append(operation, "operation.started", { agent_id: operation.agent_id }); + const reporter: OperationReporter = { + emit: (type, data) => this.append(operation, type, data), + feedback: (event) => this.append(operation, "runtime.feedback", event), + }; + try { + operation.result = await executor(reporter); + operation.status = "completed"; + this.append(operation, "operation.completed", operation.result); + } catch (error) { + operation.status = "failed"; + operation.error = error instanceof Error ? error.message : String(error); + this.append(operation, "operation.failed", { message: operation.error }); + } finally { + operation.updated_at = new Date().toISOString(); + if (this.activeOperationId === operation.id) this.activeOperationId = undefined; + this.broadcast(operation.id, null); + } + } + + private append(operation: ProjectOperation, type: string, data: unknown): void { + const event: OperationEvent = { + index: operation.events.length, + type, + timestamp: new Date().toISOString(), + data, + }; + operation.events.push(event); + operation.updated_at = event.timestamp; + this.broadcast(operation.id, event); + } + + private broadcast(id: string, event: OperationEvent | null): void { + for (const listener of this.listeners.get(id) ?? []) listener(event); + } + + private evictExpired(): void { + const cutoff = Date.now() - OPERATION_TTL_MS; + for (const [id, operation] of this.operations) { + if ( + (operation.status === "completed" || operation.status === "failed" || operation.status === "interrupted") && + Date.parse(operation.updated_at) < cutoff + ) { + this.operations.delete(id); + this.listeners.delete(id); + } + } + } +} + +export const planTokenStore = new PlanTokenStore(); +export const projectOperationStore = new ProjectOperationStore(); diff --git a/apps/server/src/services/project-runtime-registry.ts b/apps/server/src/services/project-runtime-registry.ts new file mode 100644 index 0000000..f0df2e1 --- /dev/null +++ b/apps/server/src/services/project-runtime-registry.ts @@ -0,0 +1,118 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { BackendRuntimeInput } from "@openagentpack/sdk"; +import { projectRuntimeManager } from "@/services/project-manager"; + +export interface AttachmentRecord { + id: string; + agent_id: string; + provider: string; + remote_file_id: string; + filename: string; + mime_type?: string; + status?: string; + available: boolean; + created_at: string; +} + +export interface SessionRecord { + session_id: string; + agent_id: string; + provider: string; + project_revision: string; + created_at: string; + /** Process-local pinned runtime. Deliberately omitted from the persisted JSON. */ + runtime?: BackendRuntimeInput; +} + +interface RuntimeRegistryFile { + version: 1; + attachments: AttachmentRecord[]; + sessions: Array>; +} + +class ProjectRuntimeRegistry { + private readonly filePath = join( + process.env.AGENTS_RUNTIME_HOME?.trim() || join(homedir(), ".agents", "playground-runtime"), + `${projectRuntimeManager.projectId}.json`, + ); + private loadPromise?: Promise; + private writeQueue: Promise = Promise.resolve(); + private readonly pinnedRuntimes = new Map(); + + async listAttachments(agentId?: string): Promise { + const file = await this.load(); + return file.attachments.filter((attachment) => !agentId || attachment.agent_id === agentId); + } + + async getAttachment(id: string): Promise { + return (await this.load()).attachments.find((attachment) => attachment.id === id); + } + + async putAttachment(record: AttachmentRecord): Promise { + const file = await this.load(); + file.attachments = [...file.attachments.filter((attachment) => attachment.id !== record.id), record]; + await this.persist(file); + } + + async removeAttachment(id: string): Promise { + const file = await this.load(); + file.attachments = file.attachments.filter((attachment) => attachment.id !== id); + await this.persist(file); + } + + async putSession(record: SessionRecord): Promise { + const file = await this.load(); + file.sessions = [ + ...file.sessions.filter((session) => session.session_id !== record.session_id), + { + session_id: record.session_id, + agent_id: record.agent_id, + provider: record.provider, + project_revision: record.project_revision, + created_at: record.created_at, + }, + ]; + if (record.runtime) this.pinnedRuntimes.set(record.session_id, record.runtime); + await this.persist(file); + } + + async getSession(id: string): Promise { + const record = (await this.load()).sessions.find((session) => session.session_id === id); + return record ? { ...record, runtime: this.pinnedRuntimes.get(id) } : undefined; + } + + private async load(): Promise { + this.loadPromise ??= this.readFromDisk(); + return this.loadPromise; + } + + private async readFromDisk(): Promise { + try { + const parsed = JSON.parse(await readFile(this.filePath, "utf8")) as Partial; + return { + version: 1, + attachments: Array.isArray(parsed.attachments) ? parsed.attachments : [], + sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [], + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { version: 1, attachments: [], sessions: [] }; + } + throw error; + } + } + + private async persist(file: RuntimeRegistryFile): Promise { + this.writeQueue = this.writeQueue.then(async () => { + await mkdir(dirname(this.filePath), { recursive: true }); + const temporaryPath = `${this.filePath}.${process.pid}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 }); + await rename(temporaryPath, this.filePath); + }); + await this.writeQueue; + } +} + +export const projectRuntimeRegistry = new ProjectRuntimeRegistry(); diff --git a/apps/server/src/services/project-sessions.ts b/apps/server/src/services/project-sessions.ts new file mode 100644 index 0000000..8a5ccf8 --- /dev/null +++ b/apps/server/src/services/project-sessions.ts @@ -0,0 +1,335 @@ +import { + type AgentDefinition, + type BackendRuntimeInput, + createSessionForAgent, + deleteSession, + getFileDownloadUrl, + getSession, + isAgentRunnable, + isTerminalSessionStatus, + listSessionEvents, + type ProviderSessionEvent, + type ProviderSessionInfo, + readProjectRuntime, + type Session, + sendSessionMessageStreaming, + startSessionRun, + streamSessionEvents, +} from "@openagentpack/sdk"; +import { projectRuntimeManager } from "@/services/project-manager"; +import { type AttachmentRecord, projectRuntimeRegistry } from "@/services/project-runtime-registry"; +import { createEventBuffer, getEventBuffer, seedCompletedBuffer } from "@/services/sessions/event-buffer"; + +export async function startProjectSession(input: { + agentId: string; + prompt?: string; + title?: string; + attachmentIds?: string[]; +}): Promise<{ + session: Session; + provider: string; + agent_id: string; + agent_name: string; + agent_details: AgentDefinition; + events: ProviderSessionEvent[]; +}> { + const summary = await projectRuntimeManager.getSummary(); + const selected = summary.agents.find((entry) => entry.agent.id === input.agentId); + if (!selected) throw statusError(`Agent '${input.agentId}' was not found in agents.yaml.`, 404); + if (!isAgentRunnable(selected.readiness)) { + throw statusError( + `Agent '${input.agentId}' is not ready (${selected.readiness.status}). Review and apply its resource plan first.`, + 422, + ); + } + const runtime = projectRuntimeManager.requireRuntimeInput(); + const snapshot = projectRuntimeManager.getSnapshot(); + const attachments = await resolveAttachments(input.agentId, selected.agent.provider, input.attachmentIds ?? []); + const options = { + agent: input.agentId, + title: input.title, + files: attachments.map((attachment) => ({ + fileId: attachment.remote_file_id, + mountPath: `/uploads/${safeFilename(attachment.filename)}`, + })), + }; + const prompt = input.prompt?.trim(); + let session: ProviderSessionInfo; + let provider: string; + if (prompt) { + const run = await readProjectRuntime(runtime, (context) => startSessionRun(context, prompt, options)); + createEventBuffer(run.session.id, run.events); + session = run.session; + provider = run.provider; + } else { + const created = await readProjectRuntime(runtime, (context) => createSessionForAgent(context, options)); + seedCompletedBuffer(created.session.id, []); + session = created.session; + provider = created.provider; + } + await projectRuntimeRegistry.putSession({ + session_id: session.id, + agent_id: input.agentId, + provider, + project_revision: snapshot.revision!, + created_at: new Date().toISOString(), + runtime, + }); + return { + session: toSession(session), + provider, + agent_id: input.agentId, + agent_name: agentDisplayName(runtime, input.agentId), + agent_details: sessionAgentDefinition(runtime, input.agentId, provider), + events: [], + }; +} + +export async function sendProjectSessionMessage( + sessionId: string, + message: string, +): Promise<{ + session: Session; + provider: string; + agent_id: string; + agent_name: string; + agent_details: AgentDefinition; + events: ProviderSessionEvent[]; +}> { + const record = await requireSessionRecord(sessionId); + const runtime = resolveSessionRuntime(record.runtime, record.agent_id, record.provider); + const priorEvents = await listAllEvents(runtime, sessionId, record.provider); + const stream = await readProjectRuntime(runtime, (context) => + sendSessionMessageStreaming(context, sessionId, message, { + agent: record.agent_id, + provider: record.provider, + }), + ); + createEventBuffer(sessionId, stream, priorEvents); + const session = await readProjectRuntime(runtime, (context) => getSession(context, sessionId, record.provider)); + return { + session: toSession(session), + provider: record.provider, + agent_id: record.agent_id, + agent_name: agentDisplayName(runtime, record.agent_id), + agent_details: sessionAgentDefinition(runtime, record.agent_id, record.provider), + events: priorEvents, + }; +} + +export async function getProjectSessionDetail(sessionId: string): Promise<{ + session: Session; + provider: string; + agent_id: string; + agent_name: string; + agent_details: AgentDefinition; + events: ProviderSessionEvent[]; +}> { + const record = await requireSessionRecord(sessionId); + const runtime = resolveSessionRuntime(record.runtime, record.agent_id, record.provider); + const [session, events] = await Promise.all([ + readProjectRuntime(runtime, (context) => getSession(context, sessionId, record.provider)), + listAllEvents(runtime, sessionId, record.provider), + ]); + return { + session: toSession(session), + provider: record.provider, + agent_id: record.agent_id, + agent_name: agentDisplayName(runtime, record.agent_id), + agent_details: sessionAgentDefinition(runtime, record.agent_id, record.provider), + events, + }; +} + +export async function cancelProjectSession(sessionId: string): Promise { + const record = await requireSessionRecord(sessionId); + const runtime = resolveSessionRuntime(record.runtime, record.agent_id, record.provider); + await readProjectRuntime(runtime, (context) => deleteSession(context, sessionId, record.provider)); +} + +export async function getProjectSessionArtifactDownload( + sessionId: string, + fileId: string, +): Promise<{ url: string; expires_at?: string }> { + const record = await requireSessionRecord(sessionId); + const runtime = resolveSessionRuntime(record.runtime, record.agent_id, record.provider); + const events = await listAllEvents(runtime, sessionId, record.provider); + if (!sessionOwnsArtifact(events, fileId)) { + throw statusError(`Artifact file '${fileId}' was not found in Session '${sessionId}'.`, 404); + } + try { + return await readProjectRuntime(runtime, (context) => + getFileDownloadUrl(context, fileId, { provider: record.provider }), + ); + } catch (error) { + if (error instanceof Error && /does not support file downloads/i.test(error.message)) { + throw statusError(`Provider '${record.provider}' does not support artifact downloads.`, 422); + } + throw error; + } +} + +export function sessionOwnsArtifact(events: ProviderSessionEvent[], fileId: string): boolean { + return events.some((event) => event.artifact?.file_id === fileId); +} + +export async function reconstructProjectSessionBuffer(sessionId: string): Promise { + const record = await projectRuntimeRegistry.getSession(sessionId); + if (!record) return false; + let runtime: BackendRuntimeInput; + try { + runtime = resolveSessionRuntime(record.runtime, record.agent_id, record.provider); + } catch { + return false; + } + try { + const session = await readProjectRuntime(runtime, (context) => getSession(context, sessionId, record.provider)); + const history = await listAllEvents(runtime, sessionId, record.provider); + if (isTerminalSessionStatus(session.status)) { + seedCompletedBuffer(sessionId, history); + } else { + const stream = await readProjectRuntime(runtime, (context) => + streamSessionEvents(context, sessionId, { provider: record.provider }), + ); + createEventBuffer(sessionId, stream, history); + } + return true; + } catch { + return false; + } +} + +export function currentProjectSessionEvents(sessionId: string): ProviderSessionEvent[] { + return getEventBuffer(sessionId)?.events ?? []; +} + +async function resolveAttachments(agentId: string, provider: string, attachmentIds: string[]) { + const attachments = await Promise.all(attachmentIds.map((id) => projectRuntimeRegistry.getAttachment(id))); + for (let index = 0; index < attachments.length; index++) { + const attachment = attachments[index]; + if (!attachment) throw statusError(`Attachment '${attachmentIds[index]}' was not found.`, 404); + assertAttachmentCompatible(attachment, agentId, provider); + } + return attachments as Array>; +} + +export function assertAttachmentCompatible(attachment: AttachmentRecord, agentId: string, provider: string): void { + if (attachment.agent_id !== agentId) { + throw statusError( + `Attachment '${attachment.id}' belongs to Agent '${attachment.agent_id}', not '${agentId}'.`, + 422, + ); + } + if (attachment.provider !== provider) { + throw statusError( + `Attachment '${attachment.id}' was uploaded through Provider '${attachment.provider}', not '${provider}'. Upload it again for the current Agent Provider.`, + 422, + ); + } + if (!attachment.available) { + throw statusError( + `Attachment '${attachment.filename}' is not available yet (status: ${attachment.status ?? "unknown"}).`, + 422, + ); + } +} + +async function requireSessionRecord(sessionId: string) { + const record = await projectRuntimeRegistry.getSession(sessionId); + if (!record) throw statusError(`Session '${sessionId}' was not found in this project.`, 404); + return record; +} + +function resolveSessionRuntime( + pinned: BackendRuntimeInput | undefined, + agentId: string, + provider: string, +): BackendRuntimeInput { + if (pinned) return pinned; + const runtime = projectRuntimeManager.requireRuntimeInput(); + const configAgent = runtime.config.agents?.[agentId]; + if (!configAgent) throw statusError(`Session Agent '${agentId}' is no longer declared in the current project.`, 422); + const configuredProvider = configAgent.provider ?? runtime.config.defaults?.provider; + if (configuredProvider && configuredProvider !== provider) { + throw statusError(`Session Provider '${provider}' no longer matches the current Agent configuration.`, 422); + } + return runtime; +} + +async function listAllEvents( + runtime: BackendRuntimeInput, + sessionId: string, + provider: string, +): Promise { + return readProjectRuntime(runtime, async (context) => { + const events: ProviderSessionEvent[] = []; + let pageToken: string | undefined; + for (let page = 0; page < 50; page++) { + const result = await listSessionEvents(context, sessionId, { + provider, + limit: 200, + page_token: pageToken, + }); + events.push(...result.events); + if (!result.has_more || !result.next_page) break; + pageToken = result.next_page; + } + return events; + }); +} + +function toSession(session: ProviderSessionInfo): Session { + return { + session_id: session.id, + status: session.status, + title: session.title?.trim() || session.id, + agent: session.agent_id ? { agent_id: session.agent_id } : undefined, + environment_id: session.environment_id, + created_at: session.created_at, + updated_at: session.updated_at, + }; +} + +function agentDisplayName(runtime: BackendRuntimeInput, agentId: string): string { + return runtime.config.agents?.[agentId]?.name?.trim() || agentId; +} + +export function sessionAgentDefinition( + runtime: BackendRuntimeInput, + agentId: string, + provider: string, +): AgentDefinition { + const declared = runtime.config.agents?.[agentId]; + if (!declared) throw statusError(`Session Agent '${agentId}' is not present in its pinned runtime.`, 422); + const configuredModel = declared.model; + const providerModel = typeof configuredModel === "string" ? configuredModel : configuredModel[provider]; + const model = + typeof providerModel === "string" + ? providerModel + : providerModel + ? { id: providerModel.id, ...(providerModel.speed ? { speed: providerModel.speed } : {}) } + : undefined; + return { + id: agentId, + agentName: declared.name?.trim() || agentId, + provider, + description: declared.description, + model, + environment: declared.environment, + tools: declared.tools, + skills: (declared.skills ?? []).map((skill) => + typeof skill === "string" + ? { type: "custom" as const, id: skill } + : { type: skill.type, id: skill.skill_id, version: skill.version }, + ), + mcpServers: (declared.mcp_servers ?? []).map((server) => server.name), + }; +} + +function safeFilename(filename: string): string { + return filename.replace(/[^a-zA-Z0-9._-]+/g, "_") || "upload"; +} + +function statusError(message: string, status: number): Error & { status: number } { + return Object.assign(new Error(message), { status }); +} diff --git a/apps/server/tests/project-manager.test.ts b/apps/server/tests/project-manager.test.ts new file mode 100644 index 0000000..c58d43b --- /dev/null +++ b/apps/server/tests/project-manager.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ProjectRuntimeManager } from "../src/services/project-manager"; + +const managers: ProjectRuntimeManager[] = []; + +afterEach(() => { + for (const manager of managers.splice(0)) manager.close(); +}); + +describe("ProjectRuntimeManager", () => { + test("surfaces a missing agents.yaml and watches its parent directory", async () => { + const directory = await mkdtemp(join(tmpdir(), "openagentpack-project-missing-")); + const manager = new ProjectRuntimeManager(join(directory, "agents.yaml")); + managers.push(manager); + + await manager.ensureStarted(); + const summary = await manager.getSummary(); + expect(summary.status).toBe("missing"); + expect(summary.diagnostics[0]?.code).toBe("project.config.missing"); + }); + + test("loads an agents.yaml and changes revision when a referenced instruction changes", async () => { + const directory = await mkdtemp(join(tmpdir(), "openagentpack-project-watch-")); + const instructionPath = join(directory, "instructions.md"); + const configPath = join(directory, "agents.yaml"); + await writeFile(instructionPath, "first instruction\n"); + await writeFile(configPath, validProjectYaml()); + const manager = new ProjectRuntimeManager(configPath); + managers.push(manager); + + await manager.ensureStarted(); + const first = await manager.getSummary(); + expect(first.status).toBe("valid"); + expect(first.agents[0]?.agent.id).toBe("assistant"); + + await writeFile(instructionPath, "second instruction\n"); + manager.scheduleReload(); + await Bun.sleep(350); + const second = await manager.getSummary(); + expect(second.status).toBe("valid"); + expect(second.revision).not.toBe(first.revision); + }); + + test("keeps parsed Agents visible when cross-reference validation is invalid", async () => { + const directory = await mkdtemp(join(tmpdir(), "openagentpack-project-invalid-")); + await mkdir(directory, { recursive: true }); + const configPath = join(directory, "agents.yaml"); + await writeFile(join(directory, "instructions.md"), "instructions\n"); + await writeFile(configPath, validProjectYaml().replace("environment: sandbox", "environment: missing-environment")); + const manager = new ProjectRuntimeManager(configPath); + managers.push(manager); + + await manager.ensureStarted(); + const summary = await manager.getSummary(); + expect(summary.status).toBe("invalid"); + expect(summary.agents[0]?.agent.id).toBe("assistant"); + expect(summary.diagnostics.some((diagnostic) => diagnostic.code === "config.agent.environment.unknown")).toBe(true); + expect(() => manager.requireRuntimeInput()).toThrow(/configuration is invalid/i); + }); + + test("recovers when a missing referenced file is created later", async () => { + const directory = await mkdtemp(join(tmpdir(), "openagentpack-project-missing-reference-")); + const configPath = join(directory, "agents.yaml"); + await writeFile(configPath, validProjectYaml("./prompts/system.md")); + const manager = new ProjectRuntimeManager(configPath); + managers.push(manager); + + await manager.ensureStarted(); + expect(manager.getSnapshot().status).toBe("invalid"); + + await mkdir(join(directory, "prompts")); + await writeFile(join(directory, "prompts/system.md"), "created after Playground startup"); + await waitFor(() => manager.getSnapshot().status === "valid"); + expect(manager.getSnapshot().sourcePaths).toContain(join(directory, "prompts/system.md")); + }); +}); + +function validProjectYaml(instructions = "./instructions.md"): string { + return `version: "1" +providers: + qoder: + api_key: test-token +defaults: + provider: qoder +environments: + sandbox: + config: + type: cloud +agents: + assistant: + model: ultimate + instructions: ${instructions} + environment: sandbox +`; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await Bun.sleep(25); + } + throw new Error("Timed out waiting for project reload"); +} diff --git a/apps/server/tests/project-operations.test.ts b/apps/server/tests/project-operations.test.ts new file mode 100644 index 0000000..e1f64a2 --- /dev/null +++ b/apps/server/tests/project-operations.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { PlanTokenStore, ProjectOperationStore } from "../src/services/project-operations"; + +describe("project plan/apply protocol", () => { + test("binds a plan token to Agent and project revision and consumes it once", () => { + const store = new PlanTokenStore(); + const record = store.issue({ + agentId: "assistant", + projectRevision: "revision-a", + fingerprint: "fingerprint-a", + destructive: false, + }); + expect(store.require(record.token, "assistant", "revision-a").fingerprint).toBe("fingerprint-a"); + expect(() => store.require(record.token, "other", "revision-a")).toThrow(/stale/i); + store.consume(record.token); + expect(() => store.require(record.token, "assistant", "revision-a")).toThrow(/stale/i); + }); + + test("rejects a retained token record after project-wide invalidation", () => { + const store = new PlanTokenStore(); + const record = store.issue({ + agentId: "assistant", + projectRevision: "revision-a", + fingerprint: "fingerprint-a", + destructive: false, + }); + store.invalidateAll(); + + expect(() => store.require(record.token, "assistant", record.projectRevision)).toThrow(/stale/i); + }); + + test("serializes Agent apply operations and retains replayable progress", async () => { + const store = new ProjectOperationStore(); + let finish: (() => void) | undefined; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const operation = store.create("assistant", async (reporter) => { + reporter.emit("phase", { message: "planning" }); + await gate; + return { ok: true }; + }); + await Bun.sleep(0); + expect(() => store.create("assistant", async () => undefined)).toThrow(/already running/i); + finish?.(); + await Bun.sleep(10); + const completed = store.get(operation.id); + expect(completed.status).toBe("completed"); + expect(completed.events.map((event) => event.type)).toContain("phase"); + }); +}); diff --git a/apps/server/tests/project-security.test.ts b/apps/server/tests/project-security.test.ts new file mode 100644 index 0000000..edfebee --- /dev/null +++ b/apps/server/tests/project-security.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { app } from "@/app"; + +const originalToken = process.env.AGENTS_PLAYGROUND_TOKEN; + +afterEach(() => { + if (originalToken === undefined) delete process.env.AGENTS_PLAYGROUND_TOKEN; + else process.env.AGENTS_PLAYGROUND_TOKEN = originalToken; +}); + +describe("Playground local write protection", () => { + test("requires the launch token for every mutating API request", async () => { + process.env.AGENTS_PLAYGROUND_TOKEN = "test-local-token"; + const request = new Request("http://localhost/api/project/agents/assistant/plan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh: false }), + }); + + const denied = await app.request(request); + expect(denied.status).toBe(403); + expect(await denied.json()).toEqual({ error: { message: "Invalid Playground access token." } }); + + const authenticated = await app.request("/api/project/agents/assistant/plan", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Agents-Playground-Token": "test-local-token", + }, + body: JSON.stringify({ refresh: false }), + }); + expect(authenticated.status).not.toBe(403); + }); + + test("does not grant CORS access to an unrelated origin", async () => { + const response = await app.request("/health", { headers: { Origin: "https://example.invalid" } }); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); +}); diff --git a/apps/server/tests/project-sessions.test.ts b/apps/server/tests/project-sessions.test.ts new file mode 100644 index 0000000..e267e0c --- /dev/null +++ b/apps/server/tests/project-sessions.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import type { BackendRuntimeInput, ProviderSessionEvent } from "@openagentpack/sdk"; +import { CreateProjectSessionBodySchema } from "../src/schemas/project"; +import { + assertAttachmentCompatible, + sessionAgentDefinition, + sessionOwnsArtifact, +} from "../src/services/project-sessions"; + +describe("project Session artifacts", () => { + test("only exposes files delivered by the selected Session", () => { + const events: ProviderSessionEvent[] = [ + { + id: "event-1", + type: "tool_call_output", + raw_type: "tool_call_output", + artifact: { file_id: "file-owned", filename: "report.pdf" }, + raw: {}, + }, + ]; + + expect(sessionOwnsArtifact(events, "file-owned")).toBe(true); + expect(sessionOwnsArtifact(events, "file-other")).toBe(false); + }); + + test("ignores malformed artifact metadata", () => { + const events: ProviderSessionEvent[] = [ + { id: "event-1", type: "tool_call_output", raw_type: "tool_call_output", raw: {} }, + { id: "event-2", type: "tool_call_output", raw_type: "tool_call_output", raw: {} }, + ]; + + expect(sessionOwnsArtifact(events, "file-owned")).toBe(false); + }); +}); + +describe("project Session creation", () => { + test("accepts a Session with no initial message", () => { + expect(CreateProjectSessionBodySchema.parse({})).toEqual({}); + expect(CreateProjectSessionBodySchema.parse({ prompt: "First message" })).toEqual({ prompt: "First message" }); + }); + + test("rejects an attachment uploaded through the Agent's previous Provider", () => { + expect(() => + assertAttachmentCompatible( + { + id: "attachment-1", + agent_id: "assistant", + provider: "bailian", + remote_file_id: "file-1", + filename: "context.txt", + available: true, + created_at: new Date().toISOString(), + }, + "assistant", + "qoder", + ), + ).toThrow(/uploaded through Provider 'bailian'.*'qoder'/); + }); + + test("returns a safe Agent capability snapshot from the pinned runtime", () => { + const runtime = { + config: { + agents: { + assistant: { + name: "Assistant", + description: "Pinned description", + instructions: "Help the user", + model: { bailian: { id: "qwen3-max", speed: "fast" } }, + tools: { builtin: ["WebSearch"] }, + skills: ["bailian-cli", { type: "official", skill_id: "web-reader", version: "1" }], + mcp_servers: [{ name: "docs", url: "https://example.invalid/mcp" }], + }, + }, + }, + } as unknown as BackendRuntimeInput; + + expect(sessionAgentDefinition(runtime, "assistant", "bailian")).toEqual({ + id: "assistant", + agentName: "Assistant", + provider: "bailian", + description: "Pinned description", + model: { id: "qwen3-max", speed: "fast" }, + environment: undefined, + tools: { builtin: ["WebSearch"] }, + skills: [ + { type: "custom", id: "bailian-cli" }, + { type: "official", id: "web-reader", version: "1" }, + ], + mcpServers: ["docs"], + }); + }); +}); diff --git a/apps/webui/index.html b/apps/webui/index.html index 405199c..5f66430 100644 --- a/apps/webui/index.html +++ b/apps/webui/index.html @@ -1,10 +1,10 @@ - + - OpenAgentPack 体验中心 - + OpenAgentPack Playground +
diff --git a/apps/webui/package.json b/apps/webui/package.json index 0be0e1c..176916a 100644 --- a/apps/webui/package.json +++ b/apps/webui/package.json @@ -15,7 +15,6 @@ "license": "Apache-2.0", "description": "", "dependencies": { - "@openagentpack/playbooks": "workspace:*", "@tiptap/core": "^3.27.2", "@tiptap/extension-document": "^3.27.2", "@tiptap/extension-mention": "^3.27.2", @@ -32,6 +31,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@openagentpack/playbooks": "workspace:*", "@openagentpack/sdk": "workspace:*", "@types/node": "^25.9.3", "@types/react": "^19.2.17", diff --git a/apps/webui/src/App.tsx b/apps/webui/src/App.tsx index e0bc552..3f7c5e8 100644 --- a/apps/webui/src/App.tsx +++ b/apps/webui/src/App.tsx @@ -1,275 +1,1002 @@ -import { useCallback, useEffect, useReducer, useRef, useState } from "react"; -import BottomBar, { type BottomBarHandle } from "@/components/BottomBar"; -import Composer, { type ComposerHandle } from "@/components/Composer"; -import ConfirmDialog from "@/components/ConfirmDialog"; -import DeploymentCenter from "@/components/DeploymentCenter"; -import GlobalToastHost from "@/components/GlobalToastHost"; -import HeroGreeting from "@/components/HeroGreeting"; -import PromptDialog from "@/components/PromptDialog"; -import { PromptEditorProvider } from "@/components/prompt-editor/PromptEditorProvider"; -import RoleCards from "@/components/RoleCards"; -import ResourceCenter from "@/components/resource-center"; -import SettingsDialog from "@/components/SettingsDialog"; -import Showcase from "@/components/Showcase"; -import TopBar from "@/components/TopBar"; -import WarmBanner from "@/components/WarmBanner"; -import { getModels, type UiModel } from "@/lib/domain/model-api"; -import { type WarmProgress, warmWorkspace } from "@/lib/domain/warm"; -import { useAgentsConfigReady } from "@/lib/hooks/useAgentsConfigReady"; -import { getRoleCards } from "@/lib/playbooks"; -import type { RoleCard } from "@/lib/playbooks/types"; -import { isPlaygroundMode } from "@/lib/runtime-mode"; -import { useProviderConfigRevision } from "@/lib/store/provider-config-store"; -import { useTopBarView } from "@/lib/use-topbar-view"; - -// Fallback while the provider's model list is still loading. An empty string makes createSession -// omit the model, so the backend applies the provider's own default (never a hardcoded id that a -// non-bailian provider would reject). -const DEFAULT_MODEL = ""; - -interface MakeSameInput { - prompt: string; - agentId?: string; -} +import type { PlannedAction, SessionEvent } from "@openagentpack/sdk"; +import { + AlertTriangle, + Box, + Braces, + CheckCircle2, + ChevronRight, + CircleDot, + ExternalLink, + FileText, + LoaderCircle, + Paperclip, + Play, + RefreshCw, + Search, + Send, + ServerCog, + ShieldAlert, + Square, + Trash2, + Upload, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type AgentPlan, + type Attachment, + applyAgent, + cancelSession, + deleteAttachment, + getOperation, + getProject, + listAttachments, + type OperationEvent, + operationEventSource, + type ProjectAgent, + type ProjectSummary, + planAgent, + projectEventSource, + type SessionDetail, + sendSessionMessage, + sessionEventSource, + startSession, + uploadAttachment, +} from "@/lib/project-api"; -// Active-playbook selection: which role is explicitly picked, which the carousel highlights, and a -// transient "做同款" agent override. They change together through the same handlers, so a reducer -// keeps them as one logical unit instead of three independent renders. -interface PlaybookState { - selectedRoleId: string | null; - highlightedIndex: number; - agentOverride: string | null; -} +type WorkbenchTab = "overview" | "changes" | "debug" | "artifacts" | "deployments"; -type PlaybookAction = - | { type: "selectRole"; id: string | null; clearOverride: boolean } - | { type: "setIndex"; index: number } - | { type: "override"; agentId: string | null }; - -function playbookReducer(state: PlaybookState, action: PlaybookAction): PlaybookState { - switch (action.type) { - case "selectRole": - return { ...state, selectedRoleId: action.id, agentOverride: action.clearOverride ? null : state.agentOverride }; - case "setIndex": - return { ...state, highlightedIndex: action.index }; - case "override": - return { ...state, agentOverride: action.agentId }; - } -} +const TAB_LABELS: Array<{ id: WorkbenchTab; label: string }> = [ + { id: "overview", label: "Overview" }, + { id: "changes", label: "Changes" }, + { id: "debug", label: "Debug" }, + { id: "artifacts", label: "Artifacts" }, + { id: "deployments", label: "Deployments" }, +]; +const ACTIVE_OPERATION_KEY = "openagentpack.playground.activeOperation"; + +export default function App() { + const [project, setProject] = useState(); + const [projectError, setProjectError] = useState(); + const [reloading, setReloading] = useState(false); + const [selectedAgentId, setSelectedAgentId] = useState(""); + const [query, setQuery] = useState(""); + const [providerFilter, setProviderFilter] = useState("all"); + const [readinessFilter, setReadinessFilter] = useState("all"); + const [tab, setTab] = useState("overview"); + const [plan, setPlan] = useState(); + const [planBusy, setPlanBusy] = useState(false); + const [applyBusy, setApplyBusy] = useState(false); + const [operationEvents, setOperationEvents] = useState([]); + const [actionError, setActionError] = useState(); + const [attachments, setAttachments] = useState([]); + const [selectedAttachments, setSelectedAttachments] = useState([]); + const [uploadBusy, setUploadBusy] = useState(false); + const [prompt, setPrompt] = useState(""); + const [followup, setFollowup] = useState(""); + const [session, setSession] = useState(); + const [sessionEvents, setSessionEvents] = useState([]); + const [sessionBusy, setSessionBusy] = useState(false); + const operationSourceRef = useRef(null); + const sessionSourceRef = useRef(null); + const projectRef = useRef(undefined); + const projectRequestGenerationRef = useRef(0); + const projectValid = project?.status === "valid"; + + const loadProject = useCallback(async (refresh = false) => { + const requestGeneration = ++projectRequestGenerationRef.current; + try { + const next = await getProject(refresh); + if (requestGeneration !== projectRequestGenerationRef.current) return; + projectRef.current = next; + setProject(next); + setProjectError(undefined); + setSelectedAgentId((current) => + current && next.agents.some((entry) => entry.agent.id === current) ? current : (next.agents[0]?.agent.id ?? ""), + ); + } catch (error) { + if (requestGeneration !== projectRequestGenerationRef.current) return; + setProjectError(errorMessage(error)); + } finally { + if (requestGeneration === projectRequestGenerationRef.current) setReloading(false); + } + }, []); -export default function Home() { - const [view, setView] = useTopBarView(); - const [settingsOpen, setSettingsOpen] = useState(false); - const showSettings = isPlaygroundMode(); - const providerRevision = useProviderConfigRevision(); - const { ready: providerConfigReady } = useAgentsConfigReady(showSettings, providerRevision); - const canSubmit = !showSettings || providerConfigReady; - const [inputValue, setInputValue] = useState(""); - const [playbook, dispatchPlaybook] = useReducer(playbookReducer, { - selectedRoleId: null, - highlightedIndex: 0, - agentOverride: null, - }); - const [roleCards, setRoleCards] = useState([]); - const [models, setModels] = useState([]); - const [selectedModelsByAgent, setSelectedModelsByAgent] = useState>({}); - const [warmProgress, setWarmProgress] = useState(null); - // Only read inside handlers (top composer vs. bottom bar routing), never rendered — a ref avoids - // re-rendering the whole page each time the bar scrolls in or out of view. - const bottomBarVisibleRef = useRef(false); - const bottomBarRef = useRef(null); - const composerRef = useRef(null); - const composerHandleRef = useRef(null); - - const { selectedRoleId, highlightedIndex, agentOverride } = playbook; - - // Active playbook → agent slug. A "做同款" override wins; otherwise the explicitly - // selected role, otherwise the carousel-highlighted role. Never a hardcoded id. - const activeRole = selectedRoleId ? roleCards.find((r) => r.slug === selectedRoleId) : roleCards[highlightedIndex]; - const activeAgentSlug = agentOverride ?? activeRole?.slug ?? roleCards[0]?.slug ?? ""; - // Per-agent explicit pick wins; otherwise the provider's first model; otherwise "" (backend - // applies the provider default). Never a hardcoded id — that's what broke non-bailian providers. - const selectedModel = - (activeAgentSlug ? selectedModelsByAgent[activeAgentSlug] : undefined) ?? models[0]?.id ?? DEFAULT_MODEL; - - // biome-ignore lint/correctness/useExhaustiveDependencies: providerRevision 触发整页数据重拉 useEffect(() => { - let cancelled = false; - void getModels().then((next) => { - if (cancelled) return; - setModels(next); - // 清空旧 provider 下的模型选择,避免把不兼容 model id 提交出去 - setSelectedModelsByAgent({}); + void loadProject(); + const source = projectEventSource(); + source.addEventListener("project.snapshot", (event) => { + let snapshot: { status?: unknown; revision?: unknown } | undefined; + try { + snapshot = JSON.parse((event as MessageEvent).data) as typeof snapshot; + } catch { + // Reload below when an unexpected snapshot payload cannot be compared safely. + } + const current = projectRef.current; + if (current && current.status === snapshot?.status && current.revision === snapshot.revision) return; + setPlan(undefined); + setOperationEvents([]); + void loadProject(); }); - return () => { - cancelled = true; - }; - }, [providerRevision]); + source.addEventListener("project.reloading", () => { + projectRequestGenerationRef.current++; + setReloading(true); + }); + for (const type of ["project.valid", "project.invalid", "project.missing"] as const) { + source.addEventListener(type, () => { + setPlan(undefined); + setOperationEvents([]); + void loadProject(); + }); + } + return () => source.close(); + }, [loadProject]); - // biome-ignore lint/correctness/useExhaustiveDependencies: providerRevision 触发新 provider 预热 useEffect(() => { - setWarmProgress(null); - void warmWorkspace(setWarmProgress); - }, [providerRevision]); + setPlan(undefined); + setActionError(undefined); + setSelectedAttachments([]); + if (!selectedAgentId) { + setAttachments([]); + return; + } + void listAttachments(selectedAgentId) + .then(setAttachments) + .catch((error) => setActionError(errorMessage(error))); + }, [selectedAgentId]); + const hasPendingAttachments = attachments.some( + (attachment) => !attachment.available && attachment.status !== "capability_unavailable", + ); useEffect(() => { - let cancelled = false; - void getRoleCards().then((cards) => { - if (cancelled) return; - setRoleCards(cards); - if (providerRevision > 0) { - dispatchPlaybook({ type: "selectRole", id: null, clearOverride: true }); - dispatchPlaybook({ type: "setIndex", index: 0 }); + if (!selectedAgentId || !projectValid || !hasPendingAttachments) return; + const timer = setInterval(() => { + void listAttachments(selectedAgentId) + .then(setAttachments) + .catch((error) => setActionError(errorMessage(error))); + }, 3_000); + return () => clearInterval(timer); + }, [hasPendingAttachments, projectValid, selectedAgentId]); + + useEffect( + () => () => { + operationSourceRef.current?.close(); + sessionSourceRef.current?.close(); + }, + [], + ); + + const selectedAgent = project?.agents.find((entry) => entry.agent.id === selectedAgentId); + const providers = useMemo( + () => [...new Set((project?.agents ?? []).map((entry) => entry.agent.provider))].sort(), + [project?.agents], + ); + const filteredAgents = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + return (project?.agents ?? []).filter((entry) => { + if (providerFilter !== "all" && entry.agent.provider !== providerFilter) return false; + if (readinessFilter !== "all" && entry.readiness.status !== readinessFilter) return false; + return ( + !normalizedQuery || + entry.agent.id.toLowerCase().includes(normalizedQuery) || + (entry.agent.description ?? "").toLowerCase().includes(normalizedQuery) + ); + }); + }, [project?.agents, providerFilter, query, readinessFilter]); + + const connectOperation = useCallback( + (operationId: string) => { + operationSourceRef.current?.close(); + const source = operationEventSource(operationId); + operationSourceRef.current = source; + source.addEventListener("event", (event) => { + const operationEvent = JSON.parse((event as MessageEvent).data) as OperationEvent; + setOperationEvents((current) => [ + ...current.filter((item) => item.index !== operationEvent.index), + operationEvent, + ]); + }); + source.addEventListener("done", (event) => { + const result = JSON.parse((event as MessageEvent).data) as { status: string; error?: string | null }; + setApplyBusy(false); + setPlan(undefined); + sessionStorage.removeItem(ACTIVE_OPERATION_KEY); + if (result.error) setActionError(result.error); + void loadProject(true); + source.close(); + }); + source.onerror = () => { + setActionError("Apply progress stream disconnected; reconnecting with the same operation ID…"); + void getOperation(operationId).catch((error) => { + if ((error as { status?: number }).status !== 404) return; + setApplyBusy(false); + setActionError( + "The Playground server restarted and interrupted this Apply. Create a fresh Plan before retrying.", + ); + sessionStorage.removeItem(ACTIVE_OPERATION_KEY); + source.close(); + }); + }; + source.onopen = () => setActionError(undefined); + }, + [loadProject], + ); + + useEffect(() => { + const operationId = sessionStorage.getItem(ACTIVE_OPERATION_KEY); + if (!operationId) return; + setApplyBusy(true); + connectOperation(operationId); + }, [connectOperation]); + + const handlePlan = async () => { + if (!selectedAgent) return; + setPlanBusy(true); + setActionError(undefined); + setOperationEvents([]); + try { + setPlan(await planAgent(selectedAgent.agent.id)); + } catch (error) { + setActionError(errorMessage(error)); + } finally { + setPlanBusy(false); + } + }; + + const handleApply = async () => { + if (!selectedAgent || !plan) return; + if (plan.destructive && !window.confirm("This plan deletes remote resources. Apply the reviewed plan?")) return; + setApplyBusy(true); + setActionError(undefined); + setOperationEvents([]); + try { + const accepted = await applyAgent(selectedAgent.agent.id, plan.plan_token, plan.destructive); + sessionStorage.setItem(ACTIVE_OPERATION_KEY, accepted.operation_id); + connectOperation(accepted.operation_id); + } catch (error) { + setApplyBusy(false); + setActionError(errorMessage(error)); + } + }; + + const handleUpload = async (fileList: FileList | null) => { + if (!selectedAgent || !fileList?.length) return; + setUploadBusy(true); + setActionError(undefined); + try { + for (const file of Array.from(fileList)) { + const attachment = await uploadAttachment(selectedAgent.agent.id, file); + setAttachments((current) => [...current, attachment]); + if (attachment.available) setSelectedAttachments((current) => [...current, attachment.id]); } + } catch (error) { + setActionError(errorMessage(error)); + } finally { + setUploadBusy(false); + } + }; + + const handleDeleteAttachment = async (attachmentId: string) => { + setActionError(undefined); + try { + await deleteAttachment(attachmentId); + setAttachments((current) => current.filter((attachment) => attachment.id !== attachmentId)); + setSelectedAttachments((current) => current.filter((id) => id !== attachmentId)); + } catch (error) { + setActionError(errorMessage(error)); + } + }; + + const connectSession = (sessionId: string, initialEvents: SessionEvent[]) => { + setSessionEvents(initialEvents); + sessionSourceRef.current?.close(); + const source = sessionEventSource(sessionId, initialEvents.length - 1); + sessionSourceRef.current = source; + source.addEventListener("event", (event) => { + const sessionEvent = JSON.parse((event as MessageEvent).data) as SessionEvent; + setSessionEvents((current) => { + if (sessionEvent.event_id && current.some((entry) => entry.event_id === sessionEvent.event_id)) return current; + return [...current, sessionEvent]; + }); + }); + source.addEventListener("done", () => { + setSessionBusy(false); + source.close(); }); - return () => { - cancelled = true; + source.onerror = () => { + setActionError("Session event stream disconnected; reconnecting from the last received event…"); }; - }, [providerRevision]); - - // "做同款" context-aware handler - const handleMakeSame = useCallback((input: MakeSameInput) => { - dispatchPlaybook({ type: "override", agentId: input.agentId ?? null }); - - if (bottomBarVisibleRef.current) { - // Fill bottom bar - setInputValue(input.prompt); - bottomBarRef.current?.expand(); - } else { - // Fill top composer - setInputValue(input.prompt); - window.scrollTo({ top: 0, behavior: "smooth" }); - setTimeout(() => composerHandleRef.current?.focus(), 400); + source.onopen = () => setActionError(undefined); + }; + + const handleStartSession = async () => { + if (!selectedAgent || !prompt.trim()) return; + setSessionBusy(true); + setActionError(undefined); + try { + const detail = await startSession(selectedAgent.agent.id, prompt.trim(), selectedAttachments); + setSession(detail); + setPrompt(""); + connectSession(detail.session.session_id, detail.events); + } catch (error) { + setSessionBusy(false); + setActionError(errorMessage(error)); } - }, []); + }; - const handleBottomBarVisibility = useCallback((visible: boolean) => { - bottomBarVisibleRef.current = visible; - }, []); + const handleFollowup = async () => { + if (!session || !followup.trim()) return; + setSessionBusy(true); + setActionError(undefined); + try { + const detail = await sendSessionMessage(session.session.session_id, followup.trim()); + setSession(detail); + setFollowup(""); + connectSession(detail.session.session_id, detail.events); + } catch (error) { + setSessionBusy(false); + setActionError(errorMessage(error)); + } + }; - // 选中角色时自动填充输入框 - const handleSelectRole = useCallback( - (id: string | null) => { - const role = id ? roleCards.find((r) => r.slug === id) : undefined; - const hasPrompt = !!role?.prompt; - dispatchPlaybook({ type: "selectRole", id, clearOverride: hasPrompt }); - if (!id || !hasPrompt) return; - setInputValue(role.prompt); - if (bottomBarVisibleRef.current) { - bottomBarRef.current?.expand(); - } else { - setTimeout(() => composerHandleRef.current?.focusStart(), 80); - } - }, - [roleCards], + const handleCancel = async () => { + if (!session) return; + try { + await cancelSession(session.session.session_id); + setSessionBusy(false); + sessionSourceRef.current?.close(); + } catch (error) { + setActionError(errorMessage(error)); + } + }; + + return ( +
+
+
+ + OpenAgentPack + Playground +
+
+ {project?.project_name ?? "Loading project"} + {project?.config_file ?? "agents.yaml"} +
+
+ + {project?.revision && {project.revision.slice(0, 9)}} + +
+
+ + {projectError && } + {project && project.status !== "valid" && ( + + )} + {project?.diagnostics.map((diagnostic) => ( + + ))} + +
+ + +
+ {selectedAgent ? ( + <> +
+
+
{selectedAgent.agent.provider} / agent
+
+

{selectedAgent.agent.id}

+ + + Preview + +
+

{selectedAgent.agent.description ?? "No description declared."}

+
+ +
+ + {actionError && } + {tab === "overview" && } + {tab === "changes" && ( + + )} + {tab === "debug" && ( + + setSelectedAttachments((current) => + current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id], + ) + } + onUpload={handleUpload} + onDeleteAttachment={handleDeleteAttachment} + onStart={handleStartSession} + onFollowupSend={handleFollowup} + onCancel={handleCancel} + /> + )} + {tab === "artifacts" && ( + + )} + {tab === "deployments" && ( + deployment.agent === selectedAgent.agent.id, + )} + /> + )} + + ) : ( +
+ +

No Agent selected

+

Add an Agent to agents.yaml or adjust the filters.

+
+ )} +
+
+
); +} - const handleActiveIndexChange = useCallback((idx: number) => { - dispatchPlaybook({ type: "setIndex", index: idx }); - }, []); +function Overview({ agent }: { agent: ProjectAgent }) { + return ( +
+ } + title="Runtime" + rows={[ + ["Provider", agent.agent.provider], + ["Model", formatValue(agent.agent.model)], + ["Environment", agent.details.environment ?? "—"], + ["Vault", agent.details.vault ?? "—"], + ]} + /> + } + title="Tools & MCP" + rows={[ + ["Builtins", formatValue((agent.agent.tools as { builtin?: string[] } | undefined)?.builtin ?? [])], + ["MCP servers", agent.agent.mcpServers.join(", ") || "—"], + ]} + /> + } + title="Skills & memory" + rows={[ + ["Skills", agent.agent.skills.map((skill) => skill.id).join(", ") || "—"], + ["Memory stores", agent.details.memory_stores.join(", ") || "—"], + ]} + /> + } + title="Declared resources" + rows={ + agent.details.resources.length + ? agent.details.resources.map((resource) => [resource.type, resource.mount_path ?? "default mount"]) + : [["Resources", "—"]] + } + /> +
+ ); +} - // Model switching is local per playbook. The selected model rides createSession, where both - // transports sync the agent immediately before starting the run. - const handleModelChange = useCallback( - (model: string) => { - if (!activeAgentSlug) return; - setSelectedModelsByAgent((prev) => ({ ...prev, [activeAgentSlug]: model })); - }, - [activeAgentSlug], +function ChangesPanel({ + agent, + plan, + planBusy, + applyBusy, + projectValid, + operationEvents, + onPlan, + onApply, +}: { + agent: ProjectAgent; + plan?: AgentPlan; + planBusy: boolean; + applyBusy: boolean; + projectValid: boolean; + operationEvents: OperationEvent[]; + onPlan(): void; + onApply(): void; +}) { + return ( +
+
+
+

Runtime resource plan

+

Only {agent.agent.id} and its transitive runtime dependencies are in scope. Deployments are excluded.

+
+
+ + +
+
+ {plan ? ( +
+
+ {plan.actions.filter((action) => action.action !== "no-op").length} changes + + {plan.destructive ? ( + <> + destructive + + ) : ( + <> + non-destructive + + )} + + {plan.fingerprint.slice(0, 12)} +
+ {plan.actions.map((action) => ( + + ))} +
+ ) : ( +
+ +

Create a fresh plan to compare agents.yaml, state, and remote resources.

+
+ )} + {operationEvents.length > 0 && ( +
+

Apply progress

+ {operationEvents.map((event) => ( +
+ + {event.type} + {operationMessage(event.data)} +
+ ))} +
+ )} +
); +} +function DebugPanel({ + projectValid, + attachments, + selectedAttachments, + uploadBusy, + prompt, + followup, + session, + events, + busy, + onPrompt, + onFollowup, + onToggleAttachment, + onUpload, + onDeleteAttachment, + onStart, + onFollowupSend, + onCancel, +}: { + agent: ProjectAgent; + projectValid: boolean; + attachments: Attachment[]; + selectedAttachments: string[]; + uploadBusy: boolean; + prompt: string; + followup: string; + session?: SessionDetail; + events: SessionEvent[]; + busy: boolean; + onPrompt(value: string): void; + onFollowup(value: string): void; + onToggleAttachment(id: string): void; + onUpload(files: FileList | null): void; + onDeleteAttachment(id: string): void; + onStart(): void; + onFollowupSend(): void; + onCancel(): void; +}) { return ( - - - {view === "resources" || view === "deployments" ? ( - <> -
- - setSettingsOpen(true)} +
+
+
+
+
+

Temporary attachments

+

Uploaded for Sessions only; never written to agents.yaml.

+
+ +
+
+ {attachments.map((attachment) => ( +
+ onToggleAttachment(attachment.id)} + /> + + + {attachment.filename} + {attachment.status ?? (attachment.available ? "available" : "pending")} + + +
+ ))} + {attachments.length === 0 &&

No temporary attachments.

} +
+
+
+

Start a Session

+