diff --git a/.changeset/config.json b/.changeset/config.json index 410700a..1afef7f 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,15 @@ "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, - "fixed": [["@openagentpack/sdk", "@openagentpack/playground", "@openagentpack/cli"]], + "fixed": [ + [ + "@openagentpack/sdk", + "@openagentpack/project-versions", + "@openagentpack/project-workspace", + "@openagentpack/playground", + "@openagentpack/cli" + ] + ], "linked": [], "access": "public", "baseBranch": "main", diff --git a/.changeset/project-playground-debugger.md b/.changeset/project-playground-debugger.md new file mode 100644 index 0000000..04ea3f9 --- /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 project-aware debugging. `agents playground` remains a read-only `agents.yaml` Session Preview, while `agents project workbench` watches and edits a directory project, performs fingerprint-protected Build/Plan/Publish operations, streams operation and Session events, and manages explicit temporary attachment cleanup. + +Add SDK source-path tracking, runtime-scoped Agent planning, full-project planning, stable plan fingerprints, and stale-plan enforcement. Preview stays at `agents playground -f/--file [--agent ]`; the project console moves under `agents project workbench --project `. diff --git a/.changeset/project-versions-package.md b/.changeset/project-versions-package.md new file mode 100644 index 0000000..9c691a4 --- /dev/null +++ b/.changeset/project-versions-package.md @@ -0,0 +1,13 @@ +--- +"@openagentpack/sdk": minor +"@openagentpack/project-versions": minor +"@openagentpack/project-workspace": minor +"@openagentpack/playground": minor +"@openagentpack/cli": minor +--- + +Publish a directory workspace service for deterministic Build/Publish and a +Git-independent full source-tree snapshot engine. CLI and Workbench share one +versioning switch, content-addressed text/binary blobs, forward restore, and a +cross-process mutation lock while remote State remains excluded. Project Build +previews now show full directory source changes against the current version HEAD. diff --git a/.changeset/workbench-resource-editing.md b/.changeset/workbench-resource-editing.md new file mode 100644 index 0000000..1c13af5 --- /dev/null +++ b/.changeset/workbench-resource-editing.md @@ -0,0 +1,5 @@ +--- +"@openagentpack/playground": minor +--- + +Add Workbench editing and removal for resources already present in a directory project. Agent JSON, instructions Markdown, Skill metadata/content, and project resources use server-side redacted previews, revision conflicts, reference protection, explicit Build, full-project Plan, and separately confirmed Publish. diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 3ce2bc2..fdbbdac 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -4,14 +4,14 @@ module.exports = { { name: "no-cli-to-server", severity: "error", - comment: "The CLI host must consume shared behavior through @openagentpack/sdk, not by importing the API server.", + comment: "The CLI host must consume shared behavior through published engine packages, not the API server.", from: { path: "^packages/cli/" }, to: { path: "^apps/server/" }, }, { name: "no-server-to-cli", severity: "error", - comment: "The API server must consume shared behavior through @openagentpack/sdk, not by importing CLI code.", + comment: "The API server must consume shared behavior through published engine packages, not CLI code.", from: { path: "^apps/server/" }, to: { path: "^packages/cli/" }, }, @@ -22,6 +22,21 @@ module.exports = { from: { path: "^packages/sdk/" }, to: { path: "^(packages/cli|apps/server|apps/webui)/" }, }, + { + name: "no-project-versions-to-hosts-or-apps", + severity: "error", + comment: + "@openagentpack/project-versions is a shared Node engine and must not depend on host packages or applications.", + from: { path: "^packages/project-versions/" }, + to: { path: "^(packages/cli|packages/playground|apps/server|apps/webui)/" }, + }, + { + name: "no-project-workspace-to-hosts-or-apps", + severity: "error", + comment: "@openagentpack/project-workspace is a shared Node engine and must not depend on hosts or applications.", + from: { path: "^packages/project-workspace/" }, + to: { path: "^(packages/cli|packages/playground|apps/server|apps/webui)/" }, + }, { name: "no-sdk-deep-imports", severity: "error", @@ -42,6 +57,20 @@ module.exports = { from: { path: "^apps/webui/src/" }, to: { path: "^packages/sdk/" }, }, + { + name: "no-webui-project-versions-runtime-import", + severity: "error", + comment: "Browser-facing code must use Workbench APIs instead of importing the Node-only version engine.", + from: { path: "^apps/webui/src/" }, + to: { path: "^packages/project-versions/" }, + }, + { + name: "no-webui-project-workspace-runtime-import", + severity: "error", + comment: "Browser-facing code must use Workbench APIs instead of importing the Node-only workspace engine.", + from: { path: "^apps/webui/src/" }, + to: { path: "^packages/project-workspace/" }, + }, ], options: { doNotFollow: { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e36a1b..194d8b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: fail-fast: false matrix: include: - # Full consumer smoke (sdk + playground + cli) on the repo baseline. + # Full consumer smoke (sdk + local-git + playground + cli) on the repo baseline. - node: 22 scope: all - node: 24 diff --git a/README.md b/README.md index ddd6dbb..de491e6 100644 --- a/README.md +++ b/README.md @@ -77,14 +77,19 @@ The mechanics are a single `agents.yaml`, a `validate → plan → apply` workfl ## Quick start ```bash -agents init # interactive wizard writes a starter agents.yaml -agents validate # offline YAML check, no API calls -agents plan # preview create / update / delete -agents apply -y # apply changes -agents destroy # tear down managed resources +agents project init # create a directory project (or convert agents.yaml) +agents project validate # validate JSON, Markdown, skills, and local files +agents project build --dry-run # preview organization and generated YAML +agents project build -y # freeze the current source into a Build +agents project publish -y # publish exactly that Build and record a version +agents project workbench # edit and debug the same directory project ``` -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. +Directory projects keep global settings in `project.json`, each Agent under `agents//`, Agent instructions in `instructions.md`, and local Skill source either beside its Agent or under the shared `skills/` directory. Build promotes a Skill to the shared directory when multiple Agents reference it and deterministically writes `.openagentpack/build/agents.yaml`. Publish never runs Build implicitly. + +Workbench and CLI share `agents project version status|enable|disable|list|preview|restore`. Versions are Git-independent full source-tree snapshots: immutable manifests point to content-addressed text and binary blobs, while `.openagentpack/state.json` is always excluded. Restore writes a historical tree forward into the working directory without moving version history or remote State. Deployment and Channel declarations remain read-only in Workbench but participate in full project Publish. + +The original YAML workflow remains available through `agents init`, `validate`, `plan`, `apply`, and `destroy`. `agents playground -f agents.yaml` continues to open a YAML Agent Session Preview, but YAML Apply no longer creates project versions and cannot be used inside a directory-project root. ▶ [Watch the full Playground demo](https://github.com/user-attachments/assets/bf51b8d8-f2ed-464b-bca9-0709fefcc44d) @@ -172,28 +177,31 @@ The [`examples/`](./examples) directory has runnable configs for every provider, ## Using the SDK -Everything the CLI does is available programmatically from `@openagentpack/sdk`: +Cloud runtime capabilities are available from `@openagentpack/sdk`. Directory compilation, Build/Publish, and full-tree versions are exposed by `@openagentpack/project-workspace`, backed by the storage primitives in `@openagentpack/project-versions`: ```ts -import { resolveProjectConfig, planProjectContext } from "@openagentpack/sdk"; - -const config = await resolveProjectConfig({ configPath: "agents.yaml" }); -const plan = await planProjectContext(config); -console.log(plan); +import { previewProjectBuild, commitProjectBuild } from "@openagentpack/project-workspace"; + +const preview = await previewProjectBuild("./my-agent"); +const build = await commitProjectBuild({ + projectRoot: preview.project_root, + baseRevision: preview.project_revision, +}); +console.log(build.manifest); ``` 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 directory-project Workbench; `apps/server` exposes directory editing, Build/Publish, versions, and Session debugging over an OpenAPI surface. Run both from the repo root with `AGENTS_PROJECT_ROOT` pointing at a project: ```bash bun install bun run dev # server + webui together ``` -Or launch a packaged local UI with `agents playground --provider `. +Launch the packaged project console with `agents project workbench --project `. Use `agents playground -f ` only for the legacy YAML Session Preview. Workbench edits directory source, requires an explicit Build, and publishes the reviewed Build; it never edits Provider ownership or pushes Git state. ## Contributing diff --git a/README.zh-CN.md b/README.zh-CN.md index b941e1e..9e4aec8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -81,10 +81,13 @@ agents init # 交互式向导生成 agents.yaml agents validate # 离线校验,不发起 API 调用 agents plan # 预览 create / update / delete agents apply -y # 执行变更 +agents version enable # 可选:成功 Apply 后版本化 YAML 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,并监听本地依赖文件。Workbench 的 Resources 页面可以通过服务端生成的 YAML Diff 编辑或移除已有声明;保存更新 `agents.yaml` 并自动刷新项目 Plan。本地版本默认不存在,只有用户在 Versions 页面或通过 `agents version enable` 显式启用后才会创建基线;开关启用时,成功 Apply 后自动版本化有变化的 YAML。Versions 页面与 CLI 共用开关,并浏览和恢复历史。 + +CLI 也提供 `agents version status|enable|disable|list|preview|restore`。Workbench 与 CLI 按当前 `agents.yaml` 共用一个本地开关:`agents version enable` 会在需要时创建基线并开启两边的成功 Apply 自动版本,`version disable` 会同时关闭两边。`store.json` 只保存开关和 head,`entries/` 保存不可变链式元数据,完整 YAML 保存在内容寻址的 `blobs/` 中,因此不依赖 Git。Restore 只把历史 YAML 写回工作区,不移动版本历史;`agents.state.json` 和外部引用文件始终不进入版本。Deployment 和 Channel 声明继续只读,且不进入 Workbench 项目 Apply。配置缺失或非法时进入诊断 Workbench。 ▶ [观看 Playground 完整演示](https://github.com/user-attachments/assets/bf51b8d8-f2ed-464b-bca9-0709fefcc44d) @@ -172,10 +175,11 @@ Beta 用户可以安装 `@openagentpack/cli@beta`;固定版本及切回稳定 ## 使用 SDK -CLI 的全部能力都可通过 `@openagentpack/sdk` 以编程方式调用: +云端项目运行能力可通过 `@openagentpack/sdk` 以编程方式调用;仅限 Node.js 的本地项目版本能力由独立包 `@openagentpack/project-versions` 提供: ```ts import { resolveProjectConfig, planProjectContext } from "@openagentpack/sdk"; +import { createProjectVersionService } from "@openagentpack/project-versions"; const config = await resolveProjectConfig({ configPath: "agents.yaml" }); const plan = await planProjectContext(config); @@ -186,14 +190,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..dd60ce9 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -6,851 +6,1807 @@ }, "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 + "active_mutation": { + "type": "object", + "nullable": true, + "properties": { + "kind": { + "type": "string", + "enum": [ + "agent_apply", + "project_apply", + "project_build", + "declaration_write", + "version_enable", + "version_write", + "version_restore" + ] + }, + "started_at": { + "type": "string" + }, + "operation_id": { + "type": "string" + } + }, + "required": ["kind", "started_at"] + }, + "build": { + "type": "object", + "properties": { + "exists": { + "type": "boolean" + }, + "stale": { + "type": "boolean" + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "yaml_hash": { + "type": "string" + } + }, + "required": ["exists", "stale", "reasons"] } }, - "required": ["events"] + "required": [ + "status", + "config_file", + "project_name", + "diagnostics", + "agents", + "deployments", + "active_mutation", + "build" + ] }, - "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": { + "ProjectDeclarationsResponse": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "name": { + "revision": { "type": "string" }, - "description": { - "type": "string" - }, - "source": { - "type": "string", - "enum": ["custom", "official"] - }, - "status": { - "type": "string", - "enum": ["checking", "active", "rejected", "deleted"] - }, - "latest_version": { - "type": "string" + "resources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["agent", "environment", "skill", "vault", "memory_store", "file"] + }, + "id": { + "type": "string" + }, + "declaration": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "read_only_paths": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "references": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["type", "id", "path"] + } + } + }, + "required": ["type", "id", "declaration", "read_only_paths", "references"] + } + } + }, + "required": ["revision", "resources"] + }, + "DeclarationPreviewResponse": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["agent", "environment", "skill", "vault", "memory_store", "file"] }, - "created_at": { + "id": { "type": "string" }, - "updated_at": { + "action": { + "type": "string", + "enum": ["update", "delete"] + }, + "base_revision": { "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" - } - } - } + "before_yaml": { + "type": "string" }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "after_yaml": { + "type": "string" }, - "409": { - "description": "Conflict", - "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"] } }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "references": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "path": { + "type": "string" } - } + }, + "required": ["type", "id", "path"] } + }, + "can_commit": { + "type": "boolean" } - } + }, + "required": [ + "type", + "id", + "action", + "base_revision", + "before_yaml", + "after_yaml", + "diagnostics", + "references", + "can_commit" + ] }, - "put": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SaveAgentsConfigBody" + "DeclarationCommitResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/DeclarationPreviewResponse" + }, + { + "type": "object", + "properties": { + "new_revision": { + "type": "string" } - } + }, + "required": ["new_revision"] } - }, - "responses": { - "200": { - "description": "Save local OpenAgentPack playground config (~/.agents/config.json)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentsConfig" - } - } - } + ] + }, + "ProjectVersioningStatus": { + "type": "object", + "properties": { + "initialized": { + "type": "boolean" }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "enabled": { + "type": "boolean" }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "store_root": { + "type": "string" }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } + "config_path": { + "type": "string" + }, + "head_version": { + "type": "string", + "nullable": true + }, + "source_status": { + "type": "string", + "enum": ["clean", "modified", "unversioned"] + }, + "source_versioned": { + "type": "boolean" + }, + "write_blockers": { + "type": "array", + "items": { + "type": "string" } }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } + "restore_blockers": { + "type": "array", + "items": { + "type": "string" } } - } - } - }, - "/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" - } - } + }, + "required": [ + "initialized", + "enabled", + "store_root", + "config_path", + "head_version", + "source_status", + "source_versioned", + "write_blockers", + "restore_blockers" + ] + }, + "ProjectVersionsResponse": { + "type": "object", + "properties": { + "versions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectVersion" } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "next_cursor": { + "type": "string", + "nullable": true + } + }, + "required": ["versions", "next_cursor"] + }, + "ProjectVersion": { + "type": "object", + "properties": { + "version_id": { + "type": "string" }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "short_version": { + "type": "string" }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "parent_version": { + "type": "string", + "nullable": true }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "source_hash": { + "type": "string" + }, + "message": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "version_id", + "short_version", + "parent_version", + "source_hash", + "message", + "created_by", + "created_at" + ] + }, + "ProjectVersionPreview": { + "type": "object", + "properties": { + "version_id": { + "type": "string" + }, + "base_revision": { + "type": "string" + }, + "base_head_version": { + "type": "string" + }, + "before_yaml": { + "type": "string" + }, + "after_yaml": { + "type": "string" + }, + "changes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "change": { + "type": "string", + "enum": ["create", "update", "delete"] + }, + "binary": { + "type": "boolean" + }, + "before": { + "type": "string" + }, + "after": { + "type": "string" } - } + }, + "required": ["path", "change", "binary"] } - } - } - } - }, - "/api/deployments": { - "get": { - "responses": { - "200": { - "description": "List managed deployments", - "content": { - "application/json": { - "schema": { + }, + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "severity": { + "type": "string", + "enum": ["error", "warning", "info"] + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "resource": { "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"] - } + "type": { + "type": "string", + "enum": [ + "environment", + "vault", + "memory_store", + "skill", + "agent", + "template", + "deployment", + "file", + "identity", + "channel" + ] + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" } }, - "required": ["deployments"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "required": ["type", "name", "provider"] } - } + }, + "required": ["severity", "code", "message"] } }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "can_restore": { + "type": "boolean" }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } + "blockers": { + "type": "array", + "items": { + "type": "string" } + } + }, + "required": [ + "version_id", + "base_revision", + "base_head_version", + "before_yaml", + "after_yaml", + "changes", + "diagnostics", + "can_restore", + "blockers" + ] + }, + "ProjectVersionRestoreResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ProjectVersionPreview" }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } + { + "type": "object", + "properties": { + "new_revision": { + "type": "string" } - } + }, + "required": ["new_revision"] } - } + ] }, - "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" - } + "ProjectBuildResponse": { + "type": "object", + "properties": { + "project_revision": { + "type": "string" + }, + "before_yaml": { + "type": "string" + }, + "after_yaml": { + "type": "string" + }, + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "severity": { + "type": "string", + "enum": ["error", "warning", "info"] }, - "required": ["name", "playbookId", "prompt", "expression"] - } - } - } - }, - "responses": { - "201": { - "description": "Create a native deployment", - "content": { - "application/json": { - "schema": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "resource": { "type": "object", "properties": { - "id": { - "type": "string" + "type": { + "type": "string", + "enum": [ + "environment", + "vault", + "memory_store", + "skill", + "agent", + "template", + "deployment", + "file", + "identity", + "channel" + ] }, "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": ["type", "name", "provider"] } - } + }, + "required": ["severity", "code", "message"] } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "warnings": { + "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"] + } }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "organization_moves": { + "type": "array", + "items": { + "type": "object", + "properties": { + "skill_id": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": ["shared"] + } + }, + "required": ["skill_id", "from", "to", "reason"] + } + }, + "can_build": { + "type": "boolean" + }, + "manifest": { + "type": "object", + "properties": { + "schema_version": { + "type": "number", + "enum": [1] + }, + "project_revision": { + "type": "string" + }, + "source_manifest_hash": { + "type": "string" + }, + "yaml_hash": { + "type": "string" + }, + "built_at": { + "type": "string" + } + }, + "required": ["schema_version", "project_revision", "source_manifest_hash", "yaml_hash", "built_at"] + } + }, + "required": [ + "project_revision", + "before_yaml", + "after_yaml", + "diagnostics", + "warnings", + "organization_moves", + "can_build" + ] + }, + "ProjectPlanResponse": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "enum": ["project_runtime"] + }, + "project_revision": { + "type": "string" + }, + "plan_token": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "actions": { + "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"] + } + }, + "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"] + } + }, + "destructive": { + "type": "boolean" + } + }, + "required": [ + "scope", + "project_revision", + "plan_token", + "expires_at", + "fingerprint", + "actions", + "diagnostics", + "destructive" + ] + }, + "ProjectApplyResponse": { + "type": "object", + "properties": { + "operation_id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["queued"] + } + }, + "required": ["operation_id", "status"] + }, + "AgentPlanResponse": { + "type": "object", + "properties": { + "agent_id": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "project_revision": { + "type": "string" + }, + "plan_token": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "actions": { + "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"] + } + }, + "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"] + } + }, + "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" + }, + "status": { + "type": "string", + "enum": ["queued"] + } + }, + "required": ["operation_id", "status"] + }, + "CreateProjectSessionResponse": { + "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": { + "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 + } + } + }, + "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" + }, + { + "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"] + } + }, + "required": ["session", "events", "provider", "agent_id", "agent_name", "agent_details"] + }, + "ProjectSessionArtifactDownload": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "expires_at": { + "type": "string" + } + }, + "required": ["url"] + }, + "OperationResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["agent.apply", "project.apply"] + }, + "agent_id": { + "type": "string" + }, + "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"] } }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "result": { + "nullable": true }, - "500": { - "description": "Server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "error": { + "type": "string" } - } + }, + "required": ["id", "type", "status", "created_at", "updated_at", "events"] } }, - "/api/deployments/{id}/paused": { - "put": { + "parameters": {} + }, + "paths": { + "/api/project": { + "get": { "parameters": [ { "schema": { "type": "string", - "minLength": 1 + "enum": ["true", "false"] }, - "required": true, - "name": "id", - "in": "path" + "required": false, + "name": "refresh", + "in": "query" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "paused": { - "type": "boolean" - } - }, - "required": ["paused"] - } - } - } - }, "responses": { "200": { - "description": "Pause or resume a deployment", + "description": "Current directory project, validation, readiness, Build, and deployment declarations", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "id": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string" - } - }, - "required": ["id", "status"], - "additionalProperties": { - "nullable": true - } + "$ref": "#/components/schemas/ProjectSummary" } } } @@ -865,28 +1821,8 @@ } } }, - "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", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -894,70 +1830,9 @@ } } } - } - } - } - }, - "/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", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -966,8 +1841,8 @@ } } }, - "404": { - "description": "Not found", + "409": { + "description": "Conflict", "content": { "application/json": { "schema": { @@ -976,8 +1851,8 @@ } } }, - "409": { - "description": "Conflict", + "422": { + "description": "Unprocessable entity", "content": { "application/json": { "schema": { @@ -999,32 +1874,15 @@ } } }, - "/api/deployments/{id}": { - "delete": { - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1 - }, - "required": true, - "name": "id", - "in": "path" - } - ], + "/api/project/events": { + "get": { "responses": { "200": { - "description": "Delete a deployment", + "description": "Project reload and validation events", "content": { - "application/json": { + "text/event-stream": { "schema": { - "type": "object", - "properties": { - "deleted": { - "type": "boolean" - } - }, - "required": ["deleted"] + "type": "string" } } } @@ -1039,8 +1897,8 @@ } } }, - "404": { - "description": "Not found", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -1049,8 +1907,8 @@ } } }, - "409": { - "description": "Conflict", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -1059,346 +1917,48 @@ } } }, - "500": { - "description": "Server error", + "409": { + "description": "Conflict", "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"] + } + } + }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/project/declarations": { + "get": { + "responses": { + "200": { + "description": "Editable declarations already present in agents.yaml", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectDeclarationsResponse" } } } @@ -1413,6 +1973,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -1433,6 +2003,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -1446,87 +2026,79 @@ } } }, - "/api/cloud-agents": { - "get": { + "/api/project/declarations/{type}/{id}/preview": { + "post": { "parameters": [ { "schema": { - "type": "string" + "type": "string", + "enum": ["agent", "environment", "skill", "vault", "memory_store", "file"] }, - "required": false, - "name": "prefix", - "in": "query" + "required": true, + "name": "type", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "base_revision": { + "type": "string", + "minLength": 1 + }, + "action": { + "type": "string", + "enum": ["update", "delete"] + }, + "operations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": ["set", "remove"] + }, + "path": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "value": { + "nullable": true + } + }, + "required": ["op", "path"] + } + } + }, + "required": ["base_revision", "action"] + } + } + } + }, "responses": { "200": { - "description": "List raw cloud agents (the resource center's source of truth)", + "description": "Validate and preview an in-memory declaration change", "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"] + "$ref": "#/components/schemas/DeclarationPreviewResponse" } } } @@ -1541,6 +2113,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -1561,6 +2143,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -1574,33 +2166,76 @@ } } }, - "/api/cloud-agents/{agentId}/archive": { - "post": { + "/api/project/declarations/{type}/{id}": { + "patch": { "parameters": [ + { + "schema": { + "type": "string", + "enum": ["agent", "environment", "skill", "vault", "memory_store", "file"] + }, + "required": true, + "name": "type", + "in": "path" + }, { "schema": { "type": "string", "minLength": 1 }, "required": true, - "name": "agentId", + "name": "id", "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "base_revision": { + "type": "string", + "minLength": 1 + }, + "operations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": ["set", "remove"] + }, + "path": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "value": { + "nullable": true + } + }, + "required": ["op", "path"] + }, + "minItems": 1 + } + }, + "required": ["base_revision", "operations"] + } + } + } + }, "responses": { "200": { - "description": "Archive a cloud agent (soft delete → status=archived)", + "description": "Atomically update an existing declaration in agents.yaml", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - } - }, - "required": ["ok"] + "$ref": "#/components/schemas/DeclarationCommitResponse" } } } @@ -1615,6 +2250,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -1635,6 +2280,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -1646,18 +2301,25 @@ } } } - } - }, - "/api/cloud-agents/{agentId}": { - "post": { + }, + "delete": { "parameters": [ + { + "schema": { + "type": "string", + "enum": ["agent", "environment", "skill", "vault", "memory_store", "file"] + }, + "required": true, + "name": "type", + "in": "path" + }, { "schema": { "type": "string", "minLength": 1 }, "required": true, - "name": "agentId", + "name": "id", "in": "path" } ], @@ -1667,30 +2329,23 @@ "schema": { "type": "object", "properties": { - "model": { + "base_revision": { "type": "string", "minLength": 1 } }, - "required": ["model"] + "required": ["base_revision"] } } } }, "responses": { "200": { - "description": "Update a playbook agent's config (model switch → sync-override)", + "description": "Atomically remove an unreferenced declaration from agents.yaml", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - } - }, - "required": ["ok"] + "$ref": "#/components/schemas/DeclarationCommitResponse" } } } @@ -1705,6 +2360,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -1715,8 +2380,18 @@ } } }, - "409": { - "description": "Conflict", + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Unprocessable entity", "content": { "application/json": { "schema": { @@ -1738,68 +2413,15 @@ } } }, - "/api/environments": { + "/api/project/versioning": { "get": { "responses": { "200": { - "description": "List raw cloud environments (the shared base sandbox resource)", + "description": "Local directory source snapshot store and versioning status", "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"] + "$ref": "#/components/schemas/ProjectVersioningStatus" } } } @@ -1814,6 +2436,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -1834,6 +2466,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -1845,7 +2487,9 @@ } } } - }, + } + }, + "/api/project/versioning/enable": { "post": { "requestBody": { "content": { @@ -1853,51 +2497,23 @@ "schema": { "type": "object", "properties": { - "name": { + "base_revision": { "type": "string", "minLength": 1 - }, - "description": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } } }, - "required": ["name"] + "required": ["base_revision"] } } } }, "responses": { "200": { - "description": "Create a base cloud environment (cloud + unrestricted networking)", + "description": "Enable shared automatic agents.yaml versions and create a baseline when needed", "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"] + "$ref": "#/components/schemas/ProjectVersioningStatus" } } } @@ -1912,6 +2528,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -1932,6 +2558,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -1945,35 +2581,31 @@ } } }, - "/api/environments/{environmentId}": { - "delete": { - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1 - }, - "required": true, - "name": "environmentId", - "in": "path" + "/api/project/versioning/disable": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "base_revision": { + "type": "string", + "minLength": 1 + } + }, + "required": ["base_revision"] + } + } } - ], + }, "responses": { "200": { - "description": "Delete a cloud environment by remote id", + "description": "Disable shared automatic agents.yaml versions without removing snapshots", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": ["id", "type"] + "$ref": "#/components/schemas/ProjectVersioningStatus" } } } @@ -1988,28 +2620,8 @@ } } }, - "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", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -2017,64 +2629,9 @@ } } } - } - } - } - }, - "/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", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -2083,8 +2640,8 @@ } } }, - "404": { - "description": "Not found", + "409": { + "description": "Conflict", "content": { "application/json": { "schema": { @@ -2093,8 +2650,8 @@ } } }, - "409": { - "description": "Conflict", + "422": { + "description": "Unprocessable entity", "content": { "application/json": { "schema": { @@ -2114,54 +2671,37 @@ } } } - }, - "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 - } - }, - "required": ["name"] - } - } + } + }, + "/api/project/versions": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "required": false, + "name": "limit", + "in": "query" } - }, + ], "responses": { "200": { - "description": "Create a base cloud vault holding the user-supplied DASHSCOPE_API_KEY", + "description": "Local directory source snapshots", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "id": { - "type": "string", - "nullable": true - }, - "type": { - "type": "string" - }, - "version": { - "type": "number" - } - }, - "required": ["id", "type"] + "$ref": "#/components/schemas/ProjectVersionsResponse" } } } @@ -2176,6 +2716,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -2196,6 +2746,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2209,8 +2769,8 @@ } } }, - "/api/vaults/{vaultId}": { - "delete": { + "/api/project/versions/{versionId}/preview": { + "post": { "parameters": [ { "schema": { @@ -2218,26 +2778,37 @@ "minLength": 1 }, "required": true, - "name": "vaultId", + "name": "versionId", "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "base_revision": { + "type": "string", + "minLength": 1 + }, + "base_head_version": { + "type": "string", + "minLength": 1 + } + }, + "required": ["base_revision", "base_head_version"] + } + } + } + }, "responses": { "200": { - "description": "Delete a cloud vault by remote id", + "description": "Validate and preview restoring a historical directory source tree", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": ["id", "type"] + "$ref": "#/components/schemas/ProjectVersionPreview" } } } @@ -2252,6 +2823,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -2272,6 +2853,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2285,42 +2876,46 @@ } } }, - "/api/sessions": { - "get": { + "/api/project/versions/{versionId}/restore": { + "post": { "parameters": [ { "schema": { - "type": "integer", - "nullable": true - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "agentId", - "in": "query" - }, - { - "schema": { - "type": "string" + "type": "string", + "minLength": 1 }, - "required": false, - "name": "pageToken", - "in": "query" + "required": true, + "name": "versionId", + "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "base_revision": { + "type": "string", + "minLength": 1 + }, + "base_head_version": { + "type": "string", + "minLength": 1 + } + }, + "required": ["base_revision", "base_head_version"] + } + } + } + }, "responses": { "200": { - "description": "List sessions for an agent", + "description": "Restore historical directory source without changing version history or remote State", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionListResponse" + "$ref": "#/components/schemas/ProjectVersionRestoreResponse" } } } @@ -2335,6 +2930,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -2355,6 +2960,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2366,7 +2981,9 @@ } } } - }, + } + }, + "/api/project/build/preview": { "post": { "requestBody": { "content": { @@ -2374,57 +2991,23 @@ "schema": { "type": "object", "properties": { - "agentId": { - "type": "string" - }, - "prompt": { - "type": "string", - "minLength": 1 - }, - "environmentId": { + "base_revision": { "type": "string", "minLength": 1 - }, - "vaultIds": { - "type": "array", - "items": { - "type": "string" - } - }, - "title": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "type": "object", - "properties": { - "fileId": { - "type": "string" - }, - "mountPath": { - "type": "string" - } - }, - "required": ["fileId", "mountPath"] - } - }, - "model": { - "type": "string" } }, - "required": ["agentId", "prompt", "environmentId"] + "required": ["base_revision"] } } } }, "responses": { - "201": { - "description": "Session created", + "200": { + "description": "Preview deterministic directory-project Build output and organization moves", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionDetailResponse" + "$ref": "#/components/schemas/ProjectBuildResponse" } } } @@ -2439,6 +3022,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -2459,6 +3052,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2472,33 +3075,31 @@ } } }, - "/api/sessions/{sessionId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "sessionId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "agentId", - "in": "query" + "/api/project/build": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "base_revision": { + "type": "string", + "minLength": 1 + } + }, + "required": ["base_revision"] + } + } } - ], + }, "responses": { "200": { - "description": "Session detail with events", + "description": "Organize directory source and atomically write the generated Build", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionDetailResponse" + "$ref": "#/components/schemas/ProjectBuildResponse" } } } @@ -2513,6 +3114,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -2533,6 +3144,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2544,33 +3165,31 @@ } } } - }, - "delete": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "sessionId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "agentId", - "in": "query" + } + }, + "/api/project/plan": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "refresh": { + "type": "boolean" + } + } + } + } } - ], + }, "responses": { "200": { - "description": "Session deleted", + "description": "Plan every resource in the current project Build", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionDeleteResponse" + "$ref": "#/components/schemas/ProjectPlanResponse" } } } @@ -2585,6 +3204,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -2605,6 +3234,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2618,50 +3257,34 @@ } } }, - "/api/sessions/{sessionId}/events": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "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" + "/api/project/apply": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "plan_token": { + "type": "string", + "minLength": 1 + }, + "confirm_destructive": { + "type": "boolean" + } + }, + "required": ["plan_token"] + } + } } - ], + }, "responses": { - "200": { - "description": "Paginated session events (newest page first; pass pageToken for older pages)", + "202": { + "description": "Accept a full project Publish and version its frozen directory source after success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionEventsPageResponse" + "$ref": "#/components/schemas/ProjectApplyResponse" } } } @@ -2676,6 +3299,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -2696,6 +3329,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2709,15 +3352,16 @@ } } }, - "/api/sessions/{sessionId}/messages": { + "/api/project/agents/{agentId}/plan": { "post": { "parameters": [ { "schema": { - "type": "string" + "type": "string", + "minLength": 1 }, "required": true, - "name": "sessionId", + "name": "agentId", "in": "path" } ], @@ -2727,26 +3371,21 @@ "schema": { "type": "object", "properties": { - "agentId": { - "type": "string" - }, - "message": { - "type": "string", - "minLength": 1 + "refresh": { + "type": "boolean" } - }, - "required": ["message"] + } } } } }, "responses": { "200": { - "description": "Message sent; updated session with events", + "description": "Scoped plan for one Agent and its runtime dependencies", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionDetailResponse" + "$ref": "#/components/schemas/AgentPlanResponse" } } } @@ -2761,6 +3400,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -2781,6 +3430,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2794,42 +3453,49 @@ } } }, - "/api/sessions/{sessionId}/stream": { - "get": { + "/api/project/agents/{agentId}/apply": { + "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": { + "plan_token": { + "type": "string", + "minLength": 1 + }, + "confirm_destructive": { + "type": "boolean" + } + }, + "required": ["plan_token"] + } + } + } + }, "responses": { - "200": { - "description": "Stream session events as Server-Sent Events", + "202": { + "description": "Accept a compatibility-scoped Agent apply without creating a project version", "content": { - "text/event-stream": { + "application/json": { "schema": { - "type": "string", - "description": "SSE frames with event types \"event\", \"done\", and \"ping\"." + "$ref": "#/components/schemas/AgentApplyResponse" } } } }, - "204": { - "description": "No active event buffer; caller should fetch the session detail once" - }, "400": { "description": "Bad request", "content": { @@ -2840,6 +3506,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -2860,6 +3536,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -2873,33 +3559,49 @@ } } }, - "/api/files": { + "/api/project/agents/{agentId}/sessions": { "post": { - "operationId": "uploadFile", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "agentId", + "in": "path" + } + ], "requestBody": { - "required": true, "content": { - "multipart/form-data": { + "application/json": { "schema": { "type": "object", "properties": { - "file": { - "type": "string", - "format": "binary" + "prompt": { + "type": "string" + }, + "title": { + "type": "string" + }, + "attachment_ids": { + "type": "array", + "items": { + "type": "string" + } } - }, - "required": ["file"] + } } } } }, "responses": { "201": { - "description": "Upload a workspace file", + "description": "Session created from the selected agents.yaml Agent", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderFileInfo" + "$ref": "#/components/schemas/CreateProjectSessionResponse" } } } @@ -2914,6 +3616,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -2934,8 +3646,8 @@ } } }, - "413": { - "description": "File exceeds the upload size limit", + "422": { + "description": "Unprocessable entity", "content": { "application/json": { "schema": { @@ -2955,31 +3667,60 @@ } } } - }, - "get": { - "operationId": "listFiles", + } + }, + "/api/sessions/{sessionId}/messages": { + "post": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "sessionId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "minLength": 1 + } + }, + "required": ["message"] + } + } + } + }, "responses": { "200": { - "description": "List workspace files", + "description": "Follow up in a project Session using its pinned runtime", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectSessionResponse" + } + } + } + }, + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProviderFileInfo" - } - } - }, - "required": ["files"] + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "400": { - "description": "Bad request", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -3008,6 +3749,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3021,56 +3772,26 @@ } } }, - "/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}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "sessionId", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Get file scan statuses", + "description": "Project Session detail, history, and artifacts carried by events", "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/CreateProjectSessionResponse" } } } @@ -3085,6 +3806,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -3105,6 +3836,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3118,9 +3859,8 @@ } } }, - "/api/files/{id}/download": { + "/api/sessions/{sessionId}/artifacts/{fileId}/download": { "get": { - "operationId": "downloadFile", "parameters": [ { "schema": { @@ -3128,26 +3868,26 @@ "minLength": 1 }, "required": true, - "name": "id", + "name": "sessionId", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "fileId", "in": "path" } ], "responses": { "200": { - "description": "Resolve a short-lived file download URL", + "description": "Resolve a short-lived download URL for an artifact delivered by this Session", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "expires_at": { - "type": "string" - } - }, - "required": ["url"] + "$ref": "#/components/schemas/ProjectSessionArtifactDownload" } } } @@ -3162,6 +3902,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -3182,6 +3932,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3195,9 +3955,8 @@ } } }, - "/api/files/{id}": { - "delete": { - "operationId": "deleteFile", + "/api/sessions/{sessionId}/cancel": { + "post": { "parameters": [ { "schema": { @@ -3205,13 +3964,30 @@ "minLength": 1 }, "required": true, - "name": "id", + "name": "sessionId", "in": "path" } ], "responses": { - "204": { - "description": "File deleted" + "200": { + "description": "Terminate/delete the provider Session", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "session_id": { + "type": "string" + }, + "cancelled": { + "type": "boolean", + "enum": [true] + } + }, + "required": ["session_id", "cancelled"] + } + } + } }, "400": { "description": "Bad request", @@ -3223,6 +3999,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -3243,6 +4029,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3256,37 +4052,42 @@ } } }, - "/api/skills/upload-file": { - "post": { - "operationId": "uploadSkillFile", - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "file": { - "type": "string", - "format": "binary" - } - }, - "required": ["file"] - } - } + "/api/sessions/{sessionId}/events": { + "get": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "sessionId", + "in": "path" + }, + { + "schema": { + "type": "integer", + "nullable": true + }, + "required": false, + "name": "after", + "in": "query" } - }, + ], "responses": { - "201": { - "description": "Upload a skill archive as a file pending audit", + "200": { + "description": "Replay and stream Session events", "content": { - "application/json": { + "text/event-stream": { "schema": { - "$ref": "#/components/schemas/ProviderFileInfo" + "type": "string" } } } }, + "204": { + "description": "Session is unavailable" + }, "400": { "description": "Bad request", "content": { @@ -3297,6 +4098,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -3317,8 +4128,8 @@ } } }, - "413": { - "description": "Skill archive exceeds the upload size limit", + "422": { + "description": "Unprocessable entity", "content": { "application/json": { "schema": { @@ -3340,39 +4151,89 @@ } } }, - "/api/skills": { + "/api/project/agents/{agentId}/attachments": { "post": { - "operationId": "createSkill", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "agentId", + "in": "path" + } + ], "requestBody": { "required": true, "content": { - "application/json": { + "multipart/form-data": { "schema": { "type": "object", "properties": { - "fileId": { + "file": { "type": "string", - "minLength": 1 + "format": "binary" } }, - "required": ["fileId"] + "required": ["file"] } } } }, "responses": { "201": { - "description": "Create a skill from an audited file", + "description": "Ad-hoc Session attachment uploaded", + "content": { + "application/json": { + "schema": { + "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"] + } + } + } + }, + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderSkillInfo" + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "400": { - "description": "Bad request", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -3401,6 +4262,26 @@ } } }, + "413": { + "description": "File exceeds the upload limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3414,34 +4295,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"] } } } @@ -3456,6 +4374,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -3476,6 +4404,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3489,44 +4427,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"] } } } @@ -3541,6 +4471,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -3561,6 +4501,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3574,54 +4524,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" } } } @@ -3636,6 +4558,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -3656,6 +4588,16 @@ } } }, + "422": { + "description": "Unprocessable entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Server error", "content": { @@ -3669,9 +4611,8 @@ } } }, - "/api/skills/{id}": { - "delete": { - "operationId": "deleteSkill", + "/api/operations/{operationId}/events": { + "get": { "parameters": [ { "schema": { @@ -3679,36 +4620,32 @@ "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" - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", + "200": { + "description": "Replay and stream asynchronous operation events", "content": { - "application/json": { + "text/event-stream": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "string" } } } }, - "409": { - "description": "Conflict", + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { @@ -3717,8 +4654,8 @@ } } }, - "500": { - "description": "Server error", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -3726,49 +4663,9 @@ } } } - } - } - } - }, - "/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", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -3777,8 +4674,8 @@ } } }, - "404": { - "description": "Not found", + "409": { + "description": "Conflict", "content": { "application/json": { "schema": { @@ -3787,8 +4684,8 @@ } } }, - "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..9941fed 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,11 +16,15 @@ }, "dependencies": { "@hono/zod-openapi": "^1.4.0", - "@openagentpack/playbooks": "workspace:*", + "@openagentpack/project-versions": "workspace:*", + "@openagentpack/project-workspace": "workspace:*", "@openagentpack/sdk": "workspace:*", - "hono": "^4.12.28" + "chokidar": "^4.0.3", + "hono": "^4.12.28", + "yaml": "^2.9.0" }, "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..81a315f 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,27 @@ app.use( "/*", cors({ origin: process.env.CORS_ORIGIN?.split(",") ?? ["http://localhost:3000"], - allowMethods: ["GET", "POST", "PUT", "OPTIONS"], - allowHeaders: ["Content-Type"], + allowMethods: ["GET", "POST", "PATCH", "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(); + const protectsVersionRead = + context.req.path === "/api/project/versioning" || context.req.path.startsWith("/api/project/versions"); + const requiresToken = protectsVersionRead || !["GET", "HEAD", "OPTIONS"].includes(context.req.method); + if (expected && requiresToken && 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 +45,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..38a969c --- /dev/null +++ b/apps/server/src/routes/project.ts @@ -0,0 +1,658 @@ +import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; +import { + acquireDirectoryProjectMutation, + commitProjectBuild, + previewProjectBuild, + readValidProjectBuild, +} from "@openagentpack/project-workspace"; +import { planAgentResourcesWithStateBackend, syncAgentResourcesWithStateBackend } from "@openagentpack/sdk"; +import { errorResponses } from "@/schemas/common"; +import { + AgentApplyBodySchema, + AgentApplyResponseSchema, + AgentPlanBodySchema, + AgentPlanResponseSchema, + DeclarationCommitResponseSchema, + DeclarationDeleteBodySchema, + DeclarationParamsSchema, + DeclarationPatchBodySchema, + DeclarationPreviewBodySchema, + DeclarationPreviewResponseSchema, + ProjectAgentParamsSchema, + ProjectApplyBodySchema, + ProjectApplyResponseSchema, + ProjectBuildBodySchema, + ProjectBuildResponseSchema, + ProjectDeclarationsResponseSchema, + ProjectPlanBodySchema, + ProjectPlanResponseSchema, + ProjectSummarySchema, + ProjectVersionActionBodySchema, + ProjectVersioningStatusSchema, + ProjectVersioningToggleBodySchema, + ProjectVersionParamsSchema, + ProjectVersionPreviewSchema, + ProjectVersionRestoreResponseSchema, + ProjectVersionsQuerySchema, + ProjectVersionsResponseSchema, +} from "@/schemas/project"; +import { + commitDeclarationChange, + listProjectDeclarations, + previewDeclarationChange, +} from "@/services/project-declarations"; +import { projectRuntimeManager } from "@/services/project-manager"; +import { projectMutationCoordinator } from "@/services/project-mutations"; +import { planTokenStore, projectOperationStore } from "@/services/project-operations"; +import { applyProjectRuntimeResources, planProjectRuntimeResources } from "@/services/project-runtime-plan"; +import { + commitProjectVersionAfterApply, + getProjectVersioningStatus, + listProjectVersions, + prepareProjectVersionForApply, + previewProjectVersion, + releaseProjectVersionAfterApply, + restoreProjectVersion, + setProjectVersioning, +} from "@/services/project-versions"; + +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 directory project, validation, readiness, Build, 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 unsubscribeMutation: (() => 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, + active_mutation: projectMutationCoordinator.getSnapshot(), + }); + unsubscribe = projectRuntimeManager.subscribe((event) => send(event.type, event)); + unsubscribeMutation = projectMutationCoordinator.subscribe((mutation) => + send("project.mutation", { active_mutation: mutation }), + ); + ping = setInterval(() => send("ping", {}), 15_000); + }, + cancel() { + unsubscribe?.(); + unsubscribeMutation?.(); + if (ping) clearInterval(ping); + }, + }); + context.req.raw.signal.addEventListener("abort", () => { + unsubscribe?.(); + unsubscribeMutation?.(); + if (ping) clearInterval(ping); + }); + return new Response(stream, { headers: sseHeaders() }); +}); + +const listDeclarationsRoute = createRoute({ + method: "get", + path: "/project/declarations", + responses: { + 200: { + description: "Editable declarations already present in agents.yaml", + content: { "application/json": { schema: ProjectDeclarationsResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(listDeclarationsRoute, async (context) => context.json(await listProjectDeclarations(), 200)); + +const previewDeclarationRoute = createRoute({ + method: "post", + path: "/project/declarations/{type}/{id}/preview", + request: { + params: DeclarationParamsSchema, + body: { content: { "application/json": { schema: DeclarationPreviewBodySchema } } }, + }, + responses: { + 200: { + description: "Validate and preview an in-memory declaration change", + content: { "application/json": { schema: DeclarationPreviewResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(previewDeclarationRoute, async (context) => { + const { type, id } = context.req.valid("param"); + const { base_revision: baseRevision, action, operations } = context.req.valid("json"); + return context.json(await previewDeclarationChange({ type, id, baseRevision, action, operations }), 200); +}); + +const patchDeclarationRoute = createRoute({ + method: "patch", + path: "/project/declarations/{type}/{id}", + request: { + params: DeclarationParamsSchema, + body: { content: { "application/json": { schema: DeclarationPatchBodySchema } } }, + }, + responses: { + 200: { + description: "Atomically update an existing declaration in agents.yaml", + content: { "application/json": { schema: DeclarationCommitResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(patchDeclarationRoute, async (context) => { + const { type, id } = context.req.valid("param"); + const { base_revision: baseRevision, operations } = context.req.valid("json"); + return context.json(await commitDeclarationChange({ type, id, baseRevision, action: "update", operations }), 200); +}); + +const deleteDeclarationRoute = createRoute({ + method: "delete", + path: "/project/declarations/{type}/{id}", + request: { + params: DeclarationParamsSchema, + body: { content: { "application/json": { schema: DeclarationDeleteBodySchema } } }, + }, + responses: { + 200: { + description: "Atomically remove an unreferenced declaration from agents.yaml", + content: { "application/json": { schema: DeclarationCommitResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(deleteDeclarationRoute, async (context) => { + const { type, id } = context.req.valid("param"); + const { base_revision: baseRevision } = context.req.valid("json"); + return context.json(await commitDeclarationChange({ type, id, baseRevision, action: "delete" }), 200); +}); + +const getProjectVersioningRoute = createRoute({ + method: "get", + path: "/project/versioning", + responses: { + 200: { + description: "Local directory source snapshot store and versioning status", + content: { "application/json": { schema: ProjectVersioningStatusSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(getProjectVersioningRoute, async (context) => + context.json(await getProjectVersioningStatus(), 200), +); + +const enableProjectVersioningRoute = createRoute({ + method: "post", + path: "/project/versioning/enable", + request: { body: { content: { "application/json": { schema: ProjectVersioningToggleBodySchema } } } }, + responses: { + 200: { + description: "Enable shared automatic agents.yaml versions and create a baseline when needed", + content: { "application/json": { schema: ProjectVersioningStatusSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(enableProjectVersioningRoute, async (context) => { + const { base_revision: baseRevision } = context.req.valid("json"); + return context.json(await setProjectVersioning({ baseRevision, enabled: true }), 200); +}); + +const disableProjectVersioningRoute = createRoute({ + method: "post", + path: "/project/versioning/disable", + request: { body: { content: { "application/json": { schema: ProjectVersioningToggleBodySchema } } } }, + responses: { + 200: { + description: "Disable shared automatic agents.yaml versions without removing snapshots", + content: { "application/json": { schema: ProjectVersioningStatusSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(disableProjectVersioningRoute, async (context) => { + const { base_revision: baseRevision } = context.req.valid("json"); + return context.json(await setProjectVersioning({ baseRevision, enabled: false }), 200); +}); + +const listProjectVersionsRoute = createRoute({ + method: "get", + path: "/project/versions", + request: { query: ProjectVersionsQuerySchema }, + responses: { + 200: { + description: "Local directory source snapshots", + content: { "application/json": { schema: ProjectVersionsResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(listProjectVersionsRoute, async (context) => { + const { cursor, limit } = context.req.valid("query"); + return context.json(await listProjectVersions({ cursor, limit }), 200); +}); + +const previewProjectVersionRoute = createRoute({ + method: "post", + path: "/project/versions/{versionId}/preview", + request: { + params: ProjectVersionParamsSchema, + body: { content: { "application/json": { schema: ProjectVersionActionBodySchema } } }, + }, + responses: { + 200: { + description: "Validate and preview restoring a historical directory source tree", + content: { "application/json": { schema: ProjectVersionPreviewSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(previewProjectVersionRoute, async (context) => { + const { versionId } = context.req.valid("param"); + const { base_revision: baseRevision, base_head_version: baseHeadVersion } = context.req.valid("json"); + return context.json(await previewProjectVersion({ versionId, baseRevision, baseHeadVersion }), 200); +}); + +const restoreProjectVersionRoute = createRoute({ + method: "post", + path: "/project/versions/{versionId}/restore", + request: { + params: ProjectVersionParamsSchema, + body: { content: { "application/json": { schema: ProjectVersionActionBodySchema } } }, + }, + responses: { + 200: { + description: "Restore historical directory source without changing version history or remote State", + content: { "application/json": { schema: ProjectVersionRestoreResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(restoreProjectVersionRoute, async (context) => { + const { versionId } = context.req.valid("param"); + const { base_revision: baseRevision, base_head_version: baseHeadVersion } = context.req.valid("json"); + return context.json(await restoreProjectVersion({ versionId, baseRevision, baseHeadVersion }), 200); +}); + +const previewProjectBuildRoute = createRoute({ + method: "post", + path: "/project/build/preview", + request: { body: { content: { "application/json": { schema: ProjectBuildBodySchema } } } }, + responses: { + 200: { + description: "Preview deterministic directory-project Build output and organization moves", + content: { "application/json": { schema: ProjectBuildResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(previewProjectBuildRoute, async (context) => { + const { base_revision: baseRevision } = context.req.valid("json"); + const preview = await previewProjectBuild(projectRuntimeManager.projectRoot); + if (preview.project_revision !== baseRevision) throw statusError("Project files changed. Preview Build again.", 409); + return context.json(buildForWire(preview), 200); +}); + +const commitProjectBuildRoute = createRoute({ + method: "post", + path: "/project/build", + request: { body: { content: { "application/json": { schema: ProjectBuildBodySchema } } } }, + responses: { + 200: { + description: "Organize directory source and atomically write the generated Build", + content: { "application/json": { schema: ProjectBuildResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(commitProjectBuildRoute, async (context) => { + const { base_revision: baseRevision } = context.req.valid("json"); + const lease = projectMutationCoordinator.acquire("project_build"); + try { + const result = await commitProjectBuild({ projectRoot: projectRuntimeManager.projectRoot, baseRevision }); + await projectRuntimeManager.refreshAfterSourceMutation(); + planTokenStore.invalidateAll(); + return context.json(buildForWire(result, result.manifest), 200); + } finally { + lease.release(); + } +}); + +const planProjectRoute = createRoute({ + method: "post", + path: "/project/plan", + request: { body: { content: { "application/json": { schema: ProjectPlanBodySchema } } } }, + responses: { + 200: { + description: "Plan every resource in the current project Build", + content: { "application/json": { schema: ProjectPlanResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(planProjectRoute, async (context) => { + await projectRuntimeManager.ensureStarted(); + await readValidProjectBuild(projectRuntimeManager.projectRoot); + const { refresh } = context.req.valid("json"); + const snapshot = projectRuntimeManager.getSnapshot(); + const plan = await planProjectRuntimeResources(projectRuntimeManager.requireRuntimeInput(), { + refresh: refresh ?? true, + }); + const errorDiagnostic = plan.diagnostics.find((diagnostic) => diagnostic.severity === "error"); + if (errorDiagnostic) throw statusError(errorDiagnostic.message, 422); + const scope = { kind: "project" as const }; + const token = planTokenStore.issue({ + scope, + projectRevision: snapshot.revision!, + fingerprint: plan.fingerprint, + destructive: plan.destructiveActions.length > 0, + }); + return context.json( + { + scope: "project_runtime" as const, + 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 applyProjectRoute = createRoute({ + method: "post", + path: "/project/apply", + request: { body: { content: { "application/json": { schema: ProjectApplyBodySchema } } } }, + responses: { + 202: { + description: "Accept a full project Publish and version its frozen directory source after success", + content: { "application/json": { schema: ProjectApplyResponseSchema } }, + }, + ...errorResponses, + }, +}); + +projectRoute.openapi(applyProjectRoute, async (context) => { + await projectRuntimeManager.ensureStarted(); + await readValidProjectBuild(projectRuntimeManager.projectRoot); + const { plan_token: planToken, confirm_destructive: confirmDestructive } = context.req.valid("json"); + const snapshot = projectRuntimeManager.getSnapshot(); + const scope = { kind: "project" as const }; + const token = planTokenStore.require(planToken, scope, snapshot.revision ?? ""); + if (token.destructive && !confirmDestructive) { + throw statusError( + "This plan contains destructive actions. Set confirm_destructive to true after reviewing it.", + 422, + ); + } + const versionMessage = `Publish project revision ${token.projectRevision.slice(0, 12)}`; + const input = projectRuntimeManager.requireRuntimeInput(); + const lease = projectMutationCoordinator.acquire("project_apply"); + let preparedVersion: Awaited> | null = null; + let operation: ReturnType; + try { + preparedVersion = await prepareProjectVersionForApply({ baseRevision: token.projectRevision }); + if ((await projectRuntimeManager.computeCurrentSourceRevision()) !== token.projectRevision) { + throw statusError("Plan is stale because project configuration changed. Create a new plan.", 409); + } + const freshPlan = await planProjectRuntimeResources(input, { refresh: true }); + if (freshPlan.fingerprint !== token.fingerprint) { + planTokenStore.consume(planToken); + throw statusError("Plan is stale because project or remote resources changed. Create a new plan.", 409); + } + planTokenStore.require(planToken, scope, projectRuntimeManager.getSnapshot().revision ?? ""); + operation = projectOperationStore.create(scope, async (reporter) => { + try { + const run = await applyProjectRuntimeResources(input, token.fingerprint, { onFeedback: reporter.feedback }); + await commitProjectVersionAfterApply(preparedVersion, versionMessage); + planTokenStore.invalidateAll(); + await projectRuntimeManager.refreshAfterMutation(); + return redactForWire(run); + } finally { + await releaseProjectVersionAfterApply(preparedVersion); + lease.release(); + } + }); + lease.setOperationId(operation.id); + } catch (error) { + await releaseProjectVersionAfterApply(preparedVersion); + lease.release(); + throw error; + } + planTokenStore.consume(planToken); + return context.json({ operation_id: operation.id, status: "queued" as const }, 202); +}); + +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({ + scope: { kind: "agent", 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: "Accept a compatibility-scoped Agent apply without creating a project version", + 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 scope = { kind: "agent" as const, agentId }; + const token = planTokenStore.require(planToken, scope, snapshot.revision!); + if (token.destructive && !confirmDestructive) { + throw statusError( + "This plan contains destructive actions. Set confirm_destructive to true after reviewing it.", + 422, + ); + } + const lease = projectMutationCoordinator.acquire("agent_apply"); + let filesystemLease: Awaited> | undefined; + let operation: ReturnType; + try { + filesystemLease = await acquireDirectoryProjectMutation(projectRuntimeManager.projectRoot, "agent_apply"); + if ((await projectRuntimeManager.computeCurrentSourceRevision()) !== token.projectRevision) { + throw statusError("Plan is stale because project configuration changed. Create a new plan.", 409); + } + 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); + } + try { + planTokenStore.require(planToken, scope, projectRuntimeManager.getSnapshot().revision ?? ""); + } catch { + planTokenStore.consume(planToken); + throw statusError("Plan is stale because project configuration changed. Create a new plan.", 409); + } + operation = projectOperationStore.create(scope, async (reporter) => { + try { + 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); + } finally { + await filesystemLease?.release(); + lease.release(); + } + }); + lease.setOperationId(operation.id); + } catch (error) { + await filesystemLease?.release(); + lease.release(); + throw error; + } + 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; +} + +function buildForWire( + preview: Awaited>, + manifest?: Awaited>["manifest"], +) { + return { + project_revision: preview.project_revision, + before_yaml: preview.before_yaml, + after_yaml: preview.after_yaml, + diagnostics: redactForWire(preview.diagnostics), + warnings: redactForWire(preview.warnings), + organization_moves: preview.organization_moves, + can_build: preview.can_build, + ...(manifest ? { manifest } : {}), + }; +} diff --git a/apps/server/src/schemas/common.ts b/apps/server/src/schemas/common.ts index 7d66ca4..fffb740 100644 --- a/apps/server/src/schemas/common.ts +++ b/apps/server/src/schemas/common.ts @@ -21,7 +21,9 @@ const errorResponse = (description: string) => ({ */ export const errorResponses = { 400: errorResponse("Bad request"), + 403: errorResponse("Forbidden"), 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..5169cfe --- /dev/null +++ b/apps/server/src/schemas/project.ts @@ -0,0 +1,316 @@ +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 ProjectMutationSchema = z.object({ + kind: z.enum([ + "agent_apply", + "project_apply", + "project_build", + "declaration_write", + "version_enable", + "version_write", + "version_restore", + ]), + started_at: z.string(), + operation_id: z.string().optional(), +}); + +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), + active_mutation: ProjectMutationSchema.nullable(), + build: z.object({ + exists: z.boolean(), + stale: z.boolean(), + reasons: z.array(z.string()), + yaml_hash: z.string().optional(), + }), + }) + .openapi("ProjectSummary"); + +export const ProjectBuildBodySchema = z.object({ base_revision: z.string().min(1) }); +export const ProjectBuildResponseSchema = z + .object({ + project_revision: z.string(), + before_yaml: z.string(), + after_yaml: z.string(), + diagnostics: z.array(DiagnosticSchema), + warnings: z.array(DiagnosticSchema), + organization_moves: z.array( + z.object({ skill_id: z.string(), from: z.string(), to: z.string(), reason: z.literal("shared") }), + ), + can_build: z.boolean(), + manifest: z + .object({ + schema_version: z.literal(1), + project_revision: z.string(), + source_manifest_hash: z.string(), + yaml_hash: z.string(), + built_at: z.string(), + }) + .optional(), + }) + .openapi("ProjectBuildResponse"); + +export const ProjectVersioningStatusSchema = z + .object({ + initialized: z.boolean(), + enabled: z.boolean(), + store_root: z.string(), + config_path: z.string(), + head_version: z.string().nullable(), + source_status: z.enum(["clean", "modified", "unversioned"]), + source_versioned: z.boolean(), + write_blockers: z.array(z.string()), + restore_blockers: z.array(z.string()), + }) + .openapi("ProjectVersioningStatus"); +export const ProjectVersioningToggleBodySchema = z.object({ base_revision: z.string().min(1) }); +export const ProjectVersionSchema = z + .object({ + version_id: z.string(), + short_version: z.string(), + parent_version: z.string().nullable(), + source_hash: z.string(), + message: z.string(), + created_by: z.string(), + created_at: z.string(), + }) + .openapi("ProjectVersion"); +export const ProjectVersionsQuerySchema = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), +}); +export const ProjectVersionsResponseSchema = z + .object({ versions: z.array(ProjectVersionSchema), next_cursor: z.string().nullable() }) + .openapi("ProjectVersionsResponse"); +export const ProjectVersionParamsSchema = z.object({ versionId: z.string().min(1) }); +export const ProjectVersionActionBodySchema = z.object({ + base_revision: z.string().min(1), + base_head_version: z.string().min(1), +}); +export const ProjectVersionPreviewSchema = z + .object({ + version_id: z.string(), + base_revision: z.string(), + base_head_version: z.string(), + before_yaml: z.string(), + after_yaml: z.string(), + changes: z.array( + z.object({ + path: z.string(), + change: z.enum(["create", "update", "delete"]), + binary: z.boolean(), + before: z.string().optional(), + after: z.string().optional(), + }), + ), + diagnostics: z.array(DiagnosticSchema), + can_restore: z.boolean(), + blockers: z.array(z.string()), + }) + .openapi("ProjectVersionPreview"); +export const ProjectVersionRestoreResponseSchema = ProjectVersionPreviewSchema.extend({ + new_revision: z.string(), +}).openapi("ProjectVersionRestoreResponse"); + +export const ProjectAgentParamsSchema = z.object({ agentId: z.string().min(1) }); + +export const DeclarationTypeSchema = z.enum(["agent", "environment", "skill", "vault", "memory_store", "file"]); +export const DeclarationParamsSchema = z.object({ + type: DeclarationTypeSchema, + id: z.string().min(1), +}); +export const DeclarationPatchOperationSchema = z.object({ + op: z.enum(["set", "remove"]), + path: z.array(z.string().min(1)).min(1), + value: z.unknown().optional(), +}); +export const DeclarationReferenceSchema = z.object({ + type: z.string(), + id: z.string(), + path: z.string(), +}); +export const DeclarationResourceSchema = z.object({ + type: DeclarationTypeSchema, + id: z.string(), + declaration: z.record(z.string(), z.unknown()), + read_only_paths: z.array(z.array(z.string())), + references: z.array(DeclarationReferenceSchema), +}); +export const ProjectDeclarationsResponseSchema = z + .object({ + revision: z.string(), + resources: z.array(DeclarationResourceSchema), + }) + .openapi("ProjectDeclarationsResponse"); +export const DeclarationPreviewBodySchema = z.object({ + base_revision: z.string().min(1), + action: z.enum(["update", "delete"]), + operations: z.array(DeclarationPatchOperationSchema).optional(), +}); +export const DeclarationPatchBodySchema = z.object({ + base_revision: z.string().min(1), + operations: z.array(DeclarationPatchOperationSchema).min(1), +}); +export const DeclarationDeleteBodySchema = z.object({ base_revision: z.string().min(1) }); +export const DeclarationPreviewResponseSchema = z + .object({ + type: DeclarationTypeSchema, + id: z.string(), + action: z.enum(["update", "delete"]), + base_revision: z.string(), + before_yaml: z.string(), + after_yaml: z.string(), + diagnostics: z.array(DiagnosticSchema), + references: z.array(DeclarationReferenceSchema), + can_commit: z.boolean(), + }) + .openapi("DeclarationPreviewResponse"); +export const DeclarationCommitResponseSchema = DeclarationPreviewResponseSchema.extend({ + new_revision: z.string(), +}).openapi("DeclarationCommitResponse"); + +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 ProjectPlanBodySchema = z.object({ refresh: z.boolean().optional() }); +export const ProjectPlanResponseSchema = z + .object({ + scope: z.literal("project_runtime"), + 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("ProjectPlanResponse"); +export const ProjectApplyBodySchema = AgentApplyBodySchema; +export const ProjectApplyResponseSchema = AgentApplyResponseSchema.openapi("ProjectApplyResponse"); + +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.enum(["agent.apply", "project.apply"]), + agent_id: z.string().optional(), + 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-declarations.ts b/apps/server/src/services/project-declarations.ts new file mode 100644 index 0000000..d865ba4 --- /dev/null +++ b/apps/server/src/services/project-declarations.ts @@ -0,0 +1,594 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { basename, dirname, relative, resolve } from "node:path"; +import { acquireDirectoryProjectMutation, inspectDirectoryProject } from "@openagentpack/project-workspace"; +import { + type Diagnostic, + type ResolvedProjectConfig, + resolveProjectConfigFromObject, + validateProjectConfig, +} from "@openagentpack/sdk"; +import { parse, stringify } from "yaml"; +import { type ProjectRuntimeManager, projectRuntimeManager } from "@/services/project-manager"; +import { projectMutationCoordinator } from "@/services/project-mutations"; + +export const DECLARATION_TYPES = ["agent", "environment", "skill", "vault", "memory_store", "file"] as const; +export type DeclarationType = (typeof DECLARATION_TYPES)[number]; + +export interface DeclarationPatchOperation { + op: "set" | "remove"; + path: string[]; + value?: unknown; +} + +export interface DeclarationReference { + type: string; + id: string; + path: string; +} + +export interface DeclarationResource { + type: DeclarationType; + id: string; + declaration: Record; + read_only_paths: string[][]; + references: DeclarationReference[]; +} + +export interface DeclarationPreview { + type: DeclarationType; + id: string; + action: "update" | "delete"; + base_revision: string; + before_yaml: string; + after_yaml: string; + diagnostics: Diagnostic[]; + references: DeclarationReference[]; + can_commit: boolean; +} + +interface PreparedDeclarationChange extends DeclarationPreview { + target: SourceTarget; + content?: string; + contentFileContent?: string; + deletePath?: string; +} + +interface SourceTarget { + kind: "agent" | "skill" | "project"; + path: string; + contentPath?: string; +} + +const SECTION_BY_TYPE: Record = { + agent: "agents", + environment: "environments", + skill: "skills", + vault: "vaults", + memory_store: "memory_stores", + file: "files", +}; + +const EDITABLE_FIELDS: Record> = { + agent: new Set([ + "name", + "description", + "model", + "instructions", + "environment", + "tunnel", + "provider", + "tools", + "mcp_servers", + "skills", + "vault", + "memory_stores", + "resources", + "multiagent", + "metadata", + "environment_variables", + "delivery", + ]), + environment: new Set(["name", "description", "provider", "config", "metadata"]), + skill: new Set(["name", "content", "description", "version", "origin", "provider"]), + vault: new Set(["display_name", "provider", "credentials", "metadata"]), + memory_store: new Set(["description", "provider", "metadata", "entries"]), + file: new Set(["name", "purpose", "provider"]), +}; + +const SENSITIVE_KEY = /(access[_-]?key|api[_-]?key|authorization|credential|headers?|password|secret|signature|token)/i; +const REDACTED = "[redacted]"; +let sourceMutationQueue: Promise = Promise.resolve(); + +export class DeclarationProtocolError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "DeclarationProtocolError"; + } +} + +export async function listProjectDeclarations( + manager: ProjectRuntimeManager = projectRuntimeManager, +): Promise<{ revision: string; resources: DeclarationResource[] }> { + const source = await readValidProject(manager); + const resources: DeclarationResource[] = []; + for (const type of DECLARATION_TYPES) { + const section = recordValue(source.config[SECTION_BY_TYPE[type]]); + for (const [id, rawDeclaration] of Object.entries(section)) { + if (!isRecord(rawDeclaration)) continue; + const target = await locateSourceTarget(manager.projectRoot, type, id, rawDeclaration); + const declaration = await declarationForEditor(type, id, rawDeclaration, target); + resources.push({ + type, + id, + declaration: redactSensitive(declaration) as Record, + read_only_paths: readOnlyPaths(type, declaration), + references: findDeclarationReferences(source.config, type, id), + }); + } + } + return { revision: source.revision, resources }; +} + +export async function previewDeclarationChange( + input: { + type: DeclarationType; + id: string; + baseRevision: string; + action: "update" | "delete"; + operations?: DeclarationPatchOperation[]; + }, + manager: ProjectRuntimeManager = projectRuntimeManager, +): Promise { + return publicPreview(await prepareDeclarationChange(input, manager)); +} + +export async function commitDeclarationChange( + input: { + type: DeclarationType; + id: string; + baseRevision: string; + action: "update" | "delete"; + operations?: DeclarationPatchOperation[]; + }, + manager: ProjectRuntimeManager = projectRuntimeManager, +): Promise { + return serializeSourceMutation(async () => { + const lease = projectMutationCoordinator.acquire("declaration_write"); + let filesystemLease: Awaited> | undefined; + try { + filesystemLease = await acquireDirectoryProjectMutation(manager.projectRoot, "declaration_write"); + const prepared = await prepareDeclarationChange(input, manager); + if (!prepared.can_commit) { + const blockedByReferences = prepared.action === "delete" && prepared.references.length > 0; + throw new DeclarationProtocolError( + blockedByReferences + ? `Cannot delete ${prepared.type}.${prepared.id}; it is referenced by ${prepared.references.map((reference) => reference.path).join(", ")}.` + : (prepared.diagnostics.find((diagnostic) => diagnostic.severity === "error")?.message ?? + "The declaration change is invalid."), + blockedByReferences ? 409 : 422, + ); + } + if ((await manager.computeCurrentSourceRevision()) !== prepared.base_revision) { + throw new DeclarationProtocolError("Project files changed. Reload before saving this edit.", 409); + } + if (prepared.action === "delete") { + await commitDelete(manager.projectRoot, prepared); + } else if (prepared.content !== undefined) { + await writeTextAtomic(prepared.target.path, prepared.content); + if (prepared.target.contentPath && prepared.contentFileContent !== undefined) { + await writeTextAtomic(prepared.target.contentPath, prepared.contentFileContent); + } + } + const newRevision = await manager.refreshAfterSourceMutation(); + if (!newRevision) throw new DeclarationProtocolError("The saved project has no revision.", 500); + return { ...publicPreview(prepared), new_revision: newRevision }; + } finally { + await filesystemLease?.release(); + lease.release(); + } + }); +} + +async function prepareDeclarationChange( + input: { + type: DeclarationType; + id: string; + baseRevision: string; + action: "update" | "delete"; + operations?: DeclarationPatchOperation[]; + }, + manager: ProjectRuntimeManager, +): Promise { + const source = await readValidProject(manager); + if (source.revision !== input.baseRevision) + throw new DeclarationProtocolError("Project files changed. Reload before editing.", 409); + const sectionName = SECTION_BY_TYPE[input.type]; + const sectionBefore = recordValue(source.config[sectionName]); + const rawBefore = sectionBefore[input.id]; + if (!isRecord(rawBefore)) + throw new DeclarationProtocolError(`${input.type}.${input.id} is not declared in this project.`, 404); + const target = await locateSourceTarget(manager.projectRoot, input.type, input.id, rawBefore); + const editorBefore = await declarationForEditor(input.type, input.id, rawBefore, target); + const configAfter = structuredClone(source.config); + const sectionAfter = recordValue(configAfter[sectionName]); + const references = findDeclarationReferences(source.config, input.type, input.id); + let editorAfter: Record | null = null; + let content: string | undefined; + let contentFileContent: string | undefined; + if (input.action === "delete") { + delete sectionAfter[input.id]; + if (Object.keys(sectionAfter).length === 0) delete configAfter[sectionName]; + else configAfter[sectionName] = sectionAfter; + } else { + const operations = input.operations ?? []; + if (operations.length === 0) throw new DeclarationProtocolError("At least one patch operation is required.", 400); + editorAfter = structuredClone(editorBefore); + for (const operation of operations) applyPlainPatch(editorAfter, input.type, operation); + const rawAfter = rawDeclarationForConfig(input.type, editorAfter, rawBefore); + sectionAfter[input.id] = rawAfter; + configAfter[sectionName] = sectionAfter; + const rendered = await sourceContentForTarget(input.type, input.id, target, editorAfter); + content = rendered.metadata; + contentFileContent = rendered.contentFile; + } + const diagnostics = redactSensitive(await validateConfig(configAfter, manager.projectRoot)) as Diagnostic[]; + const hasErrors = diagnostics.some((diagnostic) => diagnostic.severity === "error"); + return { + type: input.type, + id: input.id, + action: input.action, + base_revision: source.revision, + before_yaml: declarationSnippet(input.id, editorBefore), + after_yaml: editorAfter ? declarationSnippet(input.id, editorAfter) : "# declaration removed\n", + diagnostics, + references, + can_commit: !hasErrors && (input.action !== "delete" || references.length === 0), + target, + content, + contentFileContent, + deletePath: input.action === "delete" ? deletePathForTarget(input.type, target) : undefined, + }; +} + +async function readValidProject(manager: ProjectRuntimeManager): Promise<{ + revision: string; + config: Record; +}> { + await manager.ensureStarted(); + const snapshot = manager.getSnapshot(); + if (snapshot.status !== "valid" || !snapshot.revision) { + throw new DeclarationProtocolError( + `Directory project is ${snapshot.status}. Fix ${manager.projectRoot} before editing.`, + 422, + ); + } + const inspection = await inspectDirectoryProject(manager.projectRoot); + if (!inspection.canonical_yaml) throw new DeclarationProtocolError("Directory project cannot be compiled.", 422); + const config = parse(inspection.canonical_yaml); + if (!isRecord(config)) throw new DeclarationProtocolError("Compiled project must be an object.", 422); + return { revision: inspection.project_revision, config }; +} + +async function locateSourceTarget( + projectRoot: string, + type: DeclarationType, + id: string, + declaration: Record, +): Promise { + if (type === "agent") { + return { + kind: "agent", + path: resolve(projectRoot, "agents", id, "agent.json"), + contentPath: resolve(projectRoot, "agents", id, "instructions.md"), + }; + } + if (type === "skill") { + if (typeof declaration.source !== "string") + throw new DeclarationProtocolError(`skill.${id} has no local source.`, 422); + const skillDirectory = resolve(projectRoot, ".openagentpack", "build", declaration.source); + if (!isWithin(projectRoot, skillDirectory)) + throw new DeclarationProtocolError(`skill.${id} source escapes the project root.`, 422); + return { + kind: "skill", + path: resolve(skillDirectory, "skill.json"), + contentPath: resolve(skillDirectory, "SKILL.md"), + }; + } + return { kind: "project", path: resolve(projectRoot, "project.json") }; +} + +async function declarationForEditor( + type: DeclarationType, + id: string, + declaration: Record, + target: SourceTarget, +): Promise> { + let source = declaration; + if (target.kind === "project") { + const project = JSON.parse(await readFile(target.path, "utf8")) as Record; + const authored = recordValue(project[SECTION_BY_TYPE[type]])[id]; + if (isRecord(authored)) source = authored; + } + const result = structuredClone(source); + if (type === "agent" && target.contentPath) result.instructions = await readFile(target.contentPath, "utf8"); + if (type === "skill" && target.contentPath) result.content = await readFile(target.contentPath, "utf8"); + return result; +} + +function rawDeclarationForConfig( + type: DeclarationType, + editor: Record, + before: Record, +): Record { + const raw = restoreSensitiveSentinels(structuredClone(editor), before) as Record; + if (type === "agent") raw.instructions = before.instructions; + if (type === "skill") { + delete raw.content; + raw.source = before.source; + } + if (type === "file") raw.source = before.source; + return raw; +} + +async function sourceContentForTarget( + type: DeclarationType, + id: string, + target: SourceTarget, + editor: Record, +): Promise<{ metadata: string; contentFile?: string }> { + if (type === "agent") { + if (typeof editor.instructions !== "string") + throw new DeclarationProtocolError("Agent instructions must be text.", 400); + const agent = { ...editor }; + delete agent.instructions; + return { metadata: `${JSON.stringify(agent, null, 2)}\n`, contentFile: editor.instructions }; + } + if (type === "skill") { + if (typeof editor.content !== "string") throw new DeclarationProtocolError("Skill content must be text.", 400); + const existing = JSON.parse(await readFile(target.path, "utf8")) as Record; + const metadata: Record = { ...editor, id: existing.id }; + delete metadata.content; + delete metadata.source; + return { metadata: `${JSON.stringify(metadata, null, 2)}\n`, contentFile: editor.content }; + } + const project = JSON.parse(await readFile(target.path, "utf8")) as Record; + const section = SECTION_BY_TYPE[type]; + const entries = recordValue(project[section]); + entries[id] = editor; + project[section] = entries; + return { metadata: `${JSON.stringify(project, null, 2)}\n` }; +} + +async function commitDelete(projectRoot: string, prepared: PreparedDeclarationChange): Promise { + if (prepared.target.kind === "project") { + const project = JSON.parse(await readFile(prepared.target.path, "utf8")) as Record; + const sectionName = SECTION_BY_TYPE[prepared.type]; + const section = recordValue(project[sectionName]); + delete section[prepared.id]; + if (Object.keys(section).length === 0) delete project[sectionName]; + else project[sectionName] = section; + await writeTextAtomic(prepared.target.path, `${JSON.stringify(project, null, 2)}\n`); + return; + } + if (!prepared.deletePath) throw new DeclarationProtocolError("Declaration has no removable source path.", 422); + const trash = resolve(projectRoot, ".openagentpack", "trash", `${prepared.type}-${prepared.id}-${randomUUID()}`); + await mkdir(dirname(trash), { recursive: true }); + await rename(prepared.deletePath, trash); +} + +function deletePathForTarget(type: DeclarationType, target: SourceTarget): string | undefined { + if (type === "agent") return dirname(target.path); + if (type === "skill") return dirname(target.path); + return undefined; +} + +function applyPlainPatch( + target: Record, + type: DeclarationType, + operation: DeclarationPatchOperation, +): void { + if (operation.path.length === 0 || operation.path.some((entry) => !entry.trim())) { + throw new DeclarationProtocolError("Patch paths must contain non-empty fields.", 400); + } + const field = operation.path[0]!; + if (!EDITABLE_FIELDS[type].has(field)) + throw new DeclarationProtocolError(`Field '${field}' is not editable for ${type}.`, 400); + if (operation.op === "set") + setAtPath(target, operation.path, restoreSensitiveSentinels(operation.value, valueAtPath(target, operation.path))); + else deleteAtPath(target, operation.path); +} + +function setAtPath(target: Record, path: string[], value: unknown): void { + let current: Record | unknown[] = target; + for (let index = 0; index < path.length - 1; index += 1) { + const key = path[index]!; + const next: unknown = Array.isArray(current) ? current[Number(key)] : current[key]; + if (!isRecord(next) && !Array.isArray(next)) + throw new DeclarationProtocolError(`Patch path '${path.join(".")}' does not exist.`, 400); + current = next; + } + const key = path.at(-1)!; + if (Array.isArray(current)) current[Number(key)] = value; + else current[key] = value; +} + +function deleteAtPath(target: Record, path: string[]): void { + let current: Record | unknown[] = target; + for (const key of path.slice(0, -1)) { + const next: unknown = Array.isArray(current) ? current[Number(key)] : current[key]; + if (!isRecord(next) && !Array.isArray(next)) return; + current = next; + } + const key = path.at(-1)!; + if (Array.isArray(current)) current.splice(Number(key), 1); + else delete current[key]; +} + +async function validateConfig(config: Record, projectRoot: string): Promise { + try { + const loaded = await resolveProjectConfigFromObject(config, { + projectName: basename(projectRoot), + basePath: resolve(projectRoot, ".openagentpack", "build"), + }); + return validateProjectConfig(loaded.config); + } catch (error) { + return [ + { + severity: "error", + code: "project.config.invalid", + message: error instanceof Error ? error.message : String(error), + }, + ]; + } +} + +function findDeclarationReferences( + config: Record, + type: DeclarationType, + id: string, +): DeclarationReference[] { + const resolved = config as unknown as ResolvedProjectConfig; + const references: DeclarationReference[] = []; + const add = (referenceType: string, referenceId: string, path: string): void => { + references.push({ type: referenceType, id: referenceId, path }); + }; + for (const [agentId, agent] of Object.entries(resolved.agents ?? {})) { + if (type === "environment" && agent.environment === id) add("agent", agentId, `agents.${agentId}.environment`); + if ( + type === "skill" && + agent.skills?.some((skill) => skill === id || (typeof skill === "object" && skill.skill_id === id)) + ) { + add("agent", agentId, `agents.${agentId}.skills`); + } + if (type === "vault" && agent.vault === id) add("agent", agentId, `agents.${agentId}.vault`); + if (type === "memory_store" && agent.memory_stores?.includes(id)) + add("agent", agentId, `agents.${agentId}.memory_stores`); + if (type === "agent" && agentId !== id && agent.multiagent?.agents.includes(id)) + add("agent", agentId, `agents.${agentId}.multiagent.agents`); + } + if (type === "agent") { + for (const [channelId, channel] of Object.entries(resolved.channels ?? {})) + if (channel.agent === id) add("channel", channelId, `channels.${channelId}.agent`); + } + for (const [deploymentId, deployment] of Object.entries(resolved.deployments ?? {})) { + if (type === "agent" && deployment.agent === id) + add("deployment", deploymentId, `deployments.${deploymentId}.agent`); + if (type === "environment" && deployment.environment === id) + add("deployment", deploymentId, `deployments.${deploymentId}.environment`); + if (type === "vault" && deployment.vaults?.includes(id)) + add("deployment", deploymentId, `deployments.${deploymentId}.vaults`); + if (type === "memory_store" && deployment.memory_stores?.includes(id)) + add("deployment", deploymentId, `deployments.${deploymentId}.memory_stores`); + if ( + type === "memory_store" && + deployment.resources?.some( + (resource) => resource.type === "memory_store" && "memory_store" in resource && resource.memory_store === id, + ) + ) { + add("deployment", deploymentId, `deployments.${deploymentId}.resources`); + } + } + return references; +} + +function readOnlyPaths(type: DeclarationType, declaration: Record): string[][] { + const paths: string[][] = []; + if (type === "environment" && declaration.environment_id !== undefined) paths.push(["environment_id"]); + if (type === "skill") paths.push(["source"]); + if (type === "file") paths.push(["source"]); + return paths; +} + +function declarationSnippet(id: string, declaration: unknown): string { + return stringify({ [id]: redactSensitive(declaration) }, { lineWidth: 0 }); +} + +function publicPreview(prepared: PreparedDeclarationChange): DeclarationPreview { + const { + target: _target, + content: _content, + contentFileContent: _contentFileContent, + deletePath: _deletePath, + ...preview + } = prepared; + return preview; +} + +async function writeTextAtomic(path: string, content: string): Promise { + const details = await stat(path); + const temporary = resolve(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`); + try { + await writeFile(temporary, content, { encoding: "utf8", mode: details.mode }); + await rename(temporary, path); + } catch (error) { + await unlink(temporary).catch(() => undefined); + throw error; + } +} + +function recordValue(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function valueAtPath(value: unknown, path: string[]): unknown { + let current = value; + for (const key of path) { + if (Array.isArray(current)) current = current[Number(key)]; + else if (isRecord(current)) current = current[key]; + else return undefined; + } + return current; +} + +function restoreSensitiveSentinels(value: unknown, existing: unknown): unknown { + if (value === REDACTED && existing !== undefined) return existing; + if (Array.isArray(value)) + return value.map((entry, index) => + restoreSensitiveSentinels(entry, Array.isArray(existing) ? existing[index] : undefined), + ); + if (isRecord(value)) { + const old = isRecord(existing) ? existing : {}; + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, restoreSensitiveSentinels(entry, old[key])]), + ); + } + return value; +} + +function redactSensitive(value: unknown, key = ""): unknown { + if (Array.isArray(value)) return value.map((entry) => redactSensitive(entry)); + if (!isRecord(value)) { + if ( + SENSITIVE_KEY.test(key) && + typeof value === "string" && + !/^\$\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\}$/.test(value) + ) + return REDACTED; + return value; + } + return Object.fromEntries( + Object.entries(value).map(([entryKey, entry]) => [entryKey, redactSensitive(entry, entryKey)]), + ); +} + +function isWithin(root: string, path: string): boolean { + const child = relative(resolve(root), resolve(path)); + return child === "" || (!child.startsWith("..") && !child.startsWith("/")); +} + +function serializeSourceMutation(mutation: () => Promise): Promise { + const result = sourceMutationQueue.then(mutation, mutation); + sourceMutationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; +} diff --git a/apps/server/src/services/project-manager.ts b/apps/server/src/services/project-manager.ts new file mode 100644 index 0000000..4ac63b2 --- /dev/null +++ b/apps/server/src/services/project-manager.ts @@ -0,0 +1,350 @@ +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import { + getProjectBuildStatus, + inspectDirectoryProject, + PROJECT_BUILD_FILE, + PROJECT_METADATA_FILE, + PROJECT_STATE_FILE, + resolveDirectoryProjectRuntime, +} from "@openagentpack/project-workspace"; +import { + type AgentWithReadiness, + type BackendRuntimeInput, + type Diagnostic, + LocalFileStateBackend, + listAgentsWithReadiness, + type ResolvedProjectConfig, + readProjectRuntime, +} from "@openagentpack/sdk"; +import { type FSWatcher, watch } from "chokidar"; +import { type ProjectMutationSnapshot, projectMutationCoordinator } from "@/services/project-mutations"; + +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[]; + active_mutation: ProjectMutationSnapshot | null; + build: { exists: boolean; stale: boolean; reasons: string[]; yaml_hash?: string }; +} + +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 projectRoot: 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(projectDirectory = process.env.AGENTS_PROJECT_ROOT?.trim() || ".") { + this.projectRoot = resolve(projectDirectory); + this.configPath = resolve(this.projectRoot, PROJECT_BUILD_FILE); + this.projectId = createHash("sha256").update(this.projectRoot).digest("hex").slice(0, 16); + this.snapshot = { + status: "loading", + configPath: this.configPath, + projectName: basename(this.projectRoot), + diagnostics: [], + sourcePaths: [resolve(this.projectRoot, PROJECT_METADATA_FILE)], + }; + } + + async ensureStarted(): Promise { + this.startPromise ??= this.reload(); + await this.startPromise; + } + + getSnapshot(): Readonly { + return this.snapshot; + } + + async computeCurrentSourceRevision(): Promise { + await this.ensureStarted(); + return (await inspectDirectoryProject(this.projectRoot)).project_revision; + } + + requireRuntimeInput(): BackendRuntimeInput { + if (this.snapshot.status !== "valid" || !this.snapshot.input) { + throw new ProjectUnavailableError( + `Directory project is ${this.snapshot.status}. Fix ${this.projectRoot} 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), + active_mutation: projectMutationCoordinator.getSnapshot(), + build: await getProjectBuildStatus(this.projectRoot).then((status) => ({ + exists: status.exists, + stale: status.stale, + reasons: status.reasons, + yaml_hash: status.manifest?.yaml_hash, + })), + }; + } + + async refreshAfterMutation(): Promise { + this.readinessCache = undefined; + await this.reload(false); + } + + async refreshAfterSourceMutation(): Promise { + this.readinessCache = undefined; + await this.reload(false); + return this.snapshot.revision; + } + + 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(resolve(this.projectRoot, PROJECT_METADATA_FILE))) { + next = { + status: "missing", + configPath: this.configPath, + projectName: basename(this.projectRoot), + diagnostics: [ + { + severity: "error", + code: "project.config.missing", + message: `Directory project not found: ${resolve(this.projectRoot, PROJECT_METADATA_FILE)}`, + }, + ], + sourcePaths: [resolve(this.projectRoot, PROJECT_METADATA_FILE)], + }; + } else { + const inspection = await inspectDirectoryProject(this.projectRoot); + const diagnostics = [...inspection.diagnostics, ...inspection.warnings]; + const revision = inspection.project_revision; + const hasErrors = diagnostics.some((diagnostic) => diagnostic.severity === "error"); + const loaded = inspection.loaded; + const runtimeLoaded = loaded && !hasErrors ? await resolveDirectoryProjectRuntime(this.projectRoot) : loaded; + const input: BackendRuntimeInput | undefined = runtimeLoaded + ? { + projectName: basename(this.projectRoot), + config: runtimeLoaded.config, + configPath: this.configPath, + providers: runtimeLoaded.config.providers, + stateBackend: new LocalFileStateBackend({ statePath: resolve(this.projectRoot, PROJECT_STATE_FILE) }), + stateScope: { projectId: basename(this.projectRoot) }, + } + : undefined; + next = { + status: hasErrors ? "invalid" : "valid", + configPath: this.configPath, + projectName: basename(this.projectRoot), + revision, + diagnostics, + config: runtimeLoaded?.config, + input, + sourcePaths: inspection.source_files.map((file) => resolve(this.projectRoot, file.path)), + }; + } + } 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") + : [resolve(this.projectRoot, PROJECT_METADATA_FILE)]; + const revision = await inspectDirectoryProject(this.projectRoot) + .then((value) => value.project_revision) + .catch(() => undefined); + next = { + status: "invalid", + configPath: this.configPath, + projectName: basename(this.projectRoot), + revision, + 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(); + 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(): Promise { + await this.closeWatcher(); + const watcher = watch(this.projectRoot, { + ignoreInitial: true, + ignored: (path) => + path === resolve(this.projectRoot, ".openagentpack") || + path.startsWith(`${resolve(this.projectRoot, ".openagentpack")}/`), + 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 snapshotIdentity(snapshot: ProjectSnapshot): string { + return `${snapshot.status}:${snapshot.revision ?? ""}:${snapshot.diagnostics + .map((diagnostic) => `${diagnostic.severity}:${diagnostic.code}:${diagnostic.message}`) + .join("|")}`; +} + +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), + })); +} + +if (process.env.AGENTS_CONFIG_PATH?.trim() && process.env.AGENTS_PROJECT_ROOT?.trim()) { + throw new Error("AGENTS_CONFIG_PATH and AGENTS_PROJECT_ROOT cannot be used by the same Workbench process."); +} + +export const projectRuntimeManager = new ProjectRuntimeManager(); diff --git a/apps/server/src/services/project-mutations.ts b/apps/server/src/services/project-mutations.ts new file mode 100644 index 0000000..f08ac3e --- /dev/null +++ b/apps/server/src/services/project-mutations.ts @@ -0,0 +1,85 @@ +export type ProjectMutationKind = + | "agent_apply" + | "project_apply" + | "project_build" + | "declaration_write" + | "version_enable" + | "version_write" + | "version_restore"; + +export interface ProjectMutationSnapshot { + kind: ProjectMutationKind; + started_at: string; + operation_id?: string; +} + +type MutationListener = (mutation: ProjectMutationSnapshot | null) => void; + +export class ProjectMutationConflictError extends Error { + readonly status = 409; + + constructor(message: string) { + super(message); + this.name = "ProjectMutationConflictError"; + } +} + +export interface ProjectMutationLease { + setOperationId(operationId: string): void; + release(): void; +} + +/** + * One process owns one directory project, so a small in-process lease is the + * authoritative write gate. It intentionally does not try to lock external + * editors; file-watcher revision checks handle those changes as a later plan. + */ +export class ProjectMutationCoordinator { + private active?: ProjectMutationSnapshot & { leaseId: symbol }; + private readonly listeners = new Set(); + + getSnapshot(): ProjectMutationSnapshot | null { + if (!this.active) return null; + const { kind, started_at, operation_id } = this.active; + return { kind, started_at, ...(operation_id ? { operation_id } : {}) }; + } + + subscribe(listener: MutationListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + acquire(kind: ProjectMutationKind): ProjectMutationLease { + if (this.active) { + throw new ProjectMutationConflictError( + `Project mutation '${this.active.kind}' is already running. Wait for it to finish and retry.`, + ); + } + + const leaseId = Symbol(kind); + this.active = { kind, started_at: new Date().toISOString(), leaseId }; + this.emit(); + let released = false; + return { + setOperationId: (operationId) => { + if (released || this.active?.leaseId !== leaseId) return; + this.active.operation_id = operationId; + this.emit(); + }, + release: () => { + if (released) return; + released = true; + if (this.active?.leaseId !== leaseId) return; + this.active = undefined; + this.emit(); + }, + }; + } + + private emit(): void { + const snapshot = this.getSnapshot(); + for (const listener of this.listeners) listener(snapshot); + } +} + +export const projectMutationCoordinator = new ProjectMutationCoordinator(); diff --git a/apps/server/src/services/project-operations.ts b/apps/server/src/services/project-operations.ts new file mode 100644 index 0000000..511bf93 --- /dev/null +++ b/apps/server/src/services/project-operations.ts @@ -0,0 +1,207 @@ +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; + scope: PlanScope; + projectRevision: string; + fingerprint: string; + destructive: boolean; + expiresAt: number; +} + +export type PlanScope = { kind: "agent"; agentId: string } | { kind: "project" }; + +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, scope: PlanScope, projectRevision: string): PlanTokenRecord { + this.evictExpired(); + const record = this.records.get(token); + if (!record || scopeKey(record.scope) !== scopeKey(scope) || 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" | "project.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(scope: PlanScope, 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: scope.kind === "agent" ? "agent.apply" : "project.apply", + ...(scope.kind === "agent" ? { agent_id: scope.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", { + scope: operation.type === "agent.apply" ? `agent:${operation.agent_id}` : "project", + }); + 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(); + +function scopeKey(scope: PlanScope): string { + return scope.kind === "agent" ? `agent:${scope.agentId}` : "project"; +} diff --git a/apps/server/src/services/project-runtime-plan.ts b/apps/server/src/services/project-runtime-plan.ts new file mode 100644 index 0000000..1a4c95e --- /dev/null +++ b/apps/server/src/services/project-runtime-plan.ts @@ -0,0 +1,99 @@ +import { createHash } from "node:crypto"; +import { + type BackendRuntimeInput, + type Diagnostic, + executePlannedProject, + type PlannedAction, + planProjectContext, + type ResourceExecutionResult, + type RuntimeFeedbackSink, + readProjectRuntime, + writeProjectRuntime, +} from "@openagentpack/sdk"; + +export interface ProjectRuntimePlan { + fingerprint: string; + actions: PlannedAction[]; + diagnostics: Diagnostic[]; + destructiveActions: PlannedAction[]; +} + +export async function planProjectRuntimeResources( + input: BackendRuntimeInput, + options: { refresh?: boolean; onFeedback?: RuntimeFeedbackSink } = {}, +): Promise { + return readProjectRuntime(input, async (context) => { + const planned = await planProjectContext(context, { + refresh: options.refresh, + quiet: true, + onFeedback: options.onFeedback, + }); + return scopeProjectRuntimePlan(planned.plan.actions, planned.plan.diagnostics); + }); +} + +export async function applyProjectRuntimeResources( + input: BackendRuntimeInput, + expectedFingerprint: string, + options: { onFeedback?: RuntimeFeedbackSink } = {}, +): Promise<{ plan: ProjectRuntimePlan; execution: ResourceExecutionResult }> { + return writeProjectRuntime(input, async (context) => { + const planned = await planProjectContext(context, { + refresh: true, + quiet: true, + onFeedback: options.onFeedback, + }); + const scoped = scopeProjectRuntimePlan(planned.plan.actions, planned.plan.diagnostics); + if (scoped.fingerprint !== expectedFingerprint) { + throw Object.assign(new Error("Plan is stale because project or remote resources changed. Create a new plan."), { + status: 409, + }); + } + const execution = await executePlannedProject( + { + ...planned, + plan: { ...planned.plan, actions: scoped.actions, diagnostics: scoped.diagnostics }, + destructiveActions: scoped.destructiveActions, + }, + { policy: "force", onFeedback: options.onFeedback }, + ); + const failed = execution.results.find((result) => result.status !== "success"); + if (failed) + throw Object.assign(new Error(failed.error ?? `${failed.action.address.type} apply failed.`), { status: 422 }); + return { plan: scoped, execution }; + }); +} + +export function scopeProjectRuntimePlan(actions: PlannedAction[], diagnostics: Diagnostic[]): ProjectRuntimePlan { + const scopedActions = actions.filter((action) => isRuntimeResourceType(action.address.type)); + const scopedDiagnostics = diagnostics.filter( + (diagnostic) => !diagnostic.resource || isRuntimeResourceType(diagnostic.resource.type), + ); + const destructiveActions = scopedActions.filter((action) => action.action === "delete"); + return { + fingerprint: stableFingerprint({ actions: scopedActions, diagnostics: scopedDiagnostics }), + actions: scopedActions, + diagnostics: scopedDiagnostics, + destructiveActions, + }; +} + +function isRuntimeResourceType(type: string): boolean { + return Boolean(type); +} + +function stableFingerprint(value: unknown): string { + return createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} 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/src/services/project-source-security.ts b/apps/server/src/services/project-source-security.ts new file mode 100644 index 0000000..b7856ae --- /dev/null +++ b/apps/server/src/services/project-source-security.ts @@ -0,0 +1 @@ +export { inspectProjectSource, type ProjectSourceInspection } from "@openagentpack/sdk"; diff --git a/apps/server/src/services/project-versions.ts b/apps/server/src/services/project-versions.ts new file mode 100644 index 0000000..32b99a0 --- /dev/null +++ b/apps/server/src/services/project-versions.ts @@ -0,0 +1,231 @@ +import type { + DirectoryProjectVersion, + DirectoryProjectVersionPreview, + DirectoryProjectVersionStatus, + DirectoryVersionFileChange, + PreparedDirectoryProjectVersion, +} from "@openagentpack/project-versions"; +import { createDirectoryWorkspaceVersionService, inspectDirectoryProject } from "@openagentpack/project-workspace"; +import { type ProjectRuntimeManager, projectRuntimeManager } from "@/services/project-manager"; +import { projectMutationCoordinator } from "@/services/project-mutations"; + +export interface ProjectVersioningStatus { + initialized: boolean; + enabled: boolean; + store_root: string; + config_path: string; + head_version: string | null; + source_status: "clean" | "modified" | "unversioned"; + source_versioned: boolean; + write_blockers: string[]; + restore_blockers: string[]; +} + +export interface ProjectVersionEntry { + version_id: string; + short_version: string; + parent_version: string | null; + source_hash: string; + message: string; + created_by: string; + created_at: string; +} + +export interface ProjectVersionHistoryPage { + versions: ProjectVersionEntry[]; + next_cursor: string | null; +} + +export interface WorkbenchVersionPreview { + version_id: string; + base_revision: string; + base_head_version: string; + before_yaml: string; + after_yaml: string; + changes: DirectoryVersionFileChange[]; + diagnostics: DirectoryProjectVersionPreview["diagnostics"]; + can_restore: boolean; + blockers: string[]; +} + +export interface AutomaticProjectVersionResult { + version: ProjectVersionEntry | null; + versioning: ProjectVersioningStatus; +} + +export class ProjectVersionProtocolError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "ProjectVersionProtocolError"; + } +} + +export async function getProjectVersioningStatus( + manager: ProjectRuntimeManager = projectRuntimeManager, +): Promise { + await manager.ensureStarted(); + return statusForWire(await serviceFor(manager).status(), manager); +} + +export async function setProjectVersioning( + input: { baseRevision: string; enabled: boolean; baselineMessage?: string }, + manager: ProjectRuntimeManager = projectRuntimeManager, +): Promise { + const lease = projectMutationCoordinator.acquire("version_enable"); + try { + await assertRevision(manager, input.baseRevision); + const service = serviceFor(manager); + const status = input.enabled + ? (await service.enable(input.baselineMessage ?? "Enable project versions")).versioning + : await service.disable(); + return statusForWire(status, manager); + } finally { + lease.release(); + } +} + +export async function listProjectVersions( + input: { cursor?: string; limit?: number } = {}, + manager: ProjectRuntimeManager = projectRuntimeManager, +): Promise { + const page = await serviceFor(manager).listVersions(input); + return { versions: page.versions.map(versionForWire), next_cursor: page.next_cursor }; +} + +export async function prepareProjectVersionForApply( + input: { baseRevision: string }, + manager: ProjectRuntimeManager = projectRuntimeManager, +): Promise { + await assertRevision(manager, input.baseRevision); + const service = serviceFor(manager); + const inspection = await inspectDirectoryProject(manager.projectRoot); + if (inspection.project_revision !== input.baseRevision) { + throw new ProjectVersionProtocolError("Project changed before Publish.", 409); + } + return service.prepareVersion({ + project_revision: inspection.project_revision, + canonical_yaml: inspection.canonical_yaml, + files: inspection.source_files, + }); +} + +export async function commitProjectVersionAfterApply( + prepared: PreparedDirectoryProjectVersion | null, + message: string, + manager: ProjectRuntimeManager = projectRuntimeManager, +): Promise { + const service = serviceFor(manager); + const version = prepared ? await service.commitPrepared(prepared, message) : null; + return { + version: version ? versionForWire(version) : null, + versioning: statusForWire(await service.status(), manager), + }; +} + +export async function releaseProjectVersionAfterApply( + prepared: PreparedDirectoryProjectVersion | null, + manager: ProjectRuntimeManager = projectRuntimeManager, +): Promise { + await serviceFor(manager).releasePrepared(prepared); +} + +export async function previewProjectVersion( + input: { versionId: string; baseRevision: string; baseHeadVersion: string }, + manager: ProjectRuntimeManager = projectRuntimeManager, +): Promise { + await assertRevision(manager, input.baseRevision); + const status = await serviceFor(manager).status(); + assertHeadVersion(input.baseHeadVersion, status.head_version); + const preview = await serviceFor(manager).previewVersion(input.versionId); + await assertRevision(manager, input.baseRevision); + return previewForWire(preview, input.baseRevision); +} + +export async function restoreProjectVersion( + input: { versionId: string; baseRevision: string; baseHeadVersion: string }, + manager: ProjectRuntimeManager = projectRuntimeManager, +): Promise { + const lease = projectMutationCoordinator.acquire("version_restore"); + try { + await assertRevision(manager, input.baseRevision); + const service = serviceFor(manager); + const preview = await service.previewVersion(input.versionId); + assertHeadVersion(input.baseHeadVersion, preview.base_head_version); + if (!preview.can_restore) { + throw new ProjectVersionProtocolError( + preview.diagnostics.find((diagnostic) => diagnostic.severity === "error")?.message ?? + preview.blockers[0] ?? + "Version cannot be restored.", + 422, + ); + } + await service.restoreVersion(input.versionId, { + headVersion: input.baseHeadVersion, + projectRevision: preview.base_project_revision, + }); + const newRevision = await manager.refreshAfterSourceMutation(); + if (!newRevision) throw new ProjectVersionProtocolError("The restored project has no revision.", 500); + return { ...previewForWire(preview, input.baseRevision), new_revision: newRevision }; + } finally { + lease.release(); + } +} + +function serviceFor(manager: ProjectRuntimeManager) { + return createDirectoryWorkspaceVersionService(manager.projectRoot); +} + +function statusForWire(status: DirectoryProjectVersionStatus, manager: ProjectRuntimeManager): ProjectVersioningStatus { + return { + initialized: status.initialized, + enabled: status.enabled, + store_root: status.store_root, + config_path: manager.projectRoot, + head_version: status.head_version, + source_status: status.source_status, + source_versioned: status.source_status === "clean", + write_blockers: status.write_blockers, + restore_blockers: status.restore_blockers, + }; +} + +function versionForWire(version: DirectoryProjectVersion): ProjectVersionEntry { + return { + version_id: version.version_id, + short_version: version.short_version, + parent_version: version.parent_version, + source_hash: version.tree_hash, + message: version.message, + created_by: version.created_by, + created_at: version.created_at, + }; +} + +function previewForWire(preview: DirectoryProjectVersionPreview, baseRevision: string): WorkbenchVersionPreview { + return { + version_id: preview.version_id, + base_revision: baseRevision, + base_head_version: preview.base_head_version, + before_yaml: preview.before_yaml, + after_yaml: preview.after_yaml, + changes: preview.changes, + diagnostics: preview.diagnostics, + can_restore: preview.can_restore, + blockers: preview.blockers, + }; +} + +async function assertRevision(manager: ProjectRuntimeManager, expected: string): Promise { + if ((await manager.computeCurrentSourceRevision()) !== expected) { + throw new ProjectVersionProtocolError("Project files changed. Reload before changing versions.", 409); + } +} + +function assertHeadVersion(expected: string | null, current: string | null): void { + if (expected !== current) { + throw new ProjectVersionProtocolError("The current local version changed. Reload and retry.", 409); + } +} diff --git a/apps/server/tests/project-declarations.test.ts b/apps/server/tests/project-declarations.test.ts new file mode 100644 index 0000000..38ca985 --- /dev/null +++ b/apps/server/tests/project-declarations.test.ts @@ -0,0 +1,352 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { acquireDirectoryProjectMutation } from "@openagentpack/project-workspace"; +import { + commitDeclarationChange, + listProjectDeclarations, + previewDeclarationChange, +} from "../src/services/project-declarations"; +import { ProjectRuntimeManager } from "../src/services/project-manager"; +import { projectMutationCoordinator } from "../src/services/project-mutations"; + +const directories: string[] = []; +const managers: ProjectRuntimeManager[] = []; + +afterEach(async () => { + for (const manager of managers.splice(0)) manager.close(); + for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); +}); + +describe("directory project declaration editing", () => { + test("lists authored resources, editable Markdown, references, and redacted secrets", async () => { + const { manager } = await projectFixture(); + const listed = await listProjectDeclarations(manager); + const vault = resource(listed.resources, "vault", "secrets"); + const agent = resource(listed.resources, "agent", "assistant"); + const skill = resource(listed.resources, "skill", "helper"); + const file = resource(listed.resources, "file", "input"); + + expect(listed.resources.map((entry) => `${entry.type}.${entry.id}`)).toContain("environment.sandbox"); + expect(agent.declaration.instructions).toBe("External instructions\n"); + expect(agent.read_only_paths).not.toContainEqual(["instructions"]); + expect(skill.declaration.content).toBe("# Helper\n\nHelp the selected Agent.\n"); + expect(file.declaration.source).toBe("./keep.txt"); + expect(agent.references.map((reference) => reference.path)).toContain("deployments.daily.agent"); + expect(vault.declaration.credentials).toEqual([ + { + name: "bearer", + type: "static_bearer", + mcp_server_url: "https://example.com/mcp", + access_token: "[redacted]", + }, + ]); + expect(JSON.stringify(listed)).not.toContain("literal-secret"); + }); + + test("previews without writing, then atomically updates Agent JSON and instructions Markdown", async () => { + const { directory, manager } = await projectFixture(); + const metadataPath = join(directory, "agents/assistant/agent.json"); + const instructionsPath = join(directory, "agents/assistant/instructions.md"); + await chmod(metadataPath, 0o640); + const beforeMetadata = await readFile(metadataPath, "utf8"); + const beforeInstructions = await readFile(instructionsPath, "utf8"); + const listed = await listProjectDeclarations(manager); + const input = { + type: "agent" as const, + id: "assistant", + baseRevision: listed.revision, + action: "update" as const, + operations: [ + { op: "set" as const, path: ["description"], value: "Updated locally" }, + { op: "set" as const, path: ["instructions"], value: "Updated instructions\n" }, + ], + }; + + const preview = await previewDeclarationChange(input, manager); + expect(preview.can_commit).toBe(true); + expect(preview.after_yaml).toContain("Updated locally"); + expect(preview.after_yaml).toContain("Updated instructions"); + expect(await readFile(metadataPath, "utf8")).toBe(beforeMetadata); + expect(await readFile(instructionsPath, "utf8")).toBe(beforeInstructions); + + const committed = await commitDeclarationChange(input, manager); + expect(committed.new_revision).not.toBe(listed.revision); + expect(JSON.parse(await readFile(metadataPath, "utf8"))).toMatchObject({ description: "Updated locally" }); + expect(await readFile(instructionsPath, "utf8")).toBe("Updated instructions\n"); + expect((await stat(metadataPath)).mode & 0o777).toBe(0o640); + }); + + test("updates Skill Markdown and preserves authored local File paths", async () => { + const { directory, manager } = await projectFixture(); + let listed = await listProjectDeclarations(manager); + await commitDeclarationChange( + { + type: "skill", + id: "helper", + baseRevision: listed.revision, + action: "update", + operations: [{ op: "set", path: ["content"], value: "# Helper\n\nUpdated.\n" }], + }, + manager, + ); + expect(await readFile(join(directory, "skills/helper/SKILL.md"), "utf8")).toContain("Updated."); + + listed = await listProjectDeclarations(manager); + await commitDeclarationChange( + { + type: "file", + id: "input", + baseRevision: listed.revision, + action: "update", + operations: [{ op: "set", path: ["name"], value: "Input File" }], + }, + manager, + ); + const project = JSON.parse(await readFile(join(directory, "project.json"), "utf8")); + expect(project.files.input).toEqual({ source: "./keep.txt", name: "Input File" }); + }); + + test("rejects stale revisions, missing resources, and writes during another mutation", async () => { + const { directory, manager } = await projectFixture(); + const listed = await listProjectDeclarations(manager); + await writeFile(join(directory, "notes.md"), "external edit\n"); + + await expect( + previewDeclarationChange( + { + type: "agent", + id: "assistant", + baseRevision: listed.revision, + action: "update", + operations: [{ op: "set", path: ["description"], value: "stale" }], + }, + manager, + ), + ).rejects.toMatchObject({ status: 409 }); + await manager.refreshAfterSourceMutation(); + await expect( + previewDeclarationChange( + { + type: "agent", + id: "missing", + baseRevision: manager.getSnapshot().revision!, + action: "update", + operations: [{ op: "set", path: ["description"], value: "new" }], + }, + manager, + ), + ).rejects.toMatchObject({ status: 404 }); + + const filesystemLease = await acquireDirectoryProjectMutation(directory, "publish"); + try { + await expect( + commitDeclarationChange( + { + type: "agent", + id: "assistant", + baseRevision: manager.getSnapshot().revision!, + action: "update", + operations: [{ op: "set", path: ["description"], value: "blocked" }], + }, + manager, + ), + ).rejects.toThrow(/busy/i); + } finally { + await filesystemLease.release(); + } + }); + + test("blocks referenced deletes and reports every protected dependency", async () => { + const { manager } = await projectFixture(); + const listed = await listProjectDeclarations(manager); + const paths = (type: "agent" | "environment" | "skill" | "vault" | "memory_store", id: string) => + resource(listed.resources, type, id).references.map((reference) => reference.path); + + expect(paths("agent", "assistant")).toContain("deployments.daily.agent"); + expect(paths("environment", "sandbox")).toEqual(["agents.assistant.environment", "deployments.daily.environment"]); + expect(paths("skill", "helper")).toEqual(["agents.assistant.skills"]); + expect(paths("vault", "secrets")).toEqual(["agents.assistant.vault", "deployments.daily.vaults"]); + expect(paths("memory_store", "memory")).toContain("deployments.daily.resources"); + + const preview = await previewDeclarationChange( + { type: "agent", id: "assistant", baseRevision: listed.revision, action: "delete" }, + manager, + ); + expect(preview.can_commit).toBe(false); + await expect( + commitDeclarationChange( + { type: "agent", id: "assistant", baseRevision: listed.revision, action: "delete" }, + manager, + ), + ).rejects.toMatchObject({ status: 409 }); + }); + + test("removes an unreferenced Agent into local trash and leaves State untouched", async () => { + const { directory, manager } = await projectFixture({ deployment: false }); + const statePath = join(directory, ".openagentpack/state.json"); + await mkdir(join(directory, ".openagentpack"), { recursive: true }); + await writeFile(statePath, '{"remote":"latest"}\n'); + const listed = await listProjectDeclarations(manager); + + await commitDeclarationChange( + { type: "agent", id: "assistant", baseRevision: listed.revision, action: "delete" }, + manager, + ); + expect(await stat(join(directory, "agents/assistant")).catch(() => null)).toBeNull(); + expect((await readdir(join(directory, ".openagentpack/trash")))[0]).toStartWith("agent-assistant-"); + expect(await readFile(statePath, "utf8")).toBe('{"remote":"latest"}\n'); + }); + + test("removes a File declaration without deleting its local source", async () => { + const { directory, manager } = await projectFixture(); + const listed = await listProjectDeclarations(manager); + await commitDeclarationChange( + { type: "file", id: "input", baseRevision: listed.revision, action: "delete" }, + manager, + ); + + const project = JSON.parse(await readFile(join(directory, "project.json"), "utf8")); + expect(project.files).toBeUndefined(); + expect(await readFile(join(directory, "keep.txt"), "utf8")).toBe("Keep local file\n"); + }); + + test("preserves redacted Vault values and redacts replacement secrets in Preview", async () => { + const { directory, manager } = await projectFixture(); + const listed = await listProjectDeclarations(manager); + const vault = resource(listed.resources, "vault", "secrets"); + await commitDeclarationChange( + { + type: "vault", + id: "secrets", + baseRevision: listed.revision, + action: "update", + operations: [{ op: "set", path: ["credentials"], value: vault.declaration.credentials }], + }, + manager, + ); + expect(await readFile(join(directory, "project.json"), "utf8")).toContain("literal-secret"); + + const refreshed = await listProjectDeclarations(manager); + const preview = await previewDeclarationChange( + { + type: "vault", + id: "secrets", + baseRevision: refreshed.revision, + action: "update", + operations: [ + { + op: "set", + path: ["credentials"], + value: [{ name: "bearer", type: "static_bearer", access_token: "replacement-secret" }], + }, + ], + }, + manager, + ); + expect(JSON.stringify(preview)).not.toContain("literal-secret"); + expect(JSON.stringify(preview)).not.toContain("replacement-secret"); + expect(preview.after_yaml).toContain("[redacted]"); + }); + + test("in-process Apply coordination still blocks Workbench writes", async () => { + const { manager } = await projectFixture(); + const listed = await listProjectDeclarations(manager); + const lease = projectMutationCoordinator.acquire("project_apply"); + try { + await expect( + commitDeclarationChange( + { + type: "agent", + id: "assistant", + baseRevision: listed.revision, + action: "update", + operations: [{ op: "set", path: ["description"], value: "draft" }], + }, + manager, + ), + ).rejects.toMatchObject({ status: 409 }); + } finally { + lease.release(); + } + }); +}); + +async function projectFixture(options: { deployment?: boolean } = {}): Promise<{ + directory: string; + manager: ProjectRuntimeManager; +}> { + const directory = await mkdtemp(join(tmpdir(), "openagentpack-declarations-")); + directories.push(directory); + await mkdir(join(directory, "agents/assistant"), { recursive: true }); + await mkdir(join(directory, "skills/helper"), { recursive: true }); + await writeFile(join(directory, "agents/assistant/instructions.md"), "External instructions\n"); + await writeFile( + join(directory, "agents/assistant/agent.json"), + `${JSON.stringify( + { + description: "Existing Agent", + model: "ultimate", + environment: "sandbox", + skills: ["helper"], + vault: "secrets", + memory_stores: ["memory"], + }, + null, + 2, + )}\n`, + ); + await writeFile(join(directory, "skills/helper/skill.json"), '{"id":"helper","name":"Helper"}\n'); + await writeFile(join(directory, "skills/helper/SKILL.md"), "# Helper\n\nHelp the selected Agent.\n"); + await writeFile(join(directory, "keep.txt"), "Keep local file\n"); + const project: Record = { + version: "1", + providers: { qoder: {} }, + defaults: { provider: "qoder" }, + environments: { sandbox: { config: { type: "cloud" } } }, + vaults: { + secrets: { + display_name: "Secrets", + credentials: [ + { + name: "bearer", + type: "static_bearer", + mcp_server_url: "https://example.com/mcp", + access_token: "literal-secret", + }, + ], + }, + }, + memory_stores: { memory: { description: "Existing memory", entries: [{ key: "note", content: "inline note" }] } }, + files: { input: { source: "./keep.txt" } }, + }; + if (options.deployment !== false) { + project.deployments = { + daily: { + agent: "assistant", + environment: "sandbox", + vaults: ["secrets"], + memory_stores: ["memory"], + resources: [{ type: "memory_store", memory_store: "memory" }], + initial_events: [{ type: "user.message", content: "run" }], + }, + }; + } + await writeFile(join(directory, "project.json"), `${JSON.stringify(project, null, 2)}\n`); + const manager = new ProjectRuntimeManager(directory); + managers.push(manager); + await manager.ensureStarted(); + if (manager.getSnapshot().status !== "valid") throw new Error(JSON.stringify(manager.getSnapshot().diagnostics)); + return { directory, manager }; +} + +function resource( + resources: Awaited>["resources"], + type: (typeof resources)[number]["type"], + id: string, +) { + const found = resources.find((entry) => entry.type === type && entry.id === id); + if (!found) throw new Error(`Missing ${type}.${id}`); + return found; +} diff --git a/apps/server/tests/project-manager.test.ts b/apps/server/tests/project-manager.test.ts new file mode 100644 index 0000000..870eb1e --- /dev/null +++ b/apps/server/tests/project-manager.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + commitProjectBuild, + getProjectBuildStatus, + initializeDirectoryProject, +} from "@openagentpack/project-workspace"; +import { ProjectRuntimeManager } from "../src/services/project-manager"; + +const directories: string[] = []; +const managers: ProjectRuntimeManager[] = []; +process.env.QODER_PAT ??= "test-qoder-pat"; + +afterEach(async () => { + for (const manager of managers.splice(0)) manager.close(); + for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); +}); + +describe("ProjectRuntimeManager", () => { + test("surfaces a missing directory project and watches its root", async () => { + const directory = await temporaryDirectory("missing"); + const manager = trackManager(new ProjectRuntimeManager(directory)); + + await manager.ensureStarted(); + const summary = await manager.getSummary(); + expect(summary.status).toBe("missing"); + expect(summary.diagnostics[0]?.code).toBe("project.config.missing"); + expect(summary.config_file).toBe(join(directory, ".openagentpack/build/agents.yaml")); + }); + + test("loads directory source and changes revision when instructions change", async () => { + const directory = await initializedProject("watch"); + const manager = trackManager(new ProjectRuntimeManager(directory)); + + await manager.ensureStarted(); + const first = manager.getSnapshot(); + expect(first.status).toBe("valid"); + expect(first.config?.agents.assistant).toBeDefined(); + + await writeFile(join(directory, "agents/assistant/instructions.md"), "Second instruction\n"); + await manager.refreshAfterSourceMutation(); + const second = manager.getSnapshot(); + expect(second.status).toBe("valid"); + expect(second.revision).not.toBe(first.revision); + }); + + test("marks the generated Build stale after any source-tree edit", async () => { + const directory = await initializedProject("build-stale"); + const manager = trackManager(new ProjectRuntimeManager(directory)); + await manager.ensureStarted(); + await commitProjectBuild({ projectRoot: directory, baseRevision: manager.getSnapshot().revision! }); + await manager.refreshAfterSourceMutation(); + expect((await getProjectBuildStatus(directory)).stale).toBe(false); + + await writeFile(join(directory, "notes.md"), "A manually edited project file.\n"); + manager.scheduleReload(); + await waitFor(() => manager.getSnapshot().sourcePaths.includes(join(directory, "notes.md"))); + const build = await getProjectBuildStatus(directory); + expect(build.stale).toBe(true); + expect(build.reasons).toContain("Project source changed after the last Build."); + }); + + test("keeps parsed Agents visible when a cross-reference is invalid", async () => { + const directory = await initializedProject("invalid-reference"); + await writeFile( + join(directory, "agents/assistant/agent.json"), + `${JSON.stringify({ name: "Assistant", model: "qwen-plus", environment: "missing" }, null, 2)}\n`, + ); + const manager = trackManager(new ProjectRuntimeManager(directory)); + + await manager.ensureStarted(); + const snapshot = manager.getSnapshot(); + expect(snapshot.status).toBe("invalid"); + expect(snapshot.config?.agents.assistant).toBeDefined(); + expect(snapshot.diagnostics.some((diagnostic) => diagnostic.code === "config.agent.environment.unknown")).toBe( + true, + ); + expect(() => manager.requireRuntimeInput()).toThrow(/directory project is invalid/i); + }); + + test("maintains a source revision while project JSON is syntactically invalid", async () => { + const directory = await initializedProject("invalid-json"); + const manager = trackManager(new ProjectRuntimeManager(directory)); + await manager.ensureStarted(); + + await writeFile(join(directory, "project.json"), "{\n"); + await manager.refreshAfterSourceMutation(); + const firstRevision = manager.getSnapshot().revision; + expect(manager.getSnapshot().status).toBe("invalid"); + expect(firstRevision).toBeString(); + + await writeFile(join(directory, "project.json"), '{"version":\n'); + await manager.refreshAfterSourceMutation(); + expect(manager.getSnapshot().revision).not.toBe(firstRevision); + }); +}); + +async function initializedProject(name: string): Promise { + const directory = await temporaryDirectory(name); + await initializeDirectoryProject({ projectRoot: directory, provider: "qoder" }); + return directory; +} + +async function temporaryDirectory(name: string): Promise { + const directory = await mkdtemp(join(tmpdir(), `openagentpack-project-${name}-`)); + directories.push(directory); + return directory; +} + +function trackManager(manager: ProjectRuntimeManager): ProjectRuntimeManager { + managers.push(manager); + return manager; +} + +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 directory project reload"); +} diff --git a/apps/server/tests/project-mutations.test.ts b/apps/server/tests/project-mutations.test.ts new file mode 100644 index 0000000..c269471 --- /dev/null +++ b/apps/server/tests/project-mutations.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { ProjectMutationCoordinator } from "../src/services/project-mutations"; + +describe("ProjectMutationCoordinator", () => { + test("publishes one project-wide mutation and rejects concurrent writers", () => { + const coordinator = new ProjectMutationCoordinator(); + const snapshots: Array = []; + coordinator.subscribe((snapshot) => snapshots.push(snapshot?.kind ?? null)); + const lease = coordinator.acquire("project_apply"); + lease.setOperationId("operation-1"); + + expect(coordinator.getSnapshot()).toMatchObject({ kind: "project_apply", operation_id: "operation-1" }); + expect(() => coordinator.acquire("declaration_write")).toThrow(/already running/i); + lease.release(); + expect(coordinator.getSnapshot()).toBeNull(); + expect(snapshots).toEqual(["project_apply", "project_apply", null]); + }); + + test("ignores duplicate lease release", () => { + const coordinator = new ProjectMutationCoordinator(); + const lease = coordinator.acquire("version_write"); + lease.release(); + lease.release(); + expect(coordinator.getSnapshot()).toBeNull(); + }); +}); diff --git a/apps/server/tests/project-operations.test.ts b/apps/server/tests/project-operations.test.ts new file mode 100644 index 0000000..2ba521e --- /dev/null +++ b/apps/server/tests/project-operations.test.ts @@ -0,0 +1,87 @@ +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 scope = { kind: "agent" as const, agentId: "assistant" }; + const record = store.issue({ + scope, + projectRevision: "revision-a", + fingerprint: "fingerprint-a", + destructive: false, + }); + expect(store.require(record.token, scope, "revision-a").fingerprint).toBe("fingerprint-a"); + expect(() => store.require(record.token, { kind: "agent", agentId: "other" }, "revision-a")).toThrow(/stale/i); + store.consume(record.token); + expect(() => store.require(record.token, scope, "revision-a")).toThrow(/stale/i); + }); + + test("rejects a retained token record after project-wide invalidation", () => { + const store = new PlanTokenStore(); + const record = store.issue({ + scope: { kind: "project" }, + projectRevision: "revision-a", + fingerprint: "fingerprint-a", + destructive: false, + }); + store.invalidateAll(); + + expect(() => store.require(record.token, { kind: "project" }, record.projectRevision)).toThrow(/stale/i); + }); + + test("keeps Agent and Project tokens in separate scopes", () => { + const store = new PlanTokenStore(); + const projectRecord = store.issue({ + scope: { kind: "project" }, + projectRevision: "revision-a", + fingerprint: "fingerprint-a", + destructive: true, + }); + + expect(store.require(projectRecord.token, { kind: "project" }, "revision-a").destructive).toBe(true); + expect(() => store.require(projectRecord.token, { kind: "agent", agentId: "assistant" }, "revision-a")).toThrow( + /stale/i, + ); + }); + + test("rejects expired plan tokens", () => { + const originalNow = Date.now; + let now = originalNow(); + Date.now = () => now; + try { + const store = new PlanTokenStore(); + const record = store.issue({ + scope: { kind: "project" }, + projectRevision: "revision-a", + fingerprint: "fingerprint-a", + destructive: false, + }); + now += 11 * 60 * 1000; + + expect(() => store.require(record.token, { kind: "project" }, record.projectRevision)).toThrow(/stale/i); + } finally { + Date.now = originalNow; + } + }); + + 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({ kind: "agent", agentId: "assistant" }, async (reporter) => { + reporter.emit("phase", { message: "planning" }); + await gate; + return { ok: true }; + }); + await Bun.sleep(0); + expect(() => store.create({ kind: "project" }, 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-runtime-plan.test.ts b/apps/server/tests/project-runtime-plan.test.ts new file mode 100644 index 0000000..0db5ffc --- /dev/null +++ b/apps/server/tests/project-runtime-plan.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import type { Diagnostic, PlannedAction } from "@openagentpack/sdk"; +import { scopeProjectRuntimePlan } from "@/services/project-runtime-plan"; + +describe("project runtime plan scope", () => { + test("keeps the complete Publish action set including Deployment and Channel", () => { + const actions = [ + action("template", "create", "assistant"), + action("vault", "update", "secrets"), + action("agent", "delete", "retired"), + action("identity", "no-op", "runtime-identity"), + action("deployment", "delete", "daily"), + action("channel", "create", "chat"), + ]; + const diagnostics: Diagnostic[] = [ + { severity: "warning", code: "global", message: "global warning" }, + { + severity: "error", + code: "deployment.error", + message: "deployment error", + resource: { type: "deployment", name: "daily", provider: "qoder" }, + }, + { + severity: "warning", + code: "agent.warning", + message: "agent warning", + resource: { type: "agent", name: "assistant", provider: "qoder" }, + }, + ]; + + const plan = scopeProjectRuntimePlan(actions, diagnostics); + + expect(plan.actions.map((entry) => [entry.address.type, entry.action])).toEqual([ + ["template", "create"], + ["vault", "update"], + ["agent", "delete"], + ["identity", "no-op"], + ["deployment", "delete"], + ["channel", "create"], + ]); + expect(plan.destructiveActions.map((entry) => entry.address.name)).toEqual(["retired", "daily"]); + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + "global", + "deployment.error", + "agent.warning", + ]); + expect(plan.fingerprint).toMatch(/^[a-f0-9]{64}$/); + }); + + test("fingerprints the exact scoped action set used by Apply", () => { + const original = scopeProjectRuntimePlan([action("agent", "update", "assistant")], []); + const changed = scopeProjectRuntimePlan([action("agent", "delete", "assistant")], []); + const deploymentChange = scopeProjectRuntimePlan( + [action("agent", "update", "assistant"), action("deployment", "delete", "daily")], + [], + ); + + expect(changed.fingerprint).not.toBe(original.fingerprint); + expect(deploymentChange.fingerprint).not.toBe(original.fingerprint); + }); +}); + +function action( + type: PlannedAction["address"]["type"], + actionKind: PlannedAction["action"], + name: string, +): PlannedAction { + return { + action: actionKind, + address: { type, name, provider: "qoder" }, + reason: `${actionKind} ${type}`, + dependencies: [], + }; +} diff --git a/apps/server/tests/project-security.test.ts b/apps/server/tests/project-security.test.ts new file mode 100644 index 0000000..2f5f287 --- /dev/null +++ b/apps/server/tests/project-security.test.ts @@ -0,0 +1,110 @@ +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("protects declaration preview, PATCH, DELETE, and project Plan/Apply routes", async () => { + process.env.AGENTS_PLAYGROUND_TOKEN = "test-local-token"; + const requests = [ + new Request("http://localhost/api/project/declarations/agent/assistant/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ base_revision: "revision", action: "delete" }), + }), + new Request("http://localhost/api/project/declarations/agent/assistant", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + base_revision: "revision", + operations: [{ op: "set", path: ["description"], value: "updated" }], + }), + }), + new Request("http://localhost/api/project/declarations/agent/assistant", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ base_revision: "revision" }), + }), + new Request("http://localhost/api/project/plan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh: false }), + }), + new Request("http://localhost/api/project/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ plan_token: "token", confirm_destructive: false }), + }), + new Request("http://localhost/api/project/versioning/enable", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ base_revision: "revision" }), + }), + new Request("http://localhost/api/project/versioning/disable", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ base_revision: "revision" }), + }), + ]; + + for (const request of requests) expect((await app.request(request)).status).toBe(403); + }); + + test("does not expose a declaration create route", async () => { + process.env.AGENTS_PLAYGROUND_TOKEN = "test-local-token"; + const response = await app.request("/api/project/declarations/agent/new-agent", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Agents-Playground-Token": "test-local-token", + }, + body: JSON.stringify({ declaration: { model: "model", instructions: "instructions" } }), + }); + + expect(response.status).toBe(404); + }); + + test("requires the launch token for versioning reads as well as writes", async () => { + process.env.AGENTS_PLAYGROUND_TOKEN = "test-local-token"; + for (const path of ["/api/project/versioning", "/api/project/versions"]) { + expect((await app.request(path)).status).toBe(403); + } + const authenticated = await app.request("/api/project/versioning", { + headers: { "X-Agents-Playground-Token": "test-local-token" }, + }); + 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/server/tests/project-source-security.test.ts b/apps/server/tests/project-source-security.test.ts new file mode 100644 index 0000000..2212a63 --- /dev/null +++ b/apps/server/tests/project-source-security.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { inspectProjectSource } from "../src/services/project-source-security"; + +describe("project version source security", () => { + test("allows environment references, preserves secret identifiers, and redacts preview values", async () => { + const environmentReference = ["$", "{", "WORKBENCH_TOKEN", "}"].join(""); + const source = sourceWithSecret(environmentReference); + const inspected = await inspectProjectSource(source, "/tmp/project/agents.yaml"); + + expect(inspected.diagnostics.some((diagnostic) => diagnostic.code === "project.version.sensitive_literal")).toBe( + false, + ); + expect(inspected.redacted_source).not.toContain(environmentReference); + expect(inspected.redacted_source).toContain("secret_name: SERVICE_TOKEN"); + expect(inspected.redacted_source).toContain("mcp-credentials:"); + }); + + test("blocks literal values without returning the literal in source or diagnostics", async () => { + const inspected = await inspectProjectSource(sourceWithSecret("literal-do-not-leak"), "/tmp/project/agents.yaml"); + + expect(inspected.diagnostics.some((diagnostic) => diagnostic.code === "project.version.sensitive_literal")).toBe( + true, + ); + expect(JSON.stringify(inspected)).not.toContain("literal-do-not-leak"); + expect(inspected.redacted_source).toContain('access_token: "[redacted]"'); + }); + + test("rejects environment references that embed a plaintext default", async () => { + const environmentReferenceWithDefault = ["$", "{", "WORKBENCH_TOKEN", ":-literal-default}"].join(""); + const inspected = await inspectProjectSource( + sourceWithSecret(environmentReferenceWithDefault), + "/tmp/project/agents.yaml", + ); + + expect(inspected.diagnostics.some((diagnostic) => diagnostic.code === "project.version.sensitive_literal")).toBe( + true, + ); + expect(JSON.stringify(inspected)).not.toContain("literal-default"); + }); + + test("redacts escaped sensitive scalars from the serialized safe preview", async () => { + const source = sourceWithSecret('"literal\\u002dsecret"'); + const inspected = await inspectProjectSource(source, "/tmp/project/agents.yaml"); + + expect(inspected.diagnostics.some((diagnostic) => diagnostic.code === "project.version.sensitive_literal")).toBe( + true, + ); + expect(inspected.redacted_source).toContain('access_token: "[redacted]"'); + expect(inspected.redacted_source).not.toContain("literal\\u002dsecret"); + expect(inspected.redacted_source).not.toContain("literal-secret"); + }); + + test("omits malformed source instead of leaking multiline sensitive values", async () => { + const source = `${sourceWithSecret("|\n multiline-secret")}broken: "\n`; + const inspected = await inspectProjectSource(source, "/tmp/project/agents.yaml"); + + expect(inspected.diagnostics.some((diagnostic) => diagnostic.code === "project.config.invalid")).toBe(true); + expect(inspected.redacted_source).toBe("# Invalid agents.yaml source omitted from the safe preview.\n"); + expect(JSON.stringify(inspected)).not.toContain("multiline-secret"); + }); +}); + +function sourceWithSecret(accessToken: string): string { + return `version: "1" +providers: + qoder: {} +defaults: + provider: qoder +vaults: + mcp-credentials: + display_name: MCP credentials + credentials: + - name: service + type: static_bearer + mcp_server_url: https://example.com/mcp + secret_name: SERVICE_TOKEN + access_token: ${accessToken} +agents: + assistant: + model: ultimate + instructions: Help the user + vault: mcp-credentials +`; +} diff --git a/apps/server/tests/project-versions.test.ts b/apps/server/tests/project-versions.test.ts new file mode 100644 index 0000000..f7bce55 --- /dev/null +++ b/apps/server/tests/project-versions.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { initializeDirectoryProject } from "@openagentpack/project-workspace"; +import { ProjectRuntimeManager } from "../src/services/project-manager"; +import { + commitProjectVersionAfterApply, + getProjectVersioningStatus, + listProjectVersions, + prepareProjectVersionForApply, + previewProjectVersion, + releaseProjectVersionAfterApply, + restoreProjectVersion, + setProjectVersioning, +} from "../src/services/project-versions"; + +const directories: string[] = []; +const managers: ProjectRuntimeManager[] = []; +process.env.QODER_PAT ??= "test-qoder-pat"; + +afterEach(async () => { + for (const manager of managers.splice(0)) manager.close(); + for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); +}); + +describe("Workbench directory project versions", () => { + test("project init enables one shared snapshot store with a baseline", async () => { + const fixture = await projectFixture(); + const status = await getProjectVersioningStatus(fixture.manager); + + expect(status.initialized).toBe(true); + expect(status.enabled).toBe(true); + expect(status.store_root).toBe(join(fixture.root, ".openagentpack/versions/project")); + expect(status.head_version).toMatch(/^[a-f0-9]{64}$/); + expect(status.source_versioned).toBe(true); + expect((await listProjectVersions({}, fixture.manager)).versions[0]?.message).toBe("Initialize project"); + }); + + test("records complete directory source and restores it without moving head or State", async () => { + const fixture = await projectFixture(); + const instructionsPath = join(fixture.root, "agents/assistant/instructions.md"); + const binaryPath = join(fixture.root, "assets/icon.bin"); + const statePath = join(fixture.root, ".openagentpack/state.json"); + await writeFile(statePath, '{"remote":"latest"}\n'); + await chmod(instructionsPath, 0o640); + const baseline = (await getProjectVersioningStatus(fixture.manager)).head_version!; + + await writeFile(instructionsPath, "Second instructions\n"); + await mkdir(join(fixture.root, "assets")); + await writeFile(binaryPath, new Uint8Array([0, 1, 2, 3])); + await fixture.manager.refreshAfterSourceMutation(); + const prepared = await prepareProjectVersionForApply({ baseRevision: fixture.revision() }, fixture.manager); + const committed = await commitProjectVersionAfterApply(prepared, "Publish second", fixture.manager); + const second = committed.version!.version_id; + + const preview = await previewProjectVersion( + { versionId: baseline, baseRevision: fixture.revision(), baseHeadVersion: second }, + fixture.manager, + ); + expect(preview.changes).toContainEqual( + expect.objectContaining({ + path: "agents/assistant/instructions.md", + change: "update", + before: "Second instructions\n", + after: "You are a helpful assistant.\n", + }), + ); + expect(preview.changes).toContainEqual( + expect.objectContaining({ path: "assets/icon.bin", change: "delete", binary: true }), + ); + + const revision = fixture.revision(); + const restored = await restoreProjectVersion( + { versionId: baseline, baseRevision: revision, baseHeadVersion: second }, + fixture.manager, + ); + expect(restored.new_revision).not.toBe(revision); + expect(await readFile(instructionsPath, "utf8")).toBe("You are a helpful assistant.\n"); + expect(await stat(binaryPath).catch(() => null)).toBeNull(); + expect((await getProjectVersioningStatus(fixture.manager)).head_version).toBe(second); + expect(await readFile(statePath, "utf8")).toBe('{"remote":"latest"}\n'); + expect((await stat(instructionsPath)).mode & 0o777).toBe(0o644); + }); + + test("disabled versioning still leases the directory during Publish without creating versions", async () => { + const fixture = await projectFixture(); + const baseline = (await getProjectVersioningStatus(fixture.manager)).head_version!; + await setProjectVersioning({ baseRevision: fixture.revision(), enabled: false }, fixture.manager); + await writeFile(join(fixture.root, "agents/assistant/instructions.md"), "Changed while disabled\n"); + await fixture.manager.refreshAfterSourceMutation(); + + const prepared = await prepareProjectVersionForApply({ baseRevision: fixture.revision() }, fixture.manager); + await expect( + setProjectVersioning({ baseRevision: fixture.revision(), enabled: true }, fixture.manager), + ).rejects.toThrow(/busy/i); + const committed = await commitProjectVersionAfterApply(prepared, "Disabled Publish", fixture.manager); + expect(committed.version).toBeNull(); + expect(committed.versioning.head_version).toBe(baseline); + expect(committed.versioning.enabled).toBe(false); + }); + + test("reuses the current head for a no-op Publish and rejects stale or abbreviated identities", async () => { + const fixture = await projectFixture(); + const status = await getProjectVersioningStatus(fixture.manager); + const prepared = await prepareProjectVersionForApply({ baseRevision: fixture.revision() }, fixture.manager); + const committed = await commitProjectVersionAfterApply(prepared, "No-op Publish", fixture.manager); + expect(committed.version).toBeNull(); + expect(committed.versioning.head_version).toBe(status.head_version); + + await expect( + previewProjectVersion( + { + versionId: status.head_version!.slice(0, 12), + baseRevision: fixture.revision(), + baseHeadVersion: status.head_version!, + }, + fixture.manager, + ), + ).rejects.toThrow(/full 64-character/i); + await expect( + setProjectVersioning({ baseRevision: "stale", enabled: false }, fixture.manager), + ).rejects.toMatchObject({ status: 409 }); + }); + + test("releases the cross-process mutation lease when Publish preparation is abandoned", async () => { + const fixture = await projectFixture(); + const prepared = await prepareProjectVersionForApply({ baseRevision: fixture.revision() }, fixture.manager); + await releaseProjectVersionAfterApply(prepared, fixture.manager); + const disabled = await setProjectVersioning({ baseRevision: fixture.revision(), enabled: false }, fixture.manager); + expect(disabled.enabled).toBe(false); + }); +}); + +async function projectFixture(): Promise<{ + root: string; + manager: ProjectRuntimeManager; + revision(): string; +}> { + const root = await mkdtemp(join(tmpdir(), "openagentpack-versions-")); + directories.push(root); + await initializeDirectoryProject({ projectRoot: root, provider: "qoder" }); + const manager = new ProjectRuntimeManager(root); + managers.push(manager); + await manager.ensureStarted(); + if (manager.getSnapshot().status !== "valid") throw new Error(JSON.stringify(manager.getSnapshot().diagnostics)); + return { root, manager, revision: () => manager.getSnapshot().revision! }; +} 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..ee211aa 100644 --- a/apps/webui/src/App.tsx +++ b/apps/webui/src/App.tsx @@ -1,275 +1,1365 @@ -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, + Braces, + CheckCircle2, + ChevronRight, + CircleDot, + ExternalLink, + FileText, + LoaderCircle, + Play, + RefreshCw, + Search, + Send, + ServerCog, + ShieldAlert, + Square, + Trash2, + Upload, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type Attachment, + applyProject, + buildProject, + cancelSession, + type DeclarationType, + deleteAttachment, + getOperation, + getProject, + getProjectVersioning, + listAttachments, + type OperationEvent, + operationEventSource, + type ProjectAgent, + type ProjectBuild, + type ProjectPlan, + type ProjectSummary, + type ProjectVersioningStatus, + type ProjectVersionPreview, + planProject, + previewProjectBuild, + previewProjectVersion, + projectEventSource, + type SessionDetail, + sendSessionMessage, + sessionEventSource, + startSession, + uploadAttachment, +} from "@/lib/project-api"; +import { comparePlanActions } from "@/resources/plan-impact"; +import { ResourcesPanel } from "@/resources/ResourcesPanel"; +import { SourceFileDiff } from "@/versions/SourceFileDiff"; +import { VersionsPanel } from "@/versions/VersionsPanel"; -// 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" | "versions" | "debug" | "artifacts"; -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: "versions", label: "Versions" }, + { id: "debug", label: "Debug" }, + { id: "artifacts", label: "Artifacts" }, +]; +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 [baselinePlan, setBaselinePlan] = useState(); + const [planBusy, setPlanBusy] = useState(false); + const [buildBusy, setBuildBusy] = useState(false); + const [buildPreview, setBuildPreview] = useState(); + const [sourcePreviewBusy, setSourcePreviewBusy] = useState(false); + const [sourcePreview, setSourcePreview] = useState(); + const [sourcePreviewError, setSourcePreviewError] = useState(); + const [applyBusy, setApplyBusy] = useState(false); + const [operationEvents, setOperationEvents] = useState([]); + const [actionError, setActionError] = useState(); + const [versioningStatus, setVersioningStatus] = useState(); + const [versioningError, setVersioningError] = useState(); + const [versioningLoading, setVersioningLoading] = useState(true); + 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 buildPreviewRequestGenerationRef = useRef(0); + const sourcePreviewRequestGenerationRef = useRef(0); + const projectValid = project?.status === "valid"; + const writeBlockedReason = project?.active_mutation + ? `Project ${project.active_mutation.kind.replace(/_/g, " ")} is running. Drafts remain editable, but source and version writes are disabled until it finishes.` + : undefined; + + const loadVersioningStatus = useCallback(async () => { + setVersioningLoading(true); + try { + setVersioningStatus(await getProjectVersioning()); + setVersioningError(undefined); + } catch (error) { + setVersioningError(errorMessage(error)); + } finally { + setVersioningLoading(false); + } + }, []); + + const loadProject = useCallback(async (refresh = false, preserveEmptySelection = 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) => { + if (current && next.agents.some((entry) => entry.agent.id === current)) return current; + return preserveEmptySelection ? "" : (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(); + void loadVersioningStatus(); + 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); + setBaselinePlan(undefined); + setBuildPreview(undefined); + setSourcePreview(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, (event) => { + let change: { status?: unknown; revision?: unknown } | undefined; + try { + change = JSON.parse((event as MessageEvent).data) as typeof change; + } catch { + // Reload below when an unexpected change payload cannot be compared safely. + } + const current = projectRef.current; + if (current && current.status === change?.status && current.revision === change.revision) { + setReloading(false); + return; + } + setPlan(undefined); + setBaselinePlan(undefined); + setBuildPreview(undefined); + setSourcePreview(undefined); + setOperationEvents([]); + void loadProject(); + }); + } + source.addEventListener("project.mutation", (event) => { + let change: { active_mutation?: ProjectSummary["active_mutation"] } | undefined; + try { + change = JSON.parse((event as MessageEvent).data) as typeof change; + } catch { + void loadProject(); + return; + } + setProject((current) => { + if (!current) return current; + const next = { ...current, active_mutation: change?.active_mutation ?? null }; + projectRef.current = next; + return next; + }); + if (!change?.active_mutation) void loadVersioningStatus(); + }); + return () => source.close(); + }, [loadVersioningStatus, loadProject]); - // biome-ignore lint/correctness/useExhaustiveDependencies: providerRevision 触发新 provider 预热 useEffect(() => { - setWarmProgress(null); - void warmWorkspace(setWarmProgress); - }, [providerRevision]); + if (project?.revision) void loadVersioningStatus(); + }, [loadVersioningStatus, project?.revision]); 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 (tab === "versions") void loadVersioningStatus(); + }, [loadVersioningStatus, tab]); + + useEffect(() => { + 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(() => { + 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); + setBaselinePlan(undefined); + sessionStorage.removeItem(ACTIVE_OPERATION_KEY); + if (result.error) setActionError(result.error); + void loadProject(true); + source.close(); + }); + source.onerror = () => { + setActionError("Publish 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 Workbench server restarted and interrupted this Publish. 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 loadBuildPreview = useCallback(async (revision: string) => { + const requestGeneration = ++buildPreviewRequestGenerationRef.current; + setBuildBusy(true); + setActionError(undefined); + try { + const preview = await previewProjectBuild(revision); + if (requestGeneration !== buildPreviewRequestGenerationRef.current) return; + setBuildPreview(preview); + } catch (error) { + if (requestGeneration !== buildPreviewRequestGenerationRef.current) return; + setBuildPreview(undefined); + setActionError(errorMessage(error)); + } finally { + if (requestGeneration === buildPreviewRequestGenerationRef.current) setBuildBusy(false); + } + }, []); + + useEffect(() => { + if (tab !== "changes" || !projectValid || !project?.revision || project.active_mutation) return; + void loadBuildPreview(project.revision); return () => { - cancelled = true; + buildPreviewRequestGenerationRef.current++; + setBuildBusy(false); }; - }, [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); + }, [loadBuildPreview, project?.active_mutation, project?.revision, projectValid, tab]); + + const loadSourcePreview = useCallback(async (revision: string, headVersion: string) => { + const requestGeneration = ++sourcePreviewRequestGenerationRef.current; + setSourcePreviewBusy(true); + setSourcePreviewError(undefined); + try { + const preview = await previewProjectVersion(headVersion, revision, headVersion); + if (requestGeneration !== sourcePreviewRequestGenerationRef.current) return; + setSourcePreview(preview); + } catch (error) { + if (requestGeneration !== sourcePreviewRequestGenerationRef.current) return; + setSourcePreview(undefined); + setSourcePreviewError(errorMessage(error)); + } finally { + if (requestGeneration === sourcePreviewRequestGenerationRef.current) setSourcePreviewBusy(false); } }, []); - const handleBottomBarVisibility = useCallback((visible: boolean) => { - bottomBarVisibleRef.current = visible; - }, []); + useEffect(() => { + if (tab !== "changes") return; + const revision = project?.revision; + const headVersion = versioningStatus?.head_version; + if (!revision || !headVersion) { + sourcePreviewRequestGenerationRef.current++; + setSourcePreview(undefined); + setSourcePreviewBusy(false); + setSourcePreviewError(undefined); + return; + } + void loadSourcePreview(revision, headVersion); + return () => { + sourcePreviewRequestGenerationRef.current++; + setSourcePreviewBusy(false); + }; + }, [loadSourcePreview, project?.revision, tab, versioningStatus?.head_version]); - // 选中角色时自动填充输入框 - 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 handlePublish = async () => { + if (!project?.revision) return; + let preview = buildPreview; + setBuildBusy(true); + setPlan(undefined); + setActionError(undefined); + setOperationEvents([]); + try { + preview ??= await previewProjectBuild(project.revision); + setBuildPreview(preview); + if (!preview.can_build) throw new Error("Project source contains errors and cannot be built."); + await buildProject(project.revision); + await loadProject(false, !selectedAgentId); + setBuildBusy(false); - const handleActiveIndexChange = useCallback((idx: number) => { - dispatchPlaybook({ type: "setIndex", index: idx }); - }, []); + setPlanBusy(true); + const nextPlan = await planProject(); + setPlan(nextPlan); + setPlanBusy(false); + if ( + nextPlan.destructive && + !window.confirm("This Publish deletes remote resources. Continue with the generated plan?") + ) + return; + + setApplyBusy(true); + const accepted = await applyProject(nextPlan.plan_token, nextPlan.destructive); + sessionStorage.setItem(ACTIVE_OPERATION_KEY, accepted.operation_id); + connectOperation(accepted.operation_id); + } catch (error) { + setApplyBusy(false); + setActionError(errorMessage(error)); + } finally { + setBuildBusy(false); + setPlanBusy(false); + } + }; - // 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 })); + const handleDeclarationCommitted = async ( + change: { + type: DeclarationType; + id: string; + action: "edit" | "delete"; }, - [activeAgentSlug], - ); + previousPlan?: ProjectPlan, + ) => { + const deletedSelectedAgent = change.action === "delete" && change.type === "agent" && change.id === selectedAgentId; + if (deletedSelectedAgent) setSelectedAgentId(""); + setTab("changes"); + setActionError(undefined); + await loadProject(false, deletedSelectedAgent); + setBuildPreview(undefined); + setSourcePreview(undefined); + setOperationEvents([]); + setPlan(undefined); + setBaselinePlan(previousPlan); + }; + + const handleVersionRestored = async () => { + setTab("changes"); + setActionError(undefined); + await loadProject(false, !selectedAgentId); + await loadVersioningStatus(); + setBuildPreview(undefined); + setSourcePreview(undefined); + setBaselinePlan(undefined); + setOperationEvents([]); + setPlan(undefined); + }; + + 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(); + }); + source.onerror = () => { + setActionError("Session event stream disconnected; reconnecting from the last received event…"); + }; + 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 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 handleCancel = async () => { + if (!session) return; + try { + await cancelSession(session.session.session_id); + setSessionBusy(false); + sessionSourceRef.current?.close(); + } catch (error) { + setActionError(errorMessage(error)); + } + }; return ( - - - {view === "resources" || view === "deployments" ? ( - <> -
- - setSettingsOpen(true)} - /> - {view === "resources" ? : } +
+
+
+ + OpenAgentPack + Directory Workbench +
+
+ {project?.project_name ?? "Loading project"} + {project?.config_file ?? "directory project"} +
+
+ + {project?.revision && {project.revision.slice(0, 9)}} + +
+
+ + {projectError && } + {project && project.status !== "valid" && ( + + )} + {project?.diagnostics.map((diagnostic) => ( + + ))} + {writeBlockedReason && } + +
+ -
-
- {roleCards.length > 0 && ( - - )} - {roleCards.length > 0 && ( - +
+ {project ? ( + <> +
+ {selectedAgent ? ( + <> +
+
{selectedAgent.agent.provider} / agent
+
+

{selectedAgent.agent.id}

+ + + Preview + +
+

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

+
+ + + ) : ( +
+
project / runtime
+

{project?.project_name ?? "Project"}

+

No Agent is currently selected. Resources and project Changes remain available.

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

No Agent selected

+

Fix the directory project or adjust the filters to select an existing Agent.

+
+ )} +
+
+
+ ); +} - +function ChangesPanel({ + plan, + baselinePlan, + buildPreview, + buildBusy, + sourcePreview, + sourcePreviewBusy, + sourcePreviewError, + versioningInitialized, + headVersion, + planBusy, + applyBusy, + projectValid, + versioningEnabled, + mutationActive, + operationEvents, + onPublish, +}: { + plan?: ProjectPlan; + baselinePlan?: ProjectPlan; + buildPreview?: ProjectBuild; + buildBusy: boolean; + sourcePreview?: ProjectVersionPreview; + sourcePreviewBusy: boolean; + sourcePreviewError?: string; + versioningInitialized: boolean; + headVersion: string | null; + planBusy: boolean; + applyBusy: boolean; + projectValid: boolean; + versioningEnabled: boolean; + mutationActive: boolean; + operationEvents: OperationEvent[]; + onPublish(): void; +}) { + const impact = plan && baselinePlan ? comparePlanActions(baselinePlan.actions, plan.actions) : undefined; + return ( +
+
+
+

Publish

+

Publish builds the current directory source, generates a remote plan, and executes it as one operation.

+
+
+ + {buildBusy ? ( + + ) : buildPreview?.can_build ? ( + + ) : buildPreview ? ( + + ) : ( + + )} + {buildBusy + ? "Checking…" + : buildPreview?.can_build + ? "Ready to publish" + : buildPreview + ? "Needs attention" + : "Automatic checks"} + + +
+
+ + {buildPreview && } + {!versioningEnabled && ( +
+ + Project versions are disabled. This Publish will not record a directory snapshot. +
+ )} + {plan && ( +
+
+ {plan.actions.filter((action) => action.action !== "no-op").length} changes + + {plan.destructive ? ( + <> + destructive + + ) : ( + <> + non-destructive + + )} + + {plan.fingerprint.slice(0, 12)}
+ {impact ? ( + <> + + {impact.resolvedByEdit.length > 0 && } + +

+ Publish executes both groups above. Resolved items require no remote action. +

+ + ) : ( + plan.actions.map((action) => ) + )} + {plan.diagnostics.map((diagnostic) => ( +
+ {diagnostic.code} + {diagnostic.message} +
+ ))} +
+ )} + {operationEvents.length > 0 && ( +
+

Publish progress

+ {operationEvents.map((event) => ( +
+ + {event.type} + {operationMessage(event.data)} +
+ ))} +
+ )} +
+ ); +} + +function SourceChangesCard({ + preview, + busy, + error, + initialized, + headVersion, +}: { + preview?: ProjectVersionPreview; + busy: boolean; + error?: string; + initialized: boolean; + headVersion: string | null; +}) { + return ( +
+
+ Source changes + {headVersion && baseline {headVersion.slice(0, 12)}} + {preview && {preview.changes.length} changed file(s)} +
+ {busy ? ( +
+ +

Comparing the working directory with the latest published version…

+
+ ) : error ? ( +
+ + {error} +
+ ) : !initialized || !headVersion ? ( +
+ + Enable project versions to create a baseline for full directory source changes. +
+ ) : preview ? ( + preview.changes.length > 0 ? ( + preview.changes.map((change) => ( + + )) + ) : ( +

The working directory matches the latest published version.

+ ) + ) : ( +

Waiting for the latest version comparison.

+ )} +
+ ); +} + +function PublishChecks({ preview }: { preview: ProjectBuild }) { + const diagnostics = [...preview.diagnostics, ...preview.warnings]; + if (preview.organization_moves.length === 0 && diagnostics.length === 0) return null; + return ( +
+
+ Publish checks + {preview.organization_moves.length} organization move(s) + {diagnostics.length} diagnostic(s) +
+ {preview.organization_moves.map((move) => ( +
+ move shared skill {move.skill_id} + + {move.from} → {move.to} + +
+ ))} + {diagnostics.map((diagnostic) => ( +
+ {diagnostic.code} + {diagnostic.message} +
+ ))} +
+ ); +} - } - onVisibilityChange={handleBottomBarVisibility} - onMakeSame={handleMakeSame} - onNavigate={setView} - canSubmit={canSubmit} +function AgentRequiredPanel({ action }: { action: string }) { + return ( +
+ +

Select an existing Agent to {action}.

+
+ ); +} + +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 ( +
+
+
+
+
+

Temporary attachments

+

Uploaded for Sessions only; never written to directory project source.

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

No temporary attachments.

} +
+
+
+

Start a Session

+