diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 8a6c400..d238804 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -30,6 +30,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: API contract parity + run: pnpm api:parity + - name: Lint run: pnpm lint diff --git a/docs/cli-agent-control.md b/docs/cli-agent-control.md new file mode 100644 index 0000000..1b5b5a8 --- /dev/null +++ b/docs/cli-agent-control.md @@ -0,0 +1,76 @@ +# Mission Control CLI for Agent-Complete Operations (v1 scaffold) + +This repository now includes a first-party CLI scaffold at: + +- scripts/mc-cli.cjs + +It is designed for autonomous/headless usage first: +- API key auth support +- profile persistence (~/.mission-control/profiles/*.json) +- stable JSON mode (`--json`) +- deterministic exit code categories +- command groups mapped to Mission Control API resources + +## Quick start + +1) Ensure Mission Control API is running. +2) Set environment variables or use profile flags: + +- MC_URL=http://127.0.0.1:3000 +- MC_API_KEY=your-key + +3) Run commands: + +node scripts/mc-cli.cjs agents list --json +node scripts/mc-cli.cjs tasks queue --agent Aegis --max-capacity 2 --json +node scripts/mc-cli.cjs sessions control --id --action terminate + +## Supported groups in scaffold + +- auth: login, logout, whoami +- agents: list/get/create/update/delete/wake/diagnostics/heartbeat +- tasks: list/get/create/update/delete/queue +- sessions: list/control/continue +- connect: register/list/disconnect +- tokens: list/stats/by-agent +- skills: list/content/check/upsert/delete +- cron: list/create/update/pause/resume/remove/run +- events: watch (basic HTTP fallback) +- raw: generic request passthrough + +## Exit code contract + +- 0 success +- 2 usage error +- 3 auth error (401) +- 4 permission error (403) +- 5 network/timeout +- 6 server error (5xx) + +## API contract parity gate + +To detect drift between Next.js route handlers and openapi.json, use: + +node scripts/check-api-contract-parity.mjs \ + --root . \ + --openapi openapi.json \ + --ignore-file scripts/api-contract-parity.ignore + +Machine output: + +node scripts/check-api-contract-parity.mjs --json + +The checker scans `src/app/api/**/route.ts(x)`, derives operations (METHOD + /api/path), compares against OpenAPI operations, and exits non-zero on mismatch. + +Baseline policy in this repo: +- `scripts/api-contract-parity.ignore` currently stores a temporary baseline of known drift. +- CI enforces no regressions beyond baseline. +- When you fix a mismatch, remove its line from ignore file in the same PR. +- Goal is monotonic burn-down to an empty ignore file. + +## Next steps + +- Promote scripts to package.json scripts (`mc`, `api:parity`). +- Add retry/backoff and SSE stream mode for `events watch`. +- Add richer pagination/filter UX and CSV export for reporting commands. +- Add integration tests that run the CLI against a test server fixture. diff --git a/docs/plans/2026-03-20-mission-control-platform-cli-tui-prd.md b/docs/plans/2026-03-20-mission-control-platform-cli-tui-prd.md new file mode 100644 index 0000000..2ea437f --- /dev/null +++ b/docs/plans/2026-03-20-mission-control-platform-cli-tui-prd.md @@ -0,0 +1,259 @@ +# Mission Control Platform Hardening + Full Agent CLI/TUI PRD + +> For Hermes: execute this plan in iterative vertical slices (contract parity -> CLI core -> TUI -> hardening), with tests at each slice. + +Goal +Build a production-grade Mission Control operator surface for autonomous agents via a first-party CLI (and optional lightweight TUI), while fixing platform inconsistencies discovered in audit: API contract drift, uneven reliability controls, and incomplete automation ergonomics. + +Architecture +Mission Control remains the source of truth with REST + SSE endpoints. A first-party CLI consumes those APIs with profile-based auth and machine-friendly output. TUI is layered on top of CLI API client primitives for shared behavior. API contract reliability is enforced through route-to-spec parity checks in CI. + +Tech Stack +- Existing: Next.js app-router API, SQLite, Node runtime, SSE +- New: Node CLI runtime (no heavy deps required for v1), optional TUI in terminal ANSI mode +- Testing: existing Playwright/Vitest patterns + CLI smoke tests + OpenAPI parity checks + +--- + +## 1) Problem statement + +Current Mission Control backend has strong capabilities for agent orchestration, but external automation quality is constrained by: +1. API surface drift between route handlers, openapi.json, and /api/index. +2. No first-party comprehensive CLI for operators/agents. +3. Uneven hardening around operational concerns (auth posture defaults, multi-instance rate limiting strategy, spawn history durability). +4. Incomplete UX for non-interactive agent workflows (idempotent commands, stable JSON output, strict exit codes). + +Result: agents can use Mission Control partially, but not yet with high confidence as a full control plane. + +## 2) Product objectives + +Primary objectives +1. Deliver a first-party CLI with functional parity across core agent workflows. +2. Add optional TUI for rapid situational awareness and interactive operations. +3. Establish API contract parity as an enforceable quality gate. +4. Improve reliability and security defaults for autonomous operation. + +Success criteria +- 95%+ of documented operator workflows executable via CLI without web UI. +- Contract parity CI gate blocks drift between route handlers and OpenAPI. +- CLI supports machine mode: stable JSON schemas and deterministic exit codes. +- TUI can monitor and trigger core actions (agents/tasks/sessions/events). + +Non-goals (v1) +- Replacing the web UI. +- Building an advanced ncurses framework dependency stack if not needed. +- Supporting all historical/legacy endpoint aliases immediately. + +## 3) Personas and workflows + +Personas +1. Autonomous agent runtime (headless, non-interactive). +2. Human operator (terminal-first incident response). +3. Platform maintainer (release and contract governance). + +Critical workflows +- Poll task queue and claim work. +- Manage agents (register/update/diagnose/wake). +- Manage sessions (list/control/continue/transcript). +- Observe events in real time. +- Track token usage and attribution. +- Manage skills, cron jobs, and direct CLI connections. + +## 4) Functional requirements + +### A. API contract governance +- FR-A1: A parity checker must compare discovered route handlers and OpenAPI paths/methods. +- FR-A2: CI fails on non-ignored mismatches. +- FR-A3: Ignore list must be explicit and reviewable. +- FR-A4: /api/index should be validated or generated from same contract source. + +### B. CLI v1 requirements +- FR-B1: Profile-based configuration (URL + auth mode + key/cookie). +- FR-B2: Commands must support --json output and strict exit codes. +- FR-B3: Support key domains: + - auth + - agents + - tasks + - sessions + - connect + - tokens + - skills + - cron + - events watch + - raw request fallback +- FR-B4: Non-interactive defaults suitable for autonomous agents. +- FR-B5: Request timeout + retry controls for reliable automation. + +### C. TUI v1 requirements (optional but included) +- FR-C1: Dashboard with agents/tasks/sessions summary panels. +- FR-C2: Keyboard-driven refresh/navigation. +- FR-C3: Trigger key operations (wake agent, queue poll, session controls). +- FR-C4: Clear degraded mode messaging if endpoints unavailable. + +### D. Platform hardening requirements +- FR-D1: Document and enforce least-privilege auth guidance for agent keys. +- FR-D2: Expose explicit warning/controls for global admin API key usage. +- FR-D3: Add durable spawn history persistence (DB-backed) replacing log scraping fallback. +- FR-D4: Add scalable rate-limit strategy plan (in-memory now, pluggable backend next). + +## 5) CLI command map (v1) + +mc auth +- login --username --password +- logout +- whoami + +mc agents +- list +- get --id +- create --name --role +- update --id ...fields +- delete --id +- wake --id +- diagnostics --id +- heartbeat --id +- memory get|set --id +- soul get|set --id + +mc tasks +- list [filters] +- get --id +- create --title [--description --priority --assigned-to] +- update --id ...fields +- delete --id +- queue --agent [--max-capacity] +- comments list/add --id +- broadcast --id + +mc sessions +- list +- control --id --action monitor|pause|terminate +- continue --kind claude-code|codex-cli --id --prompt +- transcript --id [--source] + +mc connect +- register --tool-name --agent-name [...] +- list +- disconnect --connection-id + +mc tokens +- list +- stats +- by-agent [--days] +- export --format json|csv + +mc skills +- list +- content --source --name +- upsert --source --name --file +- delete --source --name +- check --source --name + +mc cron +- list +- create/update/pause/resume/remove/run + +mc events +- watch [--types] + +mc raw +- raw --method GET --path /api/... [--body '{}'] + +## 6) UX and interface requirements + +- Default output must be concise human-readable; --json returns machine-stable payload. +- All non-2xx responses include normalized error object and non-zero exit. +- Exit code taxonomy: + - 0 success + - 2 usage error + - 3 auth error + - 4 permission error + - 5 network/timeout + - 6 server error +- Pagination/filter flags normalized across list commands. + +## 7) Security requirements + +- Do not log raw API keys or cookies. +- Redact sensitive headers in verbose/debug output. +- Provide per-profile auth scope awareness (viewer/operator/admin implied risk labeling). +- Strong guidance: prefer agent-scoped keys over global admin key. + +## 8) Reliability requirements + +- Configurable timeout/retry/backoff. +- Safe JSON parsing and clear error surfaces. +- SSE reconnection strategy for watch mode. +- Graceful handling for partial endpoint availability. + +## 9) Testing strategy + +Unit +- CLI arg parsing and request mapping. +- Output modes and exit codes. +- API parity checker route extraction and mismatch detection. + +Integration +- CLI against local Mission Control test server. +- Auth modes (API key, login session where enabled). +- Session control, queue polling, skills CRUD. + +E2E +- Playwright/terminal-driven smoke for critical command paths. +- TUI render and keyboard navigation smoke tests. + +Contract tests +- OpenAPI parity check in CI. +- Optional index parity check in CI. + +## 10) Rollout plan + +Phase 0: Contract stabilization +- Add parity checker and fail CI on drift. +- Resolve existing mismatches. + +Phase 1: CLI core +- Ship profile/auth client + core command groups (auth/agents/tasks/sessions/connect). + +Phase 2: CLI expansion +- tokens/skills/cron/events/raw + transcript ergonomics. + +Phase 3: TUI +- Live dashboard + action shortcuts. + +Phase 4: Hardening +- durable spawn history +- auth warnings and safeguards +- scalable rate-limit backend abstraction + +## 11) Risks and mitigations + +Risk: Large API surface causes long-tail parity gaps. +Mitigation: enforce parity checker + allowlist for temporary exceptions. + +Risk: Auth complexity across cookie/key/proxy modes. +Mitigation: profile abstraction + explicit mode selection and diagnostics. + +Risk: CLI churn if endpoint contracts continue changing. +Mitigation: typed response normalizers + compatibility layer + semver release notes. + +## 12) Acceptance criteria + +- PRD approved by maintainers. +- CLI provides end-to-end control for core workflows. +- Contract parity CI gate active and green. +- TUI displays operational state and triggers key actions. +- Security and reliability hardening changes documented and tested. + +## 13) Immediate implementation tasks (next 1-2 PRs) + +PR 1 +1. Add API parity checker script and CI command. +2. Add first-party CLI scaffold with command routing and normalized request layer. +3. Add docs for CLI profiles/auth/output contract. + +PR 2 +1. Implement full command matrix. +2. Add TUI dashboard shell. +3. Add CLI integration tests. +4. Introduce durable spawn history model and endpoint alignment. diff --git a/openapi.json b/openapi.json index 610e830..d05ec9b 100644 --- a/openapi.json +++ b/openapi.json @@ -198,6 +198,44 @@ } } }, + "/api/adapters": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Get /api/adapters", + "operationId": "getApiAdapters", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "tags": [ + "Agents" + ], + "summary": "Post /api/adapters", + "operationId": "postApiAdapters", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, "/api/agents": { "get": { "tags": [ @@ -793,6 +831,244 @@ } } }, + "/api/agents/{id}/attribution": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Get attribution report for an agent", + "description": "Self-scope by default. Requester must match target agent (`x-agent-name` or username), unless admin uses `?privileged=1`.", + "operationId": "getAgentAttribution", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "hours", + "in": "query", + "required": false, + "description": "Time window in hours, integer range 1..720. Defaults to 24.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 24 + } + }, + { + "name": "section", + "in": "query", + "required": false, + "description": "Comma-separated subset of identity,audit,mutations,cost. Defaults to all.", + "schema": { + "type": "string", + "example": "identity,audit" + } + }, + { + "name": "privileged", + "in": "query", + "required": false, + "description": "Set to 1 for admin override of self-scope checks.", + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + }, + { + "name": "x-agent-name", + "in": "header", + "required": false, + "description": "Attribution identity header used for self-scope authorization.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Attribution report" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/agents/{id}/diagnostics": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Get self diagnostics for an agent", + "description": "Self-scoped diagnostics by default. Cross-agent access requires `privileged=1` with admin credentials. Trend alerts are informational signals derived from current-vs-previous window deltas (error spikes, throughput drops, activity stalls).", + "operationId": "getAgentDiagnostics", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent numeric ID or name." + }, + { + "name": "hours", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 24 + }, + "description": "Diagnostics window in hours." + }, + { + "name": "section", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated sections: summary,tasks,errors,activity,trends,tokens." + }, + { + "name": "privileged", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "1" + ] + }, + "description": "Set to `1` to allow explicit admin cross-agent diagnostics access." + } + ], + "responses": { + "200": { + "description": "Diagnostics payload", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "timeframe": { + "type": "object", + "properties": { + "hours": { + "type": "integer" + }, + "since": { + "type": "integer" + }, + "until": { + "type": "integer" + } + } + }, + "summary": { + "type": "object" + }, + "tasks": { + "type": "object" + }, + "errors": { + "type": "object" + }, + "activity": { + "type": "object" + }, + "trends": { + "type": "object", + "properties": { + "current_period": { + "type": "object" + }, + "previous_period": { + "type": "object" + }, + "change": { + "type": "object" + }, + "alerts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "info", + "warning" + ] + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "tokens": { + "type": "object" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, "/api/agents/{id}/heartbeat": { "get": { "tags": [ @@ -1078,6 +1354,54 @@ "$ref": "#/components/responses/NotFound" } } + }, + "patch": { + "tags": [ + "API" + ], + "summary": "List or load soul templates", + "operationId": "patch_api_agents_id_soul", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Resource identifier" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } } }, "/api/agents/{id}/wake": { @@ -1692,35 +2016,41 @@ } }, "/api/auth/google": { - "get": { + "post": { "tags": [ "Auth" ], - "summary": "Google OAuth callback", - "operationId": "googleOAuthCallback", + "summary": "Login with Google ID token", + "operationId": "googleLogin", "security": [], - "parameters": [ - { - "name": "code", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "state", - "in": "query", - "schema": { - "type": "string" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "credential" + ], + "properties": { + "credential": { + "type": "string", + "description": "Google identity credential token" + } + } + } } } - ], + }, "responses": { - "302": { - "description": "Redirects to dashboard after successful auth" + "200": { + "description": "Login successful" }, "400": { "$ref": "#/components/responses/BadRequest" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } } } @@ -2335,12 +2665,12 @@ } } }, - "delete": { + "patch": { "tags": [ "Chat" ], - "summary": "Delete message", - "operationId": "deleteMessage", + "summary": "Update message state", + "operationId": "updateMessage", "parameters": [ { "name": "id", @@ -2351,21 +2681,24 @@ } } ], - "responses": { - "200": { - "description": "Message deleted", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "read": { + "type": "boolean" } } } } + } + }, + "responses": { + "200": { + "description": "Message updated" }, "401": { "$ref": "#/components/responses/Unauthorized" @@ -2834,93 +3167,6 @@ } } }, - "/api/workload": { - "get": { - "tags": [ - "Monitoring" - ], - "summary": "Get real-time workload recommendation", - "description": "Returns system workload metrics and an actionable recommendation: `normal`, `throttle`, `shed`, or `pause`. Thresholds are runtime-configurable via `MC_WORKLOAD_*` environment variables.", - "operationId": "getWorkloadSignals", - "responses": { - "200": { - "description": "Workload snapshot and recommendation", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "timestamp": { "type": "integer" }, - "workspace_id": { "type": "integer" }, - "capacity": { - "type": "object", - "properties": { - "active_tasks": { "type": "integer" }, - "tasks_last_5m": { "type": "integer" }, - "errors_last_5m": { "type": "integer" }, - "error_rate_5m": { "type": "number", "minimum": 0, "maximum": 1 }, - "completions_last_hour": { "type": "integer" }, - "avg_completion_rate_per_hour": { "type": "number" } - } - }, - "queue": { - "type": "object", - "properties": { - "total_pending": { "type": "integer" }, - "by_status": { "type": "object", "additionalProperties": { "type": "integer" } }, - "by_priority": { "type": "object", "additionalProperties": { "type": "integer" } }, - "oldest_pending_age_seconds": { "type": ["integer", "null"] }, - "estimated_wait_seconds": { "type": ["integer", "null"] }, - "estimated_wait_confidence": { "type": "string", "enum": ["calculated", "unknown"] } - } - }, - "agents": { - "type": "object", - "properties": { - "total": { "type": "integer" }, - "online": { "type": "integer" }, - "busy": { "type": "integer" }, - "idle": { "type": "integer" }, - "offline": { "type": "integer" }, - "busy_ratio": { "type": "number", "minimum": 0, "maximum": 1 }, - "load_distribution": { - "type": "array", - "items": { - "type": "object", - "properties": { - "agent": { "type": "string" }, - "assigned": { "type": "integer" }, - "in_progress": { "type": "integer" } - } - } - } - } - }, - "recommendation": { - "type": "object", - "properties": { - "action": { "type": "string", "enum": ["normal", "throttle", "shed", "pause"] }, - "reason": { "type": "string" }, - "details": { "type": "array", "items": { "type": "string" } }, - "submit_ok": { "type": "boolean" }, - "suggested_delay_ms": { "type": "integer" } - } - }, - "thresholds": { - "type": "object", - "description": "Effective runtime thresholds after environment overrides." - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - } - } - }, "/api/events": { "get": { "tags": [ @@ -2945,6 +3191,62 @@ } } }, + "/api/exec-approvals": { + "get": { + "tags": [ + "Admin" + ], + "summary": "Get /api/exec-approvals", + "operationId": "getApiExecApprovals", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "tags": [ + "Admin" + ], + "summary": "Post /api/exec-approvals", + "operationId": "postApiExecApprovals", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "put": { + "tags": [ + "Admin" + ], + "summary": "Put /api/exec-approvals", + "operationId": "putApiExecApprovals", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, "/api/export": { "get": { "tags": [ @@ -3004,86 +3306,6 @@ } } }, - "/api/agents/{id}/attribution": { - "get": { - "tags": [ - "Agents" - ], - "summary": "Get attribution report for an agent", - "description": "Self-scope by default. Requester must match target agent (`x-agent-name` or username), unless admin uses `?privileged=1`.", - "operationId": "getAgentAttribution", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "hours", - "in": "query", - "required": false, - "description": "Time window in hours, integer range 1..720. Defaults to 24.", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 720, - "default": 24 - } - }, - { - "name": "section", - "in": "query", - "required": false, - "description": "Comma-separated subset of identity,audit,mutations,cost. Defaults to all.", - "schema": { - "type": "string", - "example": "identity,audit" - } - }, - { - "name": "privileged", - "in": "query", - "required": false, - "description": "Set to 1 for admin override of self-scope checks.", - "schema": { - "type": "string", - "enum": [ - "1" - ] - } - }, - { - "name": "x-agent-name", - "in": "header", - "required": false, - "description": "Attribution identity header used for self-scope authorization.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Attribution report" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - } - } - } - }, "/api/gateway-config": { "get": { "tags": [ @@ -3110,7 +3332,7 @@ } } }, - "post": { + "put": { "tags": [ "Admin" ], @@ -3356,6 +3578,71 @@ } } }, + "/api/gateways/connect": { + "post": { + "tags": [ + "Admin" + ], + "summary": "Resolve websocket connect payload for a gateway", + "operationId": "connectGateway", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Resolved websocket URL and token metadata", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "ws_url": { + "type": "string" + }, + "token": { + "type": "string" + }, + "token_set": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, "/api/gateways/health": { "post": { "tags": [ @@ -3408,63 +3695,6 @@ } } }, - "/api/gateways/connect": { - "post": { - "tags": [ - "Admin" - ], - "summary": "Resolve websocket connect payload for a gateway", - "operationId": "connectGateway", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "type": "integer" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Resolved websocket URL and token metadata", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { "type": "integer" }, - "ws_url": { "type": "string" }, - "token": { "type": "string" }, - "token_set": { "type": "boolean" } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - } - } - } - }, "/api/github": { "get": { "tags": [ @@ -3950,126 +4180,6 @@ } } }, - "/api/agents/{id}/diagnostics": { - "get": { - "tags": [ - "Agents" - ], - "summary": "Get self diagnostics for an agent", - "description": "Self-scoped diagnostics by default. Cross-agent access requires `privileged=1` with admin credentials. Trend alerts are informational signals derived from current-vs-previous window deltas (error spikes, throughput drops, activity stalls).", - "operationId": "getAgentDiagnostics", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Agent numeric ID or name." - }, - { - "name": "hours", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 720, - "default": 24 - }, - "description": "Diagnostics window in hours." - }, - { - "name": "section", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Comma-separated sections: summary,tasks,errors,activity,trends,tokens." - }, - { - "name": "privileged", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "1" - ] - }, - "description": "Set to `1` to allow explicit admin cross-agent diagnostics access." - } - ], - "responses": { - "200": { - "description": "Diagnostics payload", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "agent": { - "type": "object", - "properties": { - "id": { "type": "integer" }, - "name": { "type": "string" }, - "role": { "type": "string" }, - "status": { "type": "string" } - } - }, - "timeframe": { - "type": "object", - "properties": { - "hours": { "type": "integer" }, - "since": { "type": "integer" }, - "until": { "type": "integer" } - } - }, - "summary": { "type": "object" }, - "tasks": { "type": "object" }, - "errors": { "type": "object" }, - "activity": { "type": "object" }, - "trends": { - "type": "object", - "properties": { - "current_period": { "type": "object" }, - "previous_period": { "type": "object" }, - "change": { "type": "object" }, - "alerts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "level": { "type": "string", "enum": ["info", "warning"] }, - "message": { "type": "string" } - } - } - } - } - }, - "tokens": { "type": "object" } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - } - } - } - }, "/api/notifications/deliver": { "post": { "tags": [ @@ -4134,6 +4244,44 @@ } } }, + "/api/onboarding": { + "get": { + "tags": [ + "System" + ], + "summary": "Get /api/onboarding", + "operationId": "getApiOnboarding", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "tags": [ + "System" + ], + "summary": "Post /api/onboarding", + "operationId": "postApiOnboarding", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, "/api/pipelines": { "get": { "tags": [ @@ -4981,6 +5129,122 @@ "$ref": "#/components/responses/Unauthorized" } } + }, + "delete": { + "tags": [ + "Sessions" + ], + "summary": "Delete /api/sessions", + "operationId": "deleteApiSessions", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "tags": [ + "Sessions" + ], + "summary": "Post /api/sessions", + "operationId": "postApiSessions", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/sessions/continue": { + "post": { + "tags": [ + "Sessions" + ], + "summary": "Post /api/sessions/continue", + "operationId": "postApiSessionsContinue", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/sessions/transcript": { + "get": { + "tags": [ + "Sessions" + ], + "summary": "Get /api/sessions/transcript", + "operationId": "getApiSessionsTranscript", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/sessions/transcript/aggregate": { + "get": { + "tags": [ + "Sessions" + ], + "summary": "Get /api/sessions/transcript/aggregate", + "operationId": "getApiSessionsTranscriptAggregate", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/sessions/transcript/gateway": { + "get": { + "tags": [ + "Sessions" + ], + "summary": "Get /api/sessions/transcript/gateway", + "operationId": "getApiSessionsTranscriptGateway", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } } }, "/api/sessions/{id}/control": { @@ -5085,7 +5349,7 @@ } } }, - "post": { + "put": { "tags": [ "Admin" ], @@ -5124,6 +5388,178 @@ "$ref": "#/components/responses/Forbidden" } } + }, + "delete": { + "tags": [ + "Admin" + ], + "summary": "Reset a setting to default", + "operationId": "resetSetting", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "key" + ], + "properties": { + "key": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Setting reset" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/skills": { + "delete": { + "tags": [ + "Skills" + ], + "summary": "Delete /api/skills", + "operationId": "deleteApiSkills", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "get": { + "tags": [ + "Skills" + ], + "summary": "Get /api/skills", + "operationId": "getApiSkills", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "tags": [ + "Skills" + ], + "summary": "Post /api/skills", + "operationId": "postApiSkills", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "put": { + "tags": [ + "Skills" + ], + "summary": "Put /api/skills", + "operationId": "putApiSkills", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/skills/registry": { + "get": { + "tags": [ + "Skills" + ], + "summary": "Get /api/skills/registry", + "operationId": "getApiSkillsRegistry", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "tags": [ + "Skills" + ], + "summary": "Post /api/skills/registry", + "operationId": "postApiSkillsRegistry", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "put": { + "tags": [ + "Skills" + ], + "summary": "Put /api/skills/registry", + "operationId": "putApiSkillsRegistry", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } } }, "/api/spawn": { @@ -6006,8 +6442,12 @@ "properties": { "task": { "oneOf": [ - { "$ref": "#/components/schemas/Task" }, - { "type": "null" } + { + "$ref": "#/components/schemas/Task" + }, + { + "type": "null" + } ] }, "reason": { @@ -7338,6 +7778,1139 @@ } } } + }, + "/api/workload": { + "get": { + "tags": [ + "Monitoring" + ], + "summary": "Get real-time workload recommendation", + "description": "Returns system workload metrics and an actionable recommendation: `normal`, `throttle`, `shed`, or `pause`. Thresholds are runtime-configurable via `MC_WORKLOAD_*` environment variables.", + "operationId": "getWorkloadSignals", + "responses": { + "200": { + "description": "Workload snapshot and recommendation", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timestamp": { + "type": "integer" + }, + "workspace_id": { + "type": "integer" + }, + "capacity": { + "type": "object", + "properties": { + "active_tasks": { + "type": "integer" + }, + "tasks_last_5m": { + "type": "integer" + }, + "errors_last_5m": { + "type": "integer" + }, + "error_rate_5m": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "completions_last_hour": { + "type": "integer" + }, + "avg_completion_rate_per_hour": { + "type": "number" + } + } + }, + "queue": { + "type": "object", + "properties": { + "total_pending": { + "type": "integer" + }, + "by_status": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "by_priority": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "oldest_pending_age_seconds": { + "type": [ + "integer", + "null" + ] + }, + "estimated_wait_seconds": { + "type": [ + "integer", + "null" + ] + }, + "estimated_wait_confidence": { + "type": "string", + "enum": [ + "calculated", + "unknown" + ] + } + } + }, + "agents": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "online": { + "type": "integer" + }, + "busy": { + "type": "integer" + }, + "idle": { + "type": "integer" + }, + "offline": { + "type": "integer" + }, + "busy_ratio": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "load_distribution": { + "type": "array", + "items": { + "type": "object", + "properties": { + "agent": { + "type": "string" + }, + "assigned": { + "type": "integer" + }, + "in_progress": { + "type": "integer" + } + } + } + } + } + }, + "recommendation": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "normal", + "throttle", + "shed", + "pause" + ] + }, + "reason": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "string" + } + }, + "submit_ok": { + "type": "boolean" + }, + "suggested_delay_ms": { + "type": "integer" + } + } + }, + "thresholds": { + "type": "object", + "description": "Effective runtime thresholds after environment overrides." + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/api/workspaces": { + "get": { + "tags": [ + "Projects" + ], + "summary": "Get /api/workspaces", + "operationId": "getApiWorkspaces", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "tags": [ + "Projects" + ], + "summary": "Post /api/workspaces", + "operationId": "postApiWorkspaces", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/workspaces/{id}": { + "delete": { + "tags": [ + "Projects" + ], + "summary": "Delete /api/workspaces/{id}", + "operationId": "deleteApiWorkspacesId", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "get": { + "tags": [ + "Projects" + ], + "summary": "Get /api/workspaces/{id}", + "operationId": "getApiWorkspacesId", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "put": { + "tags": [ + "Projects" + ], + "summary": "Put /api/workspaces/{id}", + "operationId": "putApiWorkspacesId", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/agents/{id}/hide": { + "post": { + "tags": [ + "API" + ], + "summary": "Hide agent", + "operationId": "post_api_agents_id_hide", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Resource identifier" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "delete": { + "tags": [ + "API" + ], + "summary": "Unhide agent", + "operationId": "delete_api_agents_id_hide", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Resource identifier" + } + ] + } + }, + "/api/agents/{id}/keys": { + "get": { + "tags": [ + "API" + ], + "summary": "List agent API keys", + "operationId": "get_api_agents_id_keys", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Resource identifier" + } + ] + }, + "post": { + "tags": [ + "API" + ], + "summary": "Create agent API key", + "operationId": "post_api_agents_id_keys", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Resource identifier" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "delete": { + "tags": [ + "API" + ], + "summary": "Revoke agent API key", + "operationId": "delete_api_agents_id_keys", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Resource identifier" + } + ] + } + }, + "/api/agents/{id}/files": { + "get": { + "tags": [ + "API" + ], + "summary": "Get agent workspace files", + "operationId": "get_api_agents_id_files", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Resource identifier" + } + ] + }, + "put": { + "tags": [ + "API" + ], + "summary": "Update agent workspace file", + "operationId": "put_api_agents_id_files", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Resource identifier" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/chat/session-prefs": { + "get": { + "tags": [ + "API" + ], + "summary": "Get chat session preferences", + "operationId": "get_api_chat_session_prefs", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "patch": { + "tags": [ + "API" + ], + "summary": "Update chat session preference", + "operationId": "patch_api_chat_session_prefs", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/debug": { + "get": { + "tags": [ + "API" + ], + "summary": "Debug gateway status", + "operationId": "get_api_debug", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "post": { + "tags": [ + "API" + ], + "summary": "Proxy debug call to gateway", + "operationId": "post_api_debug", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/diagnostics": { + "get": { + "tags": [ + "API" + ], + "summary": "Get diagnostics report", + "operationId": "get_api_diagnostics", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/docs/content": { + "get": { + "tags": [ + "API" + ], + "summary": "Get docs content", + "operationId": "get_api_docs_content", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/docs/search": { + "get": { + "tags": [ + "API" + ], + "summary": "Search docs", + "operationId": "get_api_docs_search", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/docs/tree": { + "get": { + "tags": [ + "API" + ], + "summary": "Get docs tree", + "operationId": "get_api_docs_tree", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/setup": { + "get": { + "tags": [ + "API" + ], + "summary": "Get setup status", + "operationId": "get_api_setup", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "post": { + "tags": [ + "API" + ], + "summary": "Run first-time setup", + "operationId": "post_api_setup", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/openclaw/doctor": { + "get": { + "tags": [ + "API" + ], + "summary": "Run OpenClaw doctor", + "operationId": "get_api_openclaw_doctor", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "post": { + "tags": [ + "API" + ], + "summary": "Run OpenClaw doctor fix", + "operationId": "post_api_openclaw_doctor", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/openclaw/version": { + "get": { + "tags": [ + "API" + ], + "summary": "Get OpenClaw version info", + "operationId": "get_api_openclaw_version", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/openclaw/update": { + "post": { + "tags": [ + "API" + ], + "summary": "Update OpenClaw", + "operationId": "post_api_openclaw_update", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/tasks/{id}/branch": { + "get": { + "tags": [ + "API" + ], + "summary": "Get task branch/PR status", + "operationId": "get_api_tasks_id_branch", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Resource identifier" + } + ] + }, + "post": { + "tags": [ + "API" + ], + "summary": "Create task branch/PR", + "operationId": "post_api_tasks_id_branch", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Resource identifier" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/tokens/rotate": { + "get": { + "tags": [ + "API" + ], + "summary": "Get API key rotation metadata", + "operationId": "get_api_tokens_rotate", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + } } }, "components": { diff --git a/package.json b/package.json index 4066bf9..202a113 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "description": "OpenClaw Mission Control — open-source agent orchestration dashboard", "scripts": { "verify:node": "node scripts/check-node-version.mjs", + "api:parity": "node scripts/check-api-contract-parity.mjs --root . --openapi openapi.json --ignore-file scripts/api-contract-parity.ignore", + "api:parity:json": "node scripts/check-api-contract-parity.mjs --root . --openapi openapi.json --ignore-file scripts/api-contract-parity.ignore --json", "dev": "pnpm run verify:node && next dev --hostname 127.0.0.1 --port ${PORT:-3000}", "build": "pnpm run verify:node && next build", "start": "pnpm run verify:node && next start --hostname 0.0.0.0 --port ${PORT:-3000}", diff --git a/scripts/api-contract-parity.ignore b/scripts/api-contract-parity.ignore new file mode 100644 index 0000000..f756f79 --- /dev/null +++ b/scripts/api-contract-parity.ignore @@ -0,0 +1,66 @@ +# API contract parity baseline ignore list +# One operation per line: METHOD /api/path +# Keep this list shrinking over time; remove entries when route/spec parity is fixed. +DELETE /api/agents/{id}/memory +DELETE /api/backup +DELETE /api/integrations +DELETE /api/memory +DELETE /api/notifications +DELETE /api/projects/{id}/agents +GET /api/agents/evals +GET /api/agents/optimize +GET /api/agents/sync +GET /api/backup +GET /api/channels +GET /api/claude-tasks +GET /api/cleanup +GET /api/gateways/discover +GET /api/gateways/health/history +GET /api/github/sync +GET /api/gnap +GET /api/hermes +GET /api/hermes/memory +GET /api/hermes/tasks +GET /api/index +GET /api/local/agents-doc +GET /api/local/flight-deck +GET /api/memory/context +GET /api/memory/graph +GET /api/memory/health +GET /api/memory/links +GET /api/nodes +GET /api/notifications/deliver +GET /api/pipelines/run +GET /api/projects/{id}/agents +GET /api/schedule-parse +GET /api/security-audit +GET /api/security-scan +GET /api/spawn +GET /api/super/os-users +GET /api/system-monitor +GET /api/tasks/outcomes +GET /api/tasks/regression +GET /api/tokens/by-agent +PATCH /api/auth/me +POST /api/agents/evals +POST /api/agents/register +POST /api/auth/google/disconnect +POST /api/channels +POST /api/github/sync +POST /api/gnap +POST /api/hermes +POST /api/local/flight-deck +POST /api/local/terminal +POST /api/logs +POST /api/memory/process +POST /api/nodes +POST /api/projects/{id}/agents +POST /api/releases/update +POST /api/security-scan/agent +POST /api/security-scan/fix +POST /api/standup +POST /api/super/os-users +POST /api/super/provision-jobs/{id} +POST /api/tokens/rotate +PUT /api/integrations +PUT /api/notifications diff --git a/scripts/check-api-contract-parity.mjs b/scripts/check-api-contract-parity.mjs new file mode 100644 index 0000000..8921a3a --- /dev/null +++ b/scripts/check-api-contract-parity.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +import fs from 'node:fs' +import path from 'node:path' + +const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] + +function toPosix(input) { + return input.split(path.sep).join('/') +} + +function normalizeSegment(segment) { + if (segment.startsWith('[[...') && segment.endsWith(']]')) return `{${segment.slice(5, -2)}}` + if (segment.startsWith('[...') && segment.endsWith(']')) return `{${segment.slice(4, -1)}}` + if (segment.startsWith('[') && segment.endsWith(']')) return `{${segment.slice(1, -1)}}` + return segment +} + +function routeFileToApiPath(projectRoot, fullPath) { + const rel = toPosix(path.relative(projectRoot, fullPath)) + const withoutRoute = rel.replace(/\/route\.tsx?$/, '') + const trimmed = withoutRoute.startsWith('src/app/api') ? withoutRoute.slice('src/app/api'.length) : withoutRoute + const parts = trimmed.split('/').filter(Boolean).map(normalizeSegment) + return `/api${parts.length ? `/${parts.join('/')}` : ''}` +} + +function extractHttpMethods(source) { + const methods = [] + for (const method of HTTP_METHODS) { + const constExport = new RegExp(`export\\s+const\\s+${method}\\s*=`, 'm') + const fnExport = new RegExp(`export\\s+(?:async\\s+)?function\\s+${method}\\s*\\(`, 'm') + if (constExport.test(source) || fnExport.test(source)) methods.push(method) + } + return methods +} + +function walkRouteFiles(dir, out = []) { + if (!fs.existsSync(dir)) return out + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) walkRouteFiles(full, out) + else if (entry.isFile() && /route\.tsx?$/.test(entry.name)) out.push(full) + } + return out +} + +function normalizeOperation(operation) { + const [method = '', ...pathParts] = String(operation || '').trim().split(' ') + const normalizedMethod = method.toUpperCase() + const normalizedPath = pathParts.join(' ').trim() + return `${normalizedMethod} ${normalizedPath}` +} + +function parseIgnoreArg(ignoreArg) { + if (!ignoreArg) return [] + return ignoreArg + .split(',') + .map((x) => normalizeOperation(x)) + .filter(Boolean) +} + +function parseArgs(argv) { + const flags = {} + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i] + if (!token.startsWith('--')) continue + const key = token.slice(2) + const next = argv[i + 1] + if (!next || next.startsWith('--')) { + flags[key] = true + continue + } + flags[key] = next + i += 1 + } + return flags +} + +function run() { + const flags = parseArgs(process.argv.slice(2)) + const projectRoot = path.resolve(String(flags.root || process.cwd())) + const openapiPath = path.resolve(projectRoot, String(flags.openapi || 'openapi.json')) + const ignoreFile = flags['ignore-file'] ? path.resolve(projectRoot, String(flags['ignore-file'])) : null + const ignoreInline = parseIgnoreArg(flags.ignore) + let ignore = new Set(ignoreInline) + + if (ignoreFile && fs.existsSync(ignoreFile)) { + const lines = fs + .readFileSync(ignoreFile, 'utf8') + .split('\n') + .map((x) => x.trim()) + .filter((x) => x && !x.startsWith('#')) + .map((x) => normalizeOperation(x)) + ignore = new Set([...ignore, ...lines]) + } + + const openapi = JSON.parse(fs.readFileSync(openapiPath, 'utf8')) + const openapiOps = new Set() + for (const [rawPath, pathItem] of Object.entries(openapi.paths || {})) { + for (const method of Object.keys(pathItem || {})) { + const upper = method.toUpperCase() + if (HTTP_METHODS.includes(upper)) { + openapiOps.add(`${upper} ${rawPath}`) + } + } + } + + const routeFiles = walkRouteFiles(path.join(projectRoot, 'src/app/api')) + const routeOps = new Set() + for (const file of routeFiles) { + const source = fs.readFileSync(file, 'utf8') + const methods = extractHttpMethods(source) + const apiPath = routeFileToApiPath(projectRoot, file) + for (const method of methods) routeOps.add(`${method} ${apiPath}`) + } + + const missingInOpenApi = [...routeOps].filter((op) => !openapiOps.has(op) && !ignore.has(op)).sort() + const missingInRoutes = [...openapiOps].filter((op) => !routeOps.has(op) && !ignore.has(op)).sort() + + const summary = { + ok: missingInOpenApi.length === 0 && missingInRoutes.length === 0, + totals: { + routeOperations: routeOps.size, + openapiOperations: openapiOps.size, + ignoredOperations: ignore.size, + }, + missingInOpenApi, + missingInRoutes, + } + + if (flags.json) { + console.log(JSON.stringify(summary, null, 2)) + } else { + console.log('API contract parity check') + console.log(`- route operations: ${summary.totals.routeOperations}`) + console.log(`- openapi operations: ${summary.totals.openapiOperations}`) + console.log(`- ignored entries: ${summary.totals.ignoredOperations}`) + if (missingInOpenApi.length) { + console.log('\nMissing in OpenAPI:') + for (const op of missingInOpenApi) console.log(` - ${op}`) + } + if (missingInRoutes.length) { + console.log('\nMissing in routes:') + for (const op of missingInRoutes) console.log(` - ${op}`) + } + if (!missingInOpenApi.length && !missingInRoutes.length) { + console.log('\nāœ… Contract parity OK') + } + } + + process.exit(summary.ok ? 0 : 1) +} + +run() diff --git a/scripts/mc-cli.cjs b/scripts/mc-cli.cjs new file mode 100644 index 0000000..ce7bf3f --- /dev/null +++ b/scripts/mc-cli.cjs @@ -0,0 +1,353 @@ +#!/usr/bin/env node +/* + Mission Control CLI (v1 scaffold) + - Zero heavy dependencies + - API-key first for agent automation + - JSON mode + stable exit codes +*/ + +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const EXIT = { + OK: 0, + USAGE: 2, + AUTH: 3, + FORBIDDEN: 4, + NETWORK: 5, + SERVER: 6, +}; + +function parseArgs(argv) { + const out = { _: [], flags: {} }; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (!token.startsWith('--')) { + out._.push(token); + continue; + } + const key = token.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith('--')) { + out.flags[key] = true; + continue; + } + out.flags[key] = next; + i += 1; + } + return out; +} + +function usage() { + console.log(`Mission Control CLI + +Usage: + mc [--flags] + +Groups: + auth login/logout/whoami + agents list/get/create/update/delete/wake/diagnostics/heartbeat + tasks list/get/create/update/delete/queue/comment + sessions list/control/continue + connect register/list/disconnect + tokens list/stats/by-agent + skills list/content/upsert/delete/check + cron list/create/update/pause/resume/remove/run + events watch + raw request fallback + +Common flags: + --profile profile name (default: default) + --url override profile URL + --api-key override profile API key + --json JSON output + --timeout-ms request timeout (default 20000) + --help show help + +Examples: + mc agents list --json + mc tasks queue --agent Aegis --max-capacity 2 + mc sessions control --id abc123 --action terminate + mc raw --method GET --path /api/status --json +`); +} + +function profilePath(name) { + return path.join(os.homedir(), '.mission-control', 'profiles', `${name}.json`); +} + +function ensureParentDir(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function loadProfile(name) { + const p = profilePath(name); + if (!fs.existsSync(p)) { + return { + name, + url: process.env.MC_URL || 'http://127.0.0.1:3000', + apiKey: process.env.MC_API_KEY || '', + cookie: process.env.MC_COOKIE || '', + }; + } + try { + const parsed = JSON.parse(fs.readFileSync(p, 'utf8')); + return { + name, + url: parsed.url || process.env.MC_URL || 'http://127.0.0.1:3000', + apiKey: parsed.apiKey || process.env.MC_API_KEY || '', + cookie: parsed.cookie || process.env.MC_COOKIE || '', + }; + } catch { + return { + name, + url: process.env.MC_URL || 'http://127.0.0.1:3000', + apiKey: process.env.MC_API_KEY || '', + cookie: process.env.MC_COOKIE || '', + }; + } +} + +function saveProfile(profile) { + const p = profilePath(profile.name); + ensureParentDir(p); + fs.writeFileSync(p, `${JSON.stringify(profile, null, 2)}\n`, 'utf8'); +} + +function normalizeBaseUrl(url) { + return String(url || '').replace(/\/+$/, ''); +} + +function mapStatusToExit(status) { + if (status === 401) return EXIT.AUTH; + if (status === 403) return EXIT.FORBIDDEN; + if (status >= 500) return EXIT.SERVER; + return EXIT.USAGE; +} + +async function httpRequest({ baseUrl, apiKey, cookie, method, route, body, timeoutMs = 20000 }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const headers = { Accept: 'application/json' }; + if (apiKey) headers['x-api-key'] = apiKey; + if (cookie) headers['Cookie'] = cookie; + let payload; + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + payload = JSON.stringify(body); + } + const url = `${normalizeBaseUrl(baseUrl)}${route.startsWith('/') ? route : `/${route}`}`; + + try { + const res = await fetch(url, { + method, + headers, + body: payload, + signal: controller.signal, + }); + clearTimeout(timer); + const text = await res.text(); + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { raw: text }; + } + return { + ok: res.ok, + status: res.status, + data, + setCookie: res.headers.get('set-cookie') || '', + url, + method, + }; + } catch (err) { + clearTimeout(timer); + if (String(err?.name || '') === 'AbortError') { + return { ok: false, status: 0, data: { error: `Request timeout after ${timeoutMs}ms` }, timeout: true, url, method }; + } + return { ok: false, status: 0, data: { error: err?.message || 'Network error' }, network: true, url, method }; + } +} + +function printResult(result, asJson) { + if (asJson) { + console.log(JSON.stringify(result, null, 2)); + return; + } + if (result.ok) { + console.log(`OK ${result.status} ${result.method} ${result.url}`); + if (result.data && Object.keys(result.data).length > 0) { + console.log(JSON.stringify(result.data, null, 2)); + } + return; + } + console.error(`ERROR ${result.status || 'NETWORK'} ${result.method} ${result.url}`); + console.error(JSON.stringify(result.data, null, 2)); +} + +function required(flags, key) { + const value = flags[key]; + if (value === undefined || value === true || String(value).trim() === '') { + throw new Error(`Missing required flag --${key}`); + } + return value; +} + +async function run() { + const parsed = parseArgs(process.argv.slice(2)); + if (parsed.flags.help || parsed._.length === 0) { + usage(); + process.exit(EXIT.OK); + } + + const asJson = Boolean(parsed.flags.json); + const profileName = String(parsed.flags.profile || 'default'); + const profile = loadProfile(profileName); + const baseUrl = parsed.flags.url ? String(parsed.flags.url) : profile.url; + const apiKey = parsed.flags['api-key'] ? String(parsed.flags['api-key']) : profile.apiKey; + const timeoutMs = Number(parsed.flags['timeout-ms'] || 20000); + + const group = parsed._[0]; + const action = parsed._[1]; + + try { + if (group === 'auth') { + if (action === 'login') { + const username = required(parsed.flags, 'username'); + const password = required(parsed.flags, 'password'); + const result = await httpRequest({ + baseUrl, + method: 'POST', + route: '/api/auth/login', + body: { username, password }, + timeoutMs, + }); + if (result.ok && result.setCookie) { + profile.url = baseUrl; + profile.cookie = result.setCookie.split(';')[0]; + if (apiKey) profile.apiKey = apiKey; + saveProfile(profile); + result.data = { ...result.data, profile: profile.name, saved_cookie: true }; + } + printResult(result, asJson); + process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status)); + } + if (action === 'logout') { + const result = await httpRequest({ baseUrl, apiKey, cookie: profile.cookie, method: 'POST', route: '/api/auth/logout', timeoutMs }); + if (result.ok) { + profile.cookie = ''; + saveProfile(profile); + } + printResult(result, asJson); + process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status)); + } + if (action === 'whoami') { + const result = await httpRequest({ baseUrl, apiKey, cookie: profile.cookie, method: 'GET', route: '/api/auth/me', timeoutMs }); + printResult(result, asJson); + process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status)); + } + } + + if (group === 'raw') { + const method = String(required(parsed.flags, 'method')).toUpperCase(); + const route = String(required(parsed.flags, 'path')); + const body = parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : undefined; + const result = await httpRequest({ baseUrl, apiKey, cookie: profile.cookie, method, route, body, timeoutMs }); + printResult(result, asJson); + process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status)); + } + + const map = { + agents: { + list: { method: 'GET', route: '/api/agents' }, + get: { method: 'GET', route: `/api/agents/${required(parsed.flags, 'id')}` }, + create: { method: 'POST', route: '/api/agents', body: { name: required(parsed.flags, 'name'), role: required(parsed.flags, 'role') } }, + update: { method: 'PUT', route: `/api/agents/${required(parsed.flags, 'id')}`, body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, + delete: { method: 'DELETE', route: `/api/agents/${required(parsed.flags, 'id')}` }, + wake: { method: 'POST', route: `/api/agents/${required(parsed.flags, 'id')}/wake` }, + diagnostics: { method: 'GET', route: `/api/agents/${required(parsed.flags, 'id')}/diagnostics` }, + heartbeat: { method: 'POST', route: `/api/agents/${required(parsed.flags, 'id')}/heartbeat` }, + }, + tasks: { + list: { method: 'GET', route: '/api/tasks' }, + get: { method: 'GET', route: `/api/tasks/${required(parsed.flags, 'id')}` }, + create: { method: 'POST', route: '/api/tasks', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : { title: required(parsed.flags, 'title') } }, + update: { method: 'PUT', route: `/api/tasks/${required(parsed.flags, 'id')}`, body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, + delete: { method: 'DELETE', route: `/api/tasks/${required(parsed.flags, 'id')}` }, + queue: { method: 'GET', route: `/api/tasks/queue?agent=${encodeURIComponent(required(parsed.flags, 'agent'))}${parsed.flags['max-capacity'] ? `&max_capacity=${encodeURIComponent(String(parsed.flags['max-capacity']))}` : ''}` }, + }, + sessions: { + list: { method: 'GET', route: '/api/sessions' }, + control: { method: 'POST', route: `/api/sessions/${required(parsed.flags, 'id')}/control`, body: { action: required(parsed.flags, 'action') } }, + continue: { method: 'POST', route: '/api/sessions/continue', body: { kind: required(parsed.flags, 'kind'), id: required(parsed.flags, 'id'), prompt: required(parsed.flags, 'prompt') } }, + }, + connect: { + register: { method: 'POST', route: '/api/connect', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : { tool_name: required(parsed.flags, 'tool-name'), agent_name: required(parsed.flags, 'agent-name') } }, + list: { method: 'GET', route: '/api/connect' }, + disconnect: { method: 'DELETE', route: '/api/connect', body: { connection_id: required(parsed.flags, 'connection-id') } }, + }, + tokens: { + list: { method: 'GET', route: '/api/tokens?action=list' }, + stats: { method: 'GET', route: '/api/tokens?action=stats' }, + 'by-agent': { method: 'GET', route: `/api/tokens/by-agent?days=${encodeURIComponent(String(parsed.flags.days || '30'))}` }, + }, + skills: { + list: { method: 'GET', route: '/api/skills' }, + content: { method: 'GET', route: `/api/skills?mode=content&source=${encodeURIComponent(required(parsed.flags, 'source'))}&name=${encodeURIComponent(required(parsed.flags, 'name'))}` }, + check: { method: 'GET', route: `/api/skills?mode=check&source=${encodeURIComponent(required(parsed.flags, 'source'))}&name=${encodeURIComponent(required(parsed.flags, 'name'))}` }, + upsert: { method: 'PUT', route: '/api/skills', body: { source: required(parsed.flags, 'source'), name: required(parsed.flags, 'name'), content: fs.readFileSync(required(parsed.flags, 'file'), 'utf8') } }, + delete: { method: 'DELETE', route: `/api/skills?source=${encodeURIComponent(required(parsed.flags, 'source'))}&name=${encodeURIComponent(required(parsed.flags, 'name'))}` }, + }, + cron: { + list: { method: 'GET', route: '/api/cron' }, + create: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, + update: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, + pause: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, + resume: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, + remove: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, + run: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, + }, + events: { + watch: null, + }, + }; + + if (group === 'events' && action === 'watch') { + const result = await httpRequest({ baseUrl, apiKey, cookie: profile.cookie, method: 'GET', route: '/api/events', timeoutMs: Number(parsed.flags['timeout-ms'] || 3600000) }); + // Basic fallback: if server doesn't stream in this fetch mode, print response payload + printResult(result, asJson); + process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status)); + } + + const cfg = map[group] && map[group][action]; + if (!cfg) { + usage(); + process.exit(EXIT.USAGE); + } + + const result = await httpRequest({ + baseUrl, + apiKey, + cookie: profile.cookie, + method: cfg.method, + route: cfg.route, + body: cfg.body, + timeoutMs, + }); + + printResult(result, asJson); + process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status)); + } catch (err) { + const message = err?.message || String(err); + if (asJson) { + console.log(JSON.stringify({ ok: false, error: message }, null, 2)); + } else { + console.error(`USAGE ERROR: ${message}`); + } + process.exit(EXIT.USAGE); + } +} + +run(); diff --git a/src/lib/__tests__/api-contract-parity.test.ts b/src/lib/__tests__/api-contract-parity.test.ts new file mode 100644 index 0000000..b0bf457 --- /dev/null +++ b/src/lib/__tests__/api-contract-parity.test.ts @@ -0,0 +1,100 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { + collectOpenApiOperations, + compareApiContractParity, + extractHttpMethods, + routeFileToApiPath, + runApiContractParityCheck, +} from '@/lib/api-contract-parity' + +const tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('api-contract-parity helpers', () => { + it('maps Next.js route files to OpenAPI-style API paths', () => { + expect(routeFileToApiPath('src/app/api/agents/route.ts')).toBe('/api/agents') + expect(routeFileToApiPath('src/app/api/tasks/[id]/route.ts')).toBe('/api/tasks/{id}') + expect(routeFileToApiPath('src/app/api/files/[...slug]/route.ts')).toBe('/api/files/{slug}') + expect(routeFileToApiPath('src/app/api/optional/[[...tail]]/route.ts')).toBe('/api/optional/{tail}') + }) + + it('extracts exported HTTP methods from route modules', () => { + const source = ` + export const GET = async () => {} + export const POST = async () => {} + const internal = 'ignore me' + ` + expect(extractHttpMethods(source).sort()).toEqual(['GET', 'POST']) + }) + + it('normalizes OpenAPI operations', () => { + const operations = collectOpenApiOperations({ + paths: { + '/api/tasks': { get: {}, post: {} }, + '/api/tasks/{id}': { delete: {}, patch: {} }, + }, + }) + expect(operations).toEqual([ + 'DELETE /api/tasks/{id}', + 'GET /api/tasks', + 'PATCH /api/tasks/{id}', + 'POST /api/tasks', + ]) + }) + + it('reports mismatches with optional ignore list', () => { + const report = compareApiContractParity({ + routeOperations: [ + { method: 'GET', path: '/api/tasks', sourceFile: 'a' }, + { method: 'POST', path: '/api/tasks', sourceFile: 'a' }, + { method: 'DELETE', path: '/api/tasks/{id}', sourceFile: 'b' }, + ], + openapiOperations: ['GET /api/tasks', 'PATCH /api/tasks/{id}', 'DELETE /api/tasks/{id}'], + ignore: ['PATCH /api/tasks/{id}'], + }) + + expect(report.missingInOpenApi).toEqual(['POST /api/tasks']) + expect(report.missingInRoutes).toEqual([]) + expect(report.ignoredOperations).toEqual(['PATCH /api/tasks/{id}']) + }) + + it('scans a project root and compares route operations to openapi', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-contract-')) + tempDirs.push(root) + + const routeDir = path.join(root, 'src/app/api/tasks/[id]') + fs.mkdirSync(routeDir, { recursive: true }) + fs.writeFileSync(path.join(root, 'src/app/api/tasks/route.ts'), 'export const GET = async () => {};\n', 'utf8') + fs.writeFileSync(path.join(routeDir, 'route.ts'), 'export const DELETE = async () => {};\n', 'utf8') + + fs.writeFileSync( + path.join(root, 'openapi.json'), + JSON.stringify({ + openapi: '3.0.0', + paths: { + '/api/tasks': { get: {} }, + '/api/tasks/{id}': { delete: {}, patch: {} }, + }, + }), + 'utf8', + ) + + const report = runApiContractParityCheck({ + projectRoot: root, + ignore: ['PATCH /api/tasks/{id}'], + }) + + expect(report.missingInOpenApi).toEqual([]) + expect(report.missingInRoutes).toEqual([]) + expect(report.ignoredOperations).toEqual(['PATCH /api/tasks/{id}']) + }) +}) diff --git a/src/lib/api-contract-parity.ts b/src/lib/api-contract-parity.ts new file mode 100644 index 0000000..6b64df3 --- /dev/null +++ b/src/lib/api-contract-parity.ts @@ -0,0 +1,176 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' + +export type ContractOperation = string + +export interface RouteOperation { + method: string + path: string + sourceFile: string +} + +export interface ParityReport { + routeOperations: ContractOperation[] + openapiOperations: ContractOperation[] + missingInOpenApi: ContractOperation[] + missingInRoutes: ContractOperation[] + ignoredOperations: ContractOperation[] +} + +const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const + +function toPosix(input: string): string { + return input.split(path.sep).join('/') +} + +function normalizeSegment(segment: string): string { + if (segment.startsWith('[[...') && segment.endsWith(']]')) { + return `{${segment.slice(5, -2)}}` + } + if (segment.startsWith('[...') && segment.endsWith(']')) { + return `{${segment.slice(4, -1)}}` + } + if (segment.startsWith('[') && segment.endsWith(']')) { + return `{${segment.slice(1, -1)}}` + } + return segment +} + +export function routeFileToApiPath(routeFile: string, apiRoot = 'src/app/api'): string { + const normalizedFile = toPosix(routeFile) + const normalizedRoot = toPosix(apiRoot) + const routeWithoutExt = normalizedFile.replace(/\/route\.tsx?$/, '') + const relative = routeWithoutExt.startsWith(normalizedRoot) + ? routeWithoutExt.slice(normalizedRoot.length) + : routeWithoutExt + + const segments = relative + .split('/') + .filter(Boolean) + .map(normalizeSegment) + + return `/api${segments.length ? `/${segments.join('/')}` : ''}` +} + +export function extractHttpMethods(source: string): string[] { + const methods = new Set() + for (const method of HTTP_METHODS) { + const constExport = new RegExp(`export\\s+const\\s+${method}\\s*=`, 'm') + const fnExport = new RegExp(`export\\s+(?:async\\s+)?function\\s+${method}\\s*\\(`, 'm') + if (constExport.test(source) || fnExport.test(source)) methods.add(method) + } + return Array.from(methods) +} + +function walkRouteFiles(dir: string, found: string[] = []): string[] { + if (!fs.existsSync(dir)) return found + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + walkRouteFiles(fullPath, found) + } else if (entry.isFile() && /route\.tsx?$/.test(entry.name)) { + found.push(fullPath) + } + } + return found +} + +export function collectRouteOperations(projectRoot: string): RouteOperation[] { + const apiRoot = path.join(projectRoot, 'src', 'app', 'api') + const routeFiles = walkRouteFiles(apiRoot) + + const operations: RouteOperation[] = [] + for (const file of routeFiles) { + const source = fs.readFileSync(file, 'utf8') + const methods = extractHttpMethods(source) + const apiPath = routeFileToApiPath(toPosix(path.relative(projectRoot, file))) + for (const method of methods) { + operations.push({ method, path: apiPath, sourceFile: file }) + } + } + + return operations +} + +export function collectOpenApiOperations(openapi: any): ContractOperation[] { + const operations = new Set() + const paths = openapi?.paths ?? {} + for (const [rawPath, pathItem] of Object.entries(paths)) { + const normalizedPath = String(rawPath) + for (const method of Object.keys(pathItem as Record)) { + const upper = method.toUpperCase() + if ((HTTP_METHODS as readonly string[]).includes(upper)) { + operations.add(`${upper} ${normalizedPath}`) + } + } + } + return Array.from(operations).sort() +} + +function toContractOperation(method: string, apiPath: string): ContractOperation { + return `${method.toUpperCase()} ${apiPath}` +} + +function normalizeOperation(operation: string): ContractOperation { + const [method = '', ...pathParts] = operation.trim().split(' ') + const normalizedMethod = method.toUpperCase() + const normalizedPath = pathParts.join(' ').trim() + return `${normalizedMethod} ${normalizedPath}` as ContractOperation +} + +export function compareApiContractParity(params: { + routeOperations: RouteOperation[] + openapiOperations: ContractOperation[] + ignore?: string[] +}): ParityReport { + const ignored = new Set((params.ignore ?? []).map((x) => normalizeOperation(x))) + const routeOperations = Array.from(new Set(params.routeOperations.map((op) => toContractOperation(op.method, op.path)))).sort() + const openapiOperations = Array.from(new Set(params.openapiOperations.map((op) => normalizeOperation(op)))).sort() + + const routeSet = new Set(routeOperations) + const openapiSet = new Set(openapiOperations) + + const ignoredOperations: ContractOperation[] = [] + const missingInOpenApi: ContractOperation[] = [] + for (const op of routeOperations) { + if (ignored.has(op)) { + ignoredOperations.push(op) + continue + } + if (!openapiSet.has(op)) missingInOpenApi.push(op) + } + + const missingInRoutes: ContractOperation[] = [] + for (const op of openapiOperations) { + if (ignored.has(op)) { + if (!ignoredOperations.includes(op as ContractOperation)) ignoredOperations.push(op as ContractOperation) + continue + } + if (!routeSet.has(op)) missingInRoutes.push(op as ContractOperation) + } + + return { + routeOperations: routeOperations as ContractOperation[], + openapiOperations: openapiOperations as ContractOperation[], + missingInOpenApi, + missingInRoutes, + ignoredOperations: ignoredOperations.sort(), + } +} + +export function loadOpenApiFile(projectRoot: string, openapiPath = 'openapi.json'): any { + const filePath = path.join(projectRoot, openapiPath) + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +export function runApiContractParityCheck(params: { + projectRoot: string + openapiPath?: string + ignore?: string[] +}): ParityReport { + const projectRoot = path.resolve(params.projectRoot) + const openapi = loadOpenApiFile(projectRoot, params.openapiPath) + const routeOperations = collectRouteOperations(projectRoot) + const openapiOperations = collectOpenApiOperations(openapi) + return compareApiContractParity({ routeOperations, openapiOperations, ignore: params.ignore }) +}