diff --git a/docs/control-api-schema.md b/docs/control-api-schema.md new file mode 100644 index 0000000..c98f49b --- /dev/null +++ b/docs/control-api-schema.md @@ -0,0 +1,28 @@ +# control api schema + +Wolfpack's public HTTP control API and `/ws/pty` control-message runtime behavior is owned by the server routes and WebSocket handlers. `src/control-api/schema.ts` owns the generated client-facing schema artifact. + +Generated artifact: + +- `docs/generated/control-api.schema.json` + +Regenerate after contract changes: + +```sh +bun run gen:schema +``` + +The generated schema is a client integration contract. Runtime routes remain authoritative for validation and behavior. The schema must not replace the existing server trust boundaries: + +- project, session, branch, command, and plan validation stays in `src/validation.ts` and route-specific checks. +- project directory containment stays in `src/server/validate-project-dir.ts`. +- broker JSON/RPC wire compatibility stays covered by broker protocol/codec tests. +- peer Ralph loop responses remain sanitized before aggregation. + +Compatibility rules: + +- Additive changes are allowed for new optional fields, new stable operations, and new server-to-client event types. +- Breaking changes include removing or renaming stable fields/routes/messages, making optional fields required, changing auth expectations, or tightening stable enum/pattern constraints. +- Breaking changes need release notes that name the changed operation/message and migration path. + +Schema compatibility checks live in `tests/unit/control-api-schema.test.ts`; representative runtime response checks live in integration tests. diff --git a/docs/generated/control-api.schema.json b/docs/generated/control-api.schema.json new file mode 100644 index 0000000..a937b06 --- /dev/null +++ b/docs/generated/control-api.schema.json @@ -0,0 +1,1796 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "wolfpack:control-api", + "title": "Wolfpack public control API and event stream schema", + "type": "object", + "version": "1.0.0", + "generatedFrom": "src/control-api/schema.ts", + "artifactPath": "docs/generated/control-api.schema.json", + "compatibility": { + "additiveChanges": [ + "new optional object properties", + "new stable operations", + "new server-to-client event message types" + ], + "breakingChanges": [ + "removing or renaming stable operations, fields, or message types", + "making optional fields required", + "changing auth expectations", + "tightening enum/pattern constraints for stable fields" + ] + }, + "ownership": { + "schemaSource": "src/control-api/schema.ts", + "runtimeSource": "src/server/routes.ts", + "generatedArtifact": "docs/generated/control-api.schema.json", + "compatibilityDocs": "docs/control-api-schema.md" + }, + "trustBoundaries": [ + "schemas publish public client contracts; they do not replace route-side project/session/path validation", + "filesystem containment remains in src/server/validate-project-dir.ts and src/validation.ts", + "broker wire compatibility remains covered by broker codec/protocol tests, not by this schema", + "peer Ralph aggregation still sanitizes remote loop entries before exposing them to clients" + ], + "http": { + "getInfo": { + "route": "GET /api/info", + "stable": true, + "auth": "public", + "response": { + "$id": "wolfpack:control-api:http:getInfo:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getInfo response", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "additionalProperties": false + }, + "errors": [] + }, + "listSessions": { + "route": "GET /api/sessions", + "stable": true, + "auth": "jwt-when-configured", + "response": { + "$id": "wolfpack:control-api:http:listSessions:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listSessions response", + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionSummary" + } + } + }, + "required": [ + "sessions" + ], + "additionalProperties": false + }, + "errors": [] + }, + "listProjects": { + "route": "GET /api/projects", + "stable": true, + "auth": "jwt-when-configured", + "response": { + "$id": "wolfpack:control-api:http:listProjects:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listProjects response", + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "$ref": "#/$defs/ProjectName" + } + } + }, + "required": [ + "projects" + ], + "additionalProperties": false + }, + "errors": [] + }, + "nextSessionName": { + "route": "GET /api/next-session-name", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:nextSessionName:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "nextSessionName request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:nextSessionName:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "nextSessionName response", + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/SessionName" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope" + ] + }, + "createSession": { + "route": "POST /api/create", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:createSession:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "createSession request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + }, + "newProject": { + "$ref": "#/$defs/ProjectName" + }, + "cmd": { + "$ref": "#/$defs/Command" + }, + "sessionName": { + "$ref": "#/$defs/SessionName" + } + }, + "required": [], + "additionalProperties": false, + "anyOf": [ + { + "type": "object", + "properties": {}, + "required": [ + "project" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": {}, + "required": [ + "newProject" + ], + "additionalProperties": true + } + ] + }, + "response": { + "$id": "wolfpack:control-api:http:createSession:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "createSession response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "session": { + "$ref": "#/$defs/SessionName" + } + }, + "required": [ + "ok", + "session" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "409 ErrorEnvelope" + ] + }, + "getSettings": { + "route": "GET /api/settings", + "stable": true, + "auth": "jwt-when-configured", + "response": { + "$id": "wolfpack:control-api:http:getSettings:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getSettings response", + "type": "object", + "properties": { + "settings": { + "$ref": "#/$defs/Settings" + }, + "effective": { + "$ref": "#/$defs/EffectiveSettings" + } + }, + "required": [ + "settings", + "effective" + ], + "additionalProperties": false + }, + "errors": [] + }, + "updateSettings": { + "route": "POST /api/settings", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:updateSettings:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "updateSettings request", + "type": "object", + "properties": { + "agentCmd": { + "$ref": "#/$defs/Command" + }, + "addCmd": { + "$ref": "#/$defs/Command" + }, + "removeCmd": { + "$ref": "#/$defs/Command" + }, + "setCmdEnabled": { + "type": "object", + "properties": { + "cmd": { + "$ref": "#/$defs/Command" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "cmd", + "enabled" + ], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:updateSettings:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "updateSettings response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "settings": { + "$ref": "#/$defs/Settings" + }, + "effective": { + "$ref": "#/$defs/EffectiveSettings" + } + }, + "required": [ + "ok", + "settings", + "effective" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope" + ] + }, + "getBackendStatus": { + "route": "GET /api/backend", + "stable": true, + "auth": "jwt-when-configured", + "response": { + "$id": "wolfpack:control-api:http:getBackendStatus:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getBackendStatus response", + "type": "object", + "properties": { + "brokerAvailable": { + "type": "boolean" + }, + "counts": { + "type": "object", + "properties": { + "tmux": { + "type": "integer" + }, + "broker": { + "type": "integer" + } + }, + "required": [ + "tmux", + "broker" + ], + "additionalProperties": false + } + }, + "required": [ + "brokerAvailable", + "counts" + ], + "additionalProperties": false + }, + "errors": [] + }, + "killSession": { + "route": "POST /api/kill", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:killSession:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "killSession request", + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/SessionName" + } + }, + "required": [ + "session" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:killSession:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "killSession response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope" + ] + }, + "resizeSession": { + "route": "POST /api/resize", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:resizeSession:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "resizeSession request", + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/SessionName" + }, + "cols": { + "type": "integer" + }, + "rows": { + "type": "integer" + } + }, + "required": [ + "session", + "cols", + "rows" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:resizeSession:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "resizeSession response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope" + ] + }, + "discoverPeers": { + "route": "GET /api/discover", + "stable": true, + "auth": "jwt-when-configured", + "response": { + "$id": "wolfpack:control-api:http:discoverPeers:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "discoverPeers response", + "type": "object", + "properties": { + "peers": { + "type": "array", + "items": { + "$ref": "#/$defs/Peer" + } + }, + "error": { + "type": "string" + } + }, + "required": [ + "peers" + ], + "additionalProperties": false + }, + "errors": [] + }, + "capturePane": { + "route": "GET /api/poll", + "stable": false, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:capturePane:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "capturePane request", + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/SessionName" + } + }, + "required": [ + "session" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:capturePane:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "capturePane response", + "type": "object", + "properties": { + "pane": { + "type": "string" + } + }, + "required": [ + "pane" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope" + ] + }, + "copyText": { + "route": "GET /api/copy-text", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:copyText:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "copyText request", + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/SessionName" + } + }, + "required": [ + "session" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:copyText:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "copyText response", + "type": "string", + "contentMediaType": "text/plain" + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope" + ] + }, + "getGitStatus": { + "route": "GET /api/git-status", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:getGitStatus:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getGitStatus request", + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/ProjectName" + } + }, + "required": [ + "session" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:getGitStatus:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getGitStatus response", + "type": "object", + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "500 ErrorEnvelope" + ] + }, + "listRalphLoops": { + "route": "GET /api/ralph", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:listRalphLoops:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listRalphLoops request", + "type": "object", + "properties": { + "aggregate": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:listRalphLoops:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listRalphLoops response", + "type": "object", + "properties": { + "loops": { + "type": "array", + "items": { + "$ref": "#/$defs/RalphLoop" + } + } + }, + "required": [ + "loops" + ], + "additionalProperties": false + }, + "errors": [] + }, + "listRalphBranches": { + "route": "GET /api/ralph/branches", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:listRalphBranches:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listRalphBranches request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:listRalphBranches:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listRalphBranches response", + "type": "object", + "properties": { + "branches": { + "type": "array", + "items": { + "$ref": "#/$defs/BranchName" + } + }, + "current": { + "type": "string" + } + }, + "required": [ + "branches", + "current" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "500 ErrorEnvelope" + ] + }, + "listRalphPlans": { + "route": "GET /api/ralph/plans", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:listRalphPlans:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listRalphPlans request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:listRalphPlans:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listRalphPlans response", + "type": "object", + "properties": { + "plans": { + "type": "array", + "items": { + "$ref": "#/$defs/PlanFile" + } + } + }, + "required": [ + "plans" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope" + ] + }, + "getRalphLog": { + "route": "GET /api/ralph/log", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:getRalphLog:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getRalphLog request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:getRalphLog:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getRalphLog response", + "type": "object", + "properties": { + "log": { + "type": "string" + }, + "totalLines": { + "type": "integer" + } + }, + "required": [ + "log", + "totalLines" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "500 ErrorEnvelope" + ] + }, + "startRalph": { + "route": "POST /api/ralph/start", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:startRalph:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "startRalph request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + }, + "iterations": { + "type": "integer" + }, + "planFile": { + "$ref": "#/$defs/PlanFile" + }, + "agent": { + "type": "string" + }, + "newBranch": { + "$ref": "#/$defs/BranchName" + }, + "sourceBranch": { + "$ref": "#/$defs/BranchName" + }, + "format": { + "type": "boolean" + }, + "cleanup": { + "type": "boolean" + }, + "auditFix": { + "type": "boolean" + }, + "worktree": { + "$ref": "#/$defs/WorktreeMode" + }, + "worktreeBranch": { + "$ref": "#/$defs/BranchName" + }, + "worktreeBase": { + "$ref": "#/$defs/BranchName" + }, + "sandbox": { + "type": "boolean" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:startRalph:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "startRalph response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "pid": { + "type": "integer" + }, + "branch": { + "type": "string" + }, + "worktree": { + "enum": [ + "plan", + "task" + ] + } + }, + "required": [ + "ok", + "pid" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "409 ErrorEnvelope", + "500 ErrorEnvelope" + ] + }, + "getRalphTaskCount": { + "route": "GET /api/ralph/task-count", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:getRalphTaskCount:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getRalphTaskCount request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + }, + "plan": { + "$ref": "#/$defs/PlanFile" + } + }, + "required": [ + "project", + "plan" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:getRalphTaskCount:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getRalphTaskCount response", + "$ref": "#/$defs/TaskCount" + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope" + ] + }, + "cancelRalph": { + "route": "POST /api/ralph/cancel", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:cancelRalph:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "cancelRalph request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:cancelRalph:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "cancelRalph response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "killed": { + "type": "integer" + } + }, + "required": [ + "ok", + "killed" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "500 ErrorEnvelope" + ] + }, + "dismissRalph": { + "route": "POST /api/ralph/dismiss", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:dismissRalph:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "dismissRalph request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + }, + "deletePlan": { + "type": "boolean" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:dismissRalph:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "dismissRalph response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "deleted": { + "type": "array", + "items": { + "type": "string" + } + }, + "failed": { + "type": "array", + "items": { + "type": "string" + } + }, + "worktreeCleanup": { + "type": "object", + "properties": { + "removed": { + "type": "array", + "items": { + "type": "string" + } + }, + "kept": { + "type": "string" + } + }, + "required": [ + "removed", + "kept" + ], + "additionalProperties": false + } + }, + "required": [ + "ok", + "deleted", + "failed" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "409 ErrorEnvelope" + ] + }, + "getVapidPublicKey": { + "route": "GET /api/push/vapid-key", + "stable": true, + "auth": "jwt-when-configured", + "response": { + "$id": "wolfpack:control-api:http:getVapidPublicKey:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getVapidPublicKey response", + "type": "object", + "properties": { + "publicKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "publicKey" + ], + "additionalProperties": false + }, + "errors": [] + }, + "subscribePush": { + "route": "POST /api/push/subscribe", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:subscribePush:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "subscribePush request", + "$ref": "#/$defs/PushSubscription" + }, + "response": { + "$id": "wolfpack:control-api:http:subscribePush:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "subscribePush response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "429 ErrorEnvelope" + ] + }, + "unsubscribePush": { + "route": "POST /api/push/unsubscribe", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:unsubscribePush:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "unsubscribePush request", + "type": "object", + "properties": { + "endpoint": { + "type": "string" + } + }, + "required": [ + "endpoint" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:unsubscribePush:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "unsubscribePush response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope" + ] + }, + "sendNotification": { + "route": "POST /api/notify", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:sendNotification:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "sendNotification request", + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:sendNotification:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "sendNotification response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "sent": { + "type": "integer" + }, + "failed": { + "type": "integer" + } + }, + "required": [ + "ok" + ], + "additionalProperties": true + }, + "errors": [ + "400 ErrorEnvelope", + "429 ErrorEnvelope" + ] + } + }, + "websocket": { + "/ws/pty": { + "auth": "jwt-when-configured", + "query": { + "$id": "wolfpack:control-api:ws:pty:query", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty query", + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/SessionName" + }, + "reset": { + "type": "boolean" + }, + "token": { + "type": "string" + } + }, + "required": [ + "session" + ], + "additionalProperties": false + }, + "messages": { + "attach": { + "stable": true, + "direction": "client-to-server", + "schema": { + "$id": "wolfpack:control-api:ws:pty:attach", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty attach", + "type": "object", + "properties": { + "type": { + "const": "attach" + }, + "cols": { + "type": "integer" + }, + "rows": { + "type": "integer" + }, + "skipPrefill": { + "type": "boolean" + }, + "prefillMode": { + "$ref": "#/$defs/PrefillMode" + }, + "takeControl": { + "type": "boolean" + } + }, + "required": [ + "type", + "cols", + "rows" + ], + "additionalProperties": false + } + }, + "layout_stable": { + "stable": true, + "direction": "client-to-server", + "schema": { + "$id": "wolfpack:control-api:ws:pty:layout_stable", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty layout_stable", + "type": "object", + "properties": { + "type": { + "const": "layout_stable" + }, + "cols": { + "type": "integer" + }, + "rows": { + "type": "integer" + } + }, + "required": [ + "type", + "cols", + "rows" + ], + "additionalProperties": false + } + }, + "resize": { + "stable": true, + "direction": "client-to-server", + "schema": { + "$id": "wolfpack:control-api:ws:pty:resize", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty resize", + "type": "object", + "properties": { + "type": { + "const": "resize" + }, + "cols": { + "type": "integer" + }, + "rows": { + "type": "integer" + } + }, + "required": [ + "type", + "cols", + "rows" + ], + "additionalProperties": false + } + }, + "take_control": { + "stable": true, + "direction": "client-to-server", + "schema": { + "$id": "wolfpack:control-api:ws:pty:take_control", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty take_control", + "type": "object", + "properties": { + "type": { + "const": "take_control" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "attach_ack": { + "stable": true, + "direction": "server-to-client", + "schema": { + "$id": "wolfpack:control-api:ws:pty:attach_ack", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty attach_ack", + "type": "object", + "properties": { + "type": { + "const": "attach_ack" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "prefill_done": { + "stable": true, + "direction": "server-to-client", + "schema": { + "$id": "wolfpack:control-api:ws:pty:prefill_done", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty prefill_done", + "type": "object", + "properties": { + "type": { + "const": "prefill_done" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "prefill_viewport": { + "stable": true, + "direction": "server-to-client", + "schema": { + "$id": "wolfpack:control-api:ws:pty:prefill_viewport", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty prefill_viewport", + "type": "object", + "properties": { + "type": { + "const": "prefill_viewport" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "pty_ready": { + "stable": true, + "direction": "server-to-client", + "schema": { + "$id": "wolfpack:control-api:ws:pty:pty_ready", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty pty_ready", + "type": "object", + "properties": { + "type": { + "const": "pty_ready" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "viewer_conflict": { + "stable": true, + "direction": "server-to-client", + "schema": { + "$id": "wolfpack:control-api:ws:pty:viewer_conflict", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty viewer_conflict", + "type": "object", + "properties": { + "type": { + "const": "viewer_conflict" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "control_granted": { + "stable": true, + "direction": "server-to-client", + "schema": { + "$id": "wolfpack:control-api:ws:pty:control_granted", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty control_granted", + "type": "object", + "properties": { + "type": { + "const": "control_granted" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + } + }, + "binaryFrames": { + "client-to-server": "raw PTY input bytes, capped by runtime MAX_PTY_BINARY_BYTES", + "server-to-client": "raw PTY output/prefill bytes" + } + } + }, + "ralph": { + "responseFile": { + "path": ".ralph-response.json", + "schema": { + "$id": "wolfpack:control-api:ralph:response-file", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Ralph structured response file", + "$ref": "#/$defs/RalphIterationResponse" + } + } + }, + "$defs": { + "ErrorEnvelope": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "additionalProperties": true + }, + "SessionName": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "ProjectName": { + "type": "string", + "pattern": "^[a-zA-Z0-9._-]+$" + }, + "PlanFile": { + "type": "string", + "pattern": "^(?:[a-zA-Z0-9._-]+\\.md|\\.plans/[a-zA-Z0-9._-]+\\.md)$" + }, + "BranchName": { + "type": "string", + "pattern": "^[A-Za-z0-9._/-]+$" + }, + "Command": { + "type": "string", + "minLength": 1 + }, + "TriageStatus": { + "enum": [ + "running", + "idle" + ] + }, + "PublicSessionIdentity": { + "type": "object", + "properties": { + "wolfpackSessionId": { + "type": "string" + }, + "wolfpackSessionName": { + "$ref": "#/$defs/SessionName" + }, + "projectPath": { + "type": "string" + }, + "agentKind": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "restoredAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "externalAgent": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "redactedId": { + "type": "string" + }, + "capturedAt": { + "type": "string" + }, + "source": { + "enum": [ + "env", + "broker_env", + "ralph_launch" + ] + } + }, + "required": [ + "provider", + "redactedId", + "capturedAt", + "source" + ], + "additionalProperties": false + } + }, + "required": [ + "wolfpackSessionId", + "wolfpackSessionName", + "projectPath", + "agentKind", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + }, + "PrefillMode": { + "enum": [ + "full", + "viewport", + "none" + ] + }, + "WorktreeMode": { + "enum": [ + false, + "false", + "plan", + "task" + ] + }, + "CmdEntry": { + "type": "object", + "properties": { + "cmd": { + "$ref": "#/$defs/Command" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "cmd", + "enabled" + ], + "additionalProperties": false + }, + "Settings": { + "type": "object", + "properties": { + "agentCmd": { + "type": "string" + }, + "cmds": { + "type": "array", + "items": { + "$ref": "#/$defs/CmdEntry" + } + } + }, + "required": [ + "agentCmd", + "cmds" + ], + "additionalProperties": false + }, + "EffectiveSettings": { + "type": "object", + "properties": { + "agentCmd": { + "type": "string" + }, + "cmds": { + "type": "array", + "items": { + "$ref": "#/$defs/Command" + } + } + }, + "required": [ + "agentCmd", + "cmds" + ], + "additionalProperties": false + }, + "SessionSummary": { + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/SessionName" + }, + "lastLine": { + "type": "string" + }, + "triage": { + "$ref": "#/$defs/TriageStatus" + }, + "identity": { + "$ref": "#/$defs/PublicSessionIdentity" + } + }, + "required": [ + "name", + "lastLine", + "triage" + ], + "additionalProperties": false + }, + "Peer": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "name", + "url" + ], + "additionalProperties": true + }, + "RalphLoop": { + "type": "object", + "properties": { + "project": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "completed": { + "type": "boolean" + }, + "audit": { + "type": "boolean" + }, + "cleanup": { + "type": "boolean" + }, + "cleanupEnabled": { + "type": "boolean" + }, + "auditFixEnabled": { + "type": "boolean" + }, + "iteration": { + "type": "integer" + }, + "totalIterations": { + "type": "integer" + }, + "agent": { + "type": "string" + }, + "planFile": { + "type": "string" + }, + "progressFile": { + "type": "string" + }, + "started": { + "type": "string" + }, + "finished": { + "type": "string" + }, + "lastOutput": { + "type": "string" + }, + "pid": { + "type": "integer" + }, + "tasksDone": { + "type": "integer" + }, + "tasksTotal": { + "type": "integer" + }, + "worktreeMode": { + "type": "string" + }, + "worktreeBranch": { + "type": "string" + }, + "sandbox": { + "type": "string" + }, + "machineName": { + "type": "string" + }, + "machineUrl": { + "type": "string" + } + }, + "required": [ + "project", + "active", + "completed", + "audit", + "cleanup", + "cleanupEnabled", + "auditFixEnabled", + "iteration", + "totalIterations", + "agent", + "planFile", + "progressFile", + "started", + "finished", + "lastOutput", + "pid", + "tasksDone", + "tasksTotal", + "worktreeMode", + "worktreeBranch" + ], + "additionalProperties": true + }, + "TaskCount": { + "type": "object", + "properties": { + "done": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "done", + "total", + "issues" + ], + "additionalProperties": false + }, + "PushSubscription": { + "type": "object", + "properties": { + "endpoint": { + "type": "string" + }, + "keys": { + "type": "object", + "properties": { + "p256dh": { + "type": "string" + }, + "auth": { + "type": "string" + } + }, + "required": [ + "p256dh", + "auth" + ], + "additionalProperties": false + } + }, + "required": [ + "endpoint", + "keys" + ], + "additionalProperties": false + }, + "RalphIterationResponse": { + "type": "object", + "properties": { + "version": { + "const": 1 + }, + "status": { + "enum": [ + "done", + "needs_subtasks" + ] + }, + "prereqs": { + "type": "array", + "items": { + "type": "string" + } + }, + "tests": { + "type": "array", + "items": { + "type": "string" + } + }, + "done": { + "type": "array", + "items": { + "type": "string" + } + }, + "subtasks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "version", + "status", + "prereqs", + "tests", + "done", + "subtasks" + ], + "additionalProperties": false + } + } +} diff --git a/package.json b/package.json index e5bc20e..83d8ae8 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test": "bun test", "test:e2e": "bunx playwright test", "test:e2e:headed": "bunx playwright test --headed", + "gen:schema": "bun run scripts/gen-control-api-schema.ts", "perf:terminal-load": "bun scripts/terminal-load-perf.ts", "typecheck": "bunx tsc --noEmit -p . && bunx tsc --noEmit -p public/", "postinstall": "node bin/install.cjs" diff --git a/scripts/gen-control-api-schema.ts b/scripts/gen-control-api-schema.ts new file mode 100644 index 0000000..b2c9275 --- /dev/null +++ b/scripts/gen-control-api-schema.ts @@ -0,0 +1,131 @@ +#!/usr/bin/env bun +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + CONTROL_API_SCHEMA_ARTIFACT, + buildControlApiSchema, +} from "../src/control-api/schema.ts"; + +const ROOT = join(import.meta.dirname, ".."); +const OUT_FILE = join(ROOT, CONTROL_API_SCHEMA_ARTIFACT); + +type JsonObject = Record; + +const SUPPORTED_SCHEMA_KEYS = new Set([ + "$defs", + "$id", + "$ref", + "$schema", + "additionalProperties", + "anyOf", + "artifactPath", + "auth", + "binaryFrames", + "breakingChanges", + "compatibility", + "const", + "contentMediaType", + "description", + "direction", + "enum", + "errors", + "generatedFrom", + "http", + "items", + "operationId", + "ownership", + "pattern", + "properties", + "query", + "request", + "required", + "response", + "route", + "schema", + "stable", + "title", + "trustBoundaries", + "type", + "version", + "websocket", +]); + +const SUPPORTED_TYPES = new Set(["object", "array", "string", "number", "integer", "boolean", "null"]); + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validateSchemaNode(value: unknown, path: string, errors: string[]): void { + if (Array.isArray(value)) { + value.forEach((item, index) => validateSchemaNode(item, `${path}[${index}]`, errors)); + return; + } + if (!isObject(value)) return; + + for (const [key, child] of Object.entries(value)) { + if (!SUPPORTED_SCHEMA_KEYS.has(key) && !/^[A-Za-z0-9_./ -]+$/.test(key)) { + errors.push(`${path}: unsupported schema key ${JSON.stringify(key)}`); + } + if (key === "type" && (typeof child === "string" || Array.isArray(child))) { + const types = Array.isArray(child) ? child : [child]; + for (const type of types) { + if (typeof type !== "string" || !SUPPORTED_TYPES.has(type)) { + errors.push(`${path}.type: unsupported field type ${JSON.stringify(type)}`); + } + } + } + validateSchemaNode(child, `${path}.${key}`, errors); + } +} + +function assertUnique(names: readonly string[], label: string, errors: string[]): void { + const seen = new Set(); + for (const name of names) { + if (seen.has(name)) errors.push(`duplicate ${label}: ${name}`); + seen.add(name); + } +} + +export function validateControlApiSchemaArtifact(schema: unknown): string[] { + const errors: string[] = []; + if (!isObject(schema)) return ["schema artifact must be an object"]; + + const defs = schema.$defs; + if (!isObject(defs)) errors.push("$defs must be an object"); + else assertUnique(Object.keys(defs), "$defs name", errors); + + const http = schema.http; + if (!isObject(http)) { + errors.push("http must be an object"); + } else { + assertUnique(Object.keys(http), "http operation id", errors); + const routes = Object.values(http) + .map((entry) => isObject(entry) && typeof entry.route === "string" ? entry.route : "") + .filter(Boolean); + assertUnique(routes, "http route", errors); + } + + const wsPty = isObject(schema.websocket) ? schema.websocket["/ws/pty"] : undefined; + const messages = isObject(wsPty) && isObject(wsPty.messages) ? wsPty.messages : undefined; + if (!messages) errors.push("websocket./ws/pty.messages must be an object"); + else assertUnique(Object.keys(messages), "websocket message", errors); + + validateSchemaNode(schema, "$", errors); + return errors; +} + +export function generateControlApiSchemaText(): string { + const schema = buildControlApiSchema(); + const errors = validateControlApiSchemaArtifact(schema); + if (errors.length > 0) { + throw new Error(`control api schema generation failed:\n${errors.map((e) => `- ${e}`).join("\n")}`); + } + return `${JSON.stringify(schema, null, 2)}\n`; +} + +if (import.meta.main) { + mkdirSync(dirname(OUT_FILE), { recursive: true }); + writeFileSync(OUT_FILE, generateControlApiSchemaText()); + console.log(`generated ${CONTROL_API_SCHEMA_ARTIFACT}`); +} diff --git a/src/control-api/schema.ts b/src/control-api/schema.ts new file mode 100644 index 0000000..6502063 --- /dev/null +++ b/src/control-api/schema.ts @@ -0,0 +1,691 @@ +import { RALPH_RESPONSE_VERSION } from "../ralph-response.ts"; + +export const CONTROL_API_SCHEMA_VERSION = "1.0.0"; +export const CONTROL_API_SCHEMA_ARTIFACT = "docs/generated/control-api.schema.json"; + +type JsonSchema = { + readonly [key: string]: unknown; +}; + +type HttpRouteContract = { + readonly operationId: string; + readonly stable: boolean; + readonly auth: "public" | "jwt-when-configured"; + readonly request?: JsonSchema; + readonly response: JsonSchema; + readonly errors: readonly string[]; +}; + +type WebSocketMessageContract = { + readonly stable: boolean; + readonly direction: "client-to-server" | "server-to-client"; + readonly schema: JsonSchema; +}; + +type ControlApiSource = { + readonly schemaVersion: typeof CONTROL_API_SCHEMA_VERSION; + readonly artifactPath: typeof CONTROL_API_SCHEMA_ARTIFACT; + readonly ownership: { + readonly schemaSource: string; + readonly runtimeSource: string; + readonly generatedArtifact: string; + readonly compatibilityDocs: string; + }; + readonly trustBoundaries: readonly string[]; + readonly http: Record; + readonly websocket: { + readonly "/ws/pty": { + readonly auth: "jwt-when-configured"; + readonly query: JsonSchema; + readonly messages: Record; + }; + }; + readonly ralph: { + readonly responseFile: { + readonly path: ".ralph-response.json"; + readonly schema: JsonSchema; + }; + }; + readonly defs: Record; +}; + +const string = (description?: string): JsonSchema => ({ + type: "string", + ...(description ? { description } : {}), +}); + +const number = (description?: string): JsonSchema => ({ + type: "number", + ...(description ? { description } : {}), +}); + +const integer = (description?: string): JsonSchema => ({ + type: "integer", + ...(description ? { description } : {}), +}); + +const boolean = (description?: string): JsonSchema => ({ + type: "boolean", + ...(description ? { description } : {}), +}); + +const arrayOf = (items: JsonSchema): JsonSchema => ({ + type: "array", + items, +}); + +const nullable = (schema: JsonSchema): JsonSchema => ({ + anyOf: [schema, { type: "null" }], +}); + +const ref = (name: string): JsonSchema => ({ $ref: `#/$defs/${name}` }); + +const object = ( + properties: Record, + required: readonly string[] = [], + extra: { readonly description?: string; readonly additionalProperties?: boolean } = {}, +): JsonSchema => ({ + type: "object", + properties, + required, + additionalProperties: extra.additionalProperties ?? false, + ...(extra.description ? { description: extra.description } : {}), +}); + +const ok = object({ ok: boolean() }, ["ok"]); +const error = ref("ErrorEnvelope"); + +export const controlApiSource: ControlApiSource = { + schemaVersion: CONTROL_API_SCHEMA_VERSION, + artifactPath: CONTROL_API_SCHEMA_ARTIFACT, + ownership: { + schemaSource: "src/control-api/schema.ts", + runtimeSource: "src/server/routes.ts", + generatedArtifact: CONTROL_API_SCHEMA_ARTIFACT, + compatibilityDocs: "docs/control-api-schema.md", + }, + trustBoundaries: [ + "schemas publish public client contracts; they do not replace route-side project/session/path validation", + "filesystem containment remains in src/server/validate-project-dir.ts and src/validation.ts", + "broker wire compatibility remains covered by broker codec/protocol tests, not by this schema", + "peer Ralph aggregation still sanitizes remote loop entries before exposing them to clients", + ], + defs: { + ErrorEnvelope: object({ error: string() }, ["error"], { additionalProperties: true }), + SessionName: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" }, + ProjectName: { type: "string", pattern: "^[a-zA-Z0-9._-]+$" }, + PlanFile: { + type: "string", + pattern: "^(?:[a-zA-Z0-9._-]+\\.md|\\.plans/[a-zA-Z0-9._-]+\\.md)$", + }, + BranchName: { type: "string", pattern: "^[A-Za-z0-9._/-]+$" }, + Command: { type: "string", minLength: 1 }, + TriageStatus: { enum: ["running", "idle"] }, + PublicSessionIdentity: object({ + wolfpackSessionId: string(), + wolfpackSessionName: ref("SessionName"), + projectPath: string(), + agentKind: string(), + createdAt: string(), + restoredAt: string(), + updatedAt: string(), + externalAgent: object({ + provider: string(), + redactedId: string(), + capturedAt: string(), + source: { enum: ["env", "broker_env", "ralph_launch"] }, + }, ["provider", "redactedId", "capturedAt", "source"]), + }, ["wolfpackSessionId", "wolfpackSessionName", "projectPath", "agentKind", "createdAt", "updatedAt"]), + PrefillMode: { enum: ["full", "viewport", "none"] }, + WorktreeMode: { enum: [false, "false", "plan", "task"] }, + CmdEntry: object({ + cmd: ref("Command"), + enabled: boolean(), + }, ["cmd", "enabled"]), + Settings: object({ + agentCmd: string(), + cmds: arrayOf(ref("CmdEntry")), + }, ["agentCmd", "cmds"]), + EffectiveSettings: object({ + agentCmd: string(), + cmds: arrayOf(ref("Command")), + }, ["agentCmd", "cmds"]), + SessionSummary: object({ + name: ref("SessionName"), + lastLine: string(), + triage: ref("TriageStatus"), + identity: ref("PublicSessionIdentity"), + }, ["name", "lastLine", "triage"]), + Peer: object({ + name: string(), + url: string(), + }, ["name", "url"], { additionalProperties: true }), + RalphLoop: object({ + project: string(), + active: boolean(), + completed: boolean(), + audit: boolean(), + cleanup: boolean(), + cleanupEnabled: boolean(), + auditFixEnabled: boolean(), + iteration: integer(), + totalIterations: integer(), + agent: string(), + planFile: string(), + progressFile: string(), + started: string(), + finished: string(), + lastOutput: string(), + pid: integer(), + tasksDone: integer(), + tasksTotal: integer(), + worktreeMode: string(), + worktreeBranch: string(), + sandbox: string(), + machineName: string(), + machineUrl: string(), + }, [ + "project", + "active", + "completed", + "audit", + "cleanup", + "cleanupEnabled", + "auditFixEnabled", + "iteration", + "totalIterations", + "agent", + "planFile", + "progressFile", + "started", + "finished", + "lastOutput", + "pid", + "tasksDone", + "tasksTotal", + "worktreeMode", + "worktreeBranch", + ], { additionalProperties: true }), + TaskCount: object({ + done: integer(), + total: integer(), + issues: arrayOf(string()), + }, ["done", "total", "issues"]), + PushSubscription: object({ + endpoint: string(), + keys: object({ + p256dh: string(), + auth: string(), + }, ["p256dh", "auth"]), + }, ["endpoint", "keys"]), + RalphIterationResponse: object({ + version: { const: RALPH_RESPONSE_VERSION }, + status: { enum: ["done", "needs_subtasks"] }, + prereqs: arrayOf(string()), + tests: arrayOf(string()), + done: arrayOf(string()), + subtasks: arrayOf(string()), + }, ["version", "status", "prereqs", "tests", "done", "subtasks"]), + }, + http: { + "GET /api/info": { + operationId: "getInfo", + stable: true, + auth: "public", + response: object({ + name: string(), + version: string(), + }, ["name", "version"]), + errors: [], + }, + "GET /api/sessions": { + operationId: "listSessions", + stable: true, + auth: "jwt-when-configured", + response: object({ sessions: arrayOf(ref("SessionSummary")) }, ["sessions"]), + errors: [], + }, + "GET /api/projects": { + operationId: "listProjects", + stable: true, + auth: "jwt-when-configured", + response: object({ projects: arrayOf(ref("ProjectName")) }, ["projects"]), + errors: [], + }, + "GET /api/next-session-name": { + operationId: "nextSessionName", + stable: true, + auth: "jwt-when-configured", + request: object({ project: ref("ProjectName") }, ["project"]), + response: object({ name: ref("SessionName") }, ["name"]), + errors: ["400 ErrorEnvelope"], + }, + "POST /api/create": { + operationId: "createSession", + stable: true, + auth: "jwt-when-configured", + request: { + ...object({ + project: ref("ProjectName"), + newProject: ref("ProjectName"), + cmd: ref("Command"), + sessionName: ref("SessionName"), + }), + anyOf: [ + object({}, ["project"], { additionalProperties: true }), + object({}, ["newProject"], { additionalProperties: true }), + ], + }, + response: object({ + ok: boolean(), + session: ref("SessionName"), + }, ["ok", "session"]), + errors: ["400 ErrorEnvelope", "404 ErrorEnvelope", "409 ErrorEnvelope"], + }, + "GET /api/settings": { + operationId: "getSettings", + stable: true, + auth: "jwt-when-configured", + response: object({ + settings: ref("Settings"), + effective: ref("EffectiveSettings"), + }, ["settings", "effective"]), + errors: [], + }, + "POST /api/settings": { + operationId: "updateSettings", + stable: true, + auth: "jwt-when-configured", + request: object({ + agentCmd: ref("Command"), + addCmd: ref("Command"), + removeCmd: ref("Command"), + setCmdEnabled: object({ + cmd: ref("Command"), + enabled: boolean(), + }, ["cmd", "enabled"]), + }), + response: object({ + ok: boolean(), + settings: ref("Settings"), + effective: ref("EffectiveSettings"), + }, ["ok", "settings", "effective"]), + errors: ["400 ErrorEnvelope"], + }, + "GET /api/backend": { + operationId: "getBackendStatus", + stable: true, + auth: "jwt-when-configured", + response: object({ + brokerAvailable: boolean(), + counts: object({ + tmux: integer(), + broker: integer(), + }, ["tmux", "broker"]), + }, ["brokerAvailable", "counts"]), + errors: [], + }, + "POST /api/kill": { + operationId: "killSession", + stable: true, + auth: "jwt-when-configured", + request: object({ session: ref("SessionName") }, ["session"]), + response: ok, + errors: ["400 ErrorEnvelope", "404 ErrorEnvelope"], + }, + "POST /api/resize": { + operationId: "resizeSession", + stable: true, + auth: "jwt-when-configured", + request: object({ + session: ref("SessionName"), + cols: integer(), + rows: integer(), + }, ["session", "cols", "rows"]), + response: ok, + errors: ["400 ErrorEnvelope", "404 ErrorEnvelope"], + }, + "GET /api/discover": { + operationId: "discoverPeers", + stable: true, + auth: "jwt-when-configured", + response: object({ + peers: arrayOf(ref("Peer")), + error: string(), + }, ["peers"]), + errors: [], + }, + "GET /api/poll": { + operationId: "capturePane", + stable: false, + auth: "jwt-when-configured", + request: object({ session: ref("SessionName") }, ["session"]), + response: object({ pane: string() }, ["pane"]), + errors: ["400 ErrorEnvelope", "404 ErrorEnvelope"], + }, + "GET /api/copy-text": { + operationId: "copyText", + stable: true, + auth: "jwt-when-configured", + request: object({ session: ref("SessionName") }, ["session"]), + response: { type: "string", contentMediaType: "text/plain" }, + errors: ["400 ErrorEnvelope", "404 ErrorEnvelope"], + }, + "GET /api/git-status": { + operationId: "getGitStatus", + stable: true, + auth: "jwt-when-configured", + request: object({ session: ref("ProjectName") }, ["session"]), + response: object({ status: string() }, ["status"]), + errors: ["400 ErrorEnvelope", "404 ErrorEnvelope", "500 ErrorEnvelope"], + }, + "GET /api/ralph": { + operationId: "listRalphLoops", + stable: true, + auth: "jwt-when-configured", + request: object({ aggregate: boolean() }), + response: object({ loops: arrayOf(ref("RalphLoop")) }, ["loops"]), + errors: [], + }, + "GET /api/ralph/branches": { + operationId: "listRalphBranches", + stable: true, + auth: "jwt-when-configured", + request: object({ project: ref("ProjectName") }, ["project"]), + response: object({ + branches: arrayOf(ref("BranchName")), + current: string(), + }, ["branches", "current"]), + errors: ["400 ErrorEnvelope", "404 ErrorEnvelope", "500 ErrorEnvelope"], + }, + "GET /api/ralph/plans": { + operationId: "listRalphPlans", + stable: true, + auth: "jwt-when-configured", + request: object({ project: ref("ProjectName") }, ["project"]), + response: object({ plans: arrayOf(ref("PlanFile")) }, ["plans"]), + errors: ["400 ErrorEnvelope", "404 ErrorEnvelope"], + }, + "GET /api/ralph/log": { + operationId: "getRalphLog", + stable: true, + auth: "jwt-when-configured", + request: object({ project: ref("ProjectName") }, ["project"]), + response: object({ + log: string(), + totalLines: integer(), + }, ["log", "totalLines"]), + errors: ["400 ErrorEnvelope", "404 ErrorEnvelope", "500 ErrorEnvelope"], + }, + "POST /api/ralph/start": { + operationId: "startRalph", + stable: true, + auth: "jwt-when-configured", + request: object({ + project: ref("ProjectName"), + iterations: integer(), + planFile: ref("PlanFile"), + agent: string(), + newBranch: ref("BranchName"), + sourceBranch: ref("BranchName"), + format: boolean(), + cleanup: boolean(), + auditFix: boolean(), + worktree: ref("WorktreeMode"), + worktreeBranch: ref("BranchName"), + worktreeBase: ref("BranchName"), + sandbox: boolean(), + }, ["project"]), + response: object({ + ok: boolean(), + pid: integer(), + branch: string(), + worktree: { enum: ["plan", "task"] }, + }, ["ok", "pid"]), + errors: ["400 ErrorEnvelope", "404 ErrorEnvelope", "409 ErrorEnvelope", "500 ErrorEnvelope"], + }, + "GET /api/ralph/task-count": { + operationId: "getRalphTaskCount", + stable: true, + auth: "jwt-when-configured", + request: object({ + project: ref("ProjectName"), + plan: ref("PlanFile"), + }, ["project", "plan"]), + response: ref("TaskCount"), + errors: ["400 ErrorEnvelope", "404 ErrorEnvelope"], + }, + "POST /api/ralph/cancel": { + operationId: "cancelRalph", + stable: true, + auth: "jwt-when-configured", + request: object({ project: ref("ProjectName") }, ["project"]), + response: object({ + ok: boolean(), + killed: integer(), + }, ["ok", "killed"]), + errors: ["400 ErrorEnvelope", "404 ErrorEnvelope", "500 ErrorEnvelope"], + }, + "POST /api/ralph/dismiss": { + operationId: "dismissRalph", + stable: true, + auth: "jwt-when-configured", + request: object({ + project: ref("ProjectName"), + deletePlan: boolean(), + }, ["project"]), + response: object({ + ok: boolean(), + deleted: arrayOf(string()), + failed: arrayOf(string()), + worktreeCleanup: object({ + removed: arrayOf(string()), + kept: string(), + }, ["removed", "kept"]), + }, ["ok", "deleted", "failed"]), + errors: ["400 ErrorEnvelope", "404 ErrorEnvelope", "409 ErrorEnvelope"], + }, + "GET /api/push/vapid-key": { + operationId: "getVapidPublicKey", + stable: true, + auth: "jwt-when-configured", + response: object({ publicKey: nullable(string()) }, ["publicKey"]), + errors: [], + }, + "POST /api/push/subscribe": { + operationId: "subscribePush", + stable: true, + auth: "jwt-when-configured", + request: ref("PushSubscription"), + response: ok, + errors: ["400 ErrorEnvelope", "429 ErrorEnvelope"], + }, + "POST /api/push/unsubscribe": { + operationId: "unsubscribePush", + stable: true, + auth: "jwt-when-configured", + request: object({ endpoint: string() }, ["endpoint"]), + response: ok, + errors: ["400 ErrorEnvelope"], + }, + "POST /api/notify": { + operationId: "sendNotification", + stable: true, + auth: "jwt-when-configured", + request: object({ message: string() }, ["message"]), + response: object({ + ok: boolean(), + sent: integer(), + failed: integer(), + }, ["ok"], { additionalProperties: true }), + errors: ["400 ErrorEnvelope", "429 ErrorEnvelope"], + }, + }, + websocket: { + "/ws/pty": { + auth: "jwt-when-configured", + query: object({ + session: ref("SessionName"), + reset: boolean(), + token: string(), + }, ["session"]), + messages: { + attach: { + stable: true, + direction: "client-to-server", + schema: object({ + type: { const: "attach" }, + cols: integer(), + rows: integer(), + skipPrefill: boolean(), + prefillMode: ref("PrefillMode"), + takeControl: boolean(), + }, ["type", "cols", "rows"]), + }, + layout_stable: { + stable: true, + direction: "client-to-server", + schema: object({ + type: { const: "layout_stable" }, + cols: integer(), + rows: integer(), + }, ["type", "cols", "rows"]), + }, + resize: { + stable: true, + direction: "client-to-server", + schema: object({ + type: { const: "resize" }, + cols: integer(), + rows: integer(), + }, ["type", "cols", "rows"]), + }, + take_control: { + stable: true, + direction: "client-to-server", + schema: object({ type: { const: "take_control" } }, ["type"]), + }, + attach_ack: { + stable: true, + direction: "server-to-client", + schema: object({ type: { const: "attach_ack" } }, ["type"]), + }, + prefill_done: { + stable: true, + direction: "server-to-client", + schema: object({ type: { const: "prefill_done" } }, ["type"]), + }, + prefill_viewport: { + stable: true, + direction: "server-to-client", + schema: object({ type: { const: "prefill_viewport" } }, ["type"]), + }, + pty_ready: { + stable: true, + direction: "server-to-client", + schema: object({ type: { const: "pty_ready" } }, ["type"]), + }, + viewer_conflict: { + stable: true, + direction: "server-to-client", + schema: object({ type: { const: "viewer_conflict" } }, ["type"]), + }, + control_granted: { + stable: true, + direction: "server-to-client", + schema: object({ type: { const: "control_granted" } }, ["type"]), + }, + }, + }, + }, + ralph: { + responseFile: { + path: ".ralph-response.json", + schema: ref("RalphIterationResponse"), + }, + }, +}; + +function schemaWithId(schema: JsonSchema, id: string, title: string): JsonSchema { + return { + $id: id, + $schema: "https://json-schema.org/draft/2020-12/schema", + title, + ...schema, + }; +} + +function httpOperationSchemas(source: ControlApiSource): Record { + const operations: Record = {}; + for (const [route, contract] of Object.entries(source.http)) { + operations[contract.operationId] = { + route, + stable: contract.stable, + auth: contract.auth, + request: contract.request + ? schemaWithId(contract.request, `wolfpack:control-api:http:${contract.operationId}:request`, `${contract.operationId} request`) + : undefined, + response: schemaWithId(contract.response, `wolfpack:control-api:http:${contract.operationId}:response`, `${contract.operationId} response`), + errors: contract.errors, + }; + } + return operations; +} + +function wsMessageSchemas(source: ControlApiSource): Record { + const messages: Record = {}; + for (const [name, contract] of Object.entries(source.websocket["/ws/pty"].messages)) { + messages[name] = { + stable: contract.stable, + direction: contract.direction, + schema: schemaWithId(contract.schema, `wolfpack:control-api:ws:pty:${name}`, `/ws/pty ${name}`), + }; + } + return messages; +} + +export function buildControlApiSchema(): JsonSchema { + return { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "wolfpack:control-api", + title: "Wolfpack public control API and event stream schema", + type: "object", + version: controlApiSource.schemaVersion, + generatedFrom: controlApiSource.ownership.schemaSource, + artifactPath: controlApiSource.artifactPath, + compatibility: { + additiveChanges: [ + "new optional object properties", + "new stable operations", + "new server-to-client event message types", + ], + breakingChanges: [ + "removing or renaming stable operations, fields, or message types", + "making optional fields required", + "changing auth expectations", + "tightening enum/pattern constraints for stable fields", + ], + }, + ownership: controlApiSource.ownership, + trustBoundaries: controlApiSource.trustBoundaries, + http: httpOperationSchemas(controlApiSource), + websocket: { + "/ws/pty": { + auth: controlApiSource.websocket["/ws/pty"].auth, + query: schemaWithId(controlApiSource.websocket["/ws/pty"].query, "wolfpack:control-api:ws:pty:query", "/ws/pty query"), + messages: wsMessageSchemas(controlApiSource), + binaryFrames: { + "client-to-server": "raw PTY input bytes, capped by runtime MAX_PTY_BINARY_BYTES", + "server-to-client": "raw PTY output/prefill bytes", + }, + }, + }, + ralph: { + responseFile: { + path: controlApiSource.ralph.responseFile.path, + schema: schemaWithId(controlApiSource.ralph.responseFile.schema, "wolfpack:control-api:ralph:response-file", "Ralph structured response file"), + }, + }, + $defs: controlApiSource.defs, + }; +} diff --git a/tests/integration/control-api-schema-contract.test.ts b/tests/integration/control-api-schema-contract.test.ts new file mode 100644 index 0000000..a8eba9d --- /dev/null +++ b/tests/integration/control-api-schema-contract.test.ts @@ -0,0 +1,150 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { mkdirSync, readFileSync, rmSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { CONTROL_API_SCHEMA_ARTIFACT } from "../../src/control-api/schema.ts"; + +process.env.WOLFPACK_TEST = "1"; +delete process.env.WOLFPACK_JWT_SECRET; + +const rawTmpDir = join(tmpdir(), `wolfpack-schema-contract-${process.pid}`); +mkdirSync(rawTmpDir, { recursive: true }); +const TEST_DEV_DIR = realpathSync(rawTmpDir); +process.env.WOLFPACK_DEV_DIR = TEST_DEV_DIR; +process.env.WOLFPACK_SETTINGS_PATH = join(TEST_DEV_DIR, "bridge-settings.json"); + +const { __resetJwtAuthConfig, __setDevDir } = await import("../../src/test-hooks.ts"); +const { __setTestBackend } = await import("../../src/server/backend.ts"); +const { MockBackend } = await import("../../src/server/mock-backend.ts"); +__resetJwtAuthConfig(); +__setDevDir(TEST_DEV_DIR); + +const mockBackend = new MockBackend({ + sessions: ["wolf-1"], + capturePane: async () => "ready\n", +}); +__setTestBackend(mockBackend); + +const { createServerInstance } = await import("../../src/server/index.ts") as any; +const { server } = createServerInstance(); + +let base = ""; +const artifact = JSON.parse(readFileSync(CONTROL_API_SCHEMA_ARTIFACT, "utf-8")) as JsonObject; + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function resolveRef(schema: JsonObject, root: JsonObject): JsonObject { + const ref = schema.$ref; + if (typeof ref !== "string") return schema; + const prefix = "#/$defs/"; + if (!ref.startsWith(prefix)) throw new Error(`unsupported ref ${ref}`); + const defs = root.$defs; + const name = ref.slice(prefix.length); + if (!isObject(defs) || !isObject(defs[name])) throw new Error(`missing ref ${ref}`); + return defs[name] as JsonObject; +} + +function validate(schema: unknown, value: unknown, root: JsonObject, path = "$."): string[] { + if (!isObject(schema)) return []; + const resolved = resolveRef(schema, root); + if (resolved !== schema) return validate(resolved, value, root, path); + + if (Array.isArray(resolved.anyOf)) { + const variants = resolved.anyOf.map((candidate) => validate(candidate, value, root, path)); + if (!variants.some((errors) => errors.length === 0)) { + return [`${path} did not match anyOf: ${variants.map((errors) => errors.join(", ")).join(" | ")}`]; + } + } + + if ("const" in resolved && value !== resolved.const) return [`${path} expected const ${JSON.stringify(resolved.const)}`]; + if (Array.isArray(resolved.enum) && !resolved.enum.some((candidate) => candidate === value)) { + return [`${path} expected one of ${JSON.stringify(resolved.enum)}`]; + } + + if (typeof resolved.type === "string") { + if (resolved.type === "object" && !isObject(value)) return [`${path} expected object`]; + if (resolved.type === "array" && !Array.isArray(value)) return [`${path} expected array`]; + if (resolved.type === "string" && typeof value !== "string") return [`${path} expected string`]; + if (resolved.type === "number" && typeof value !== "number") return [`${path} expected number`]; + if (resolved.type === "integer" && !Number.isInteger(value)) return [`${path} expected integer`]; + if (resolved.type === "boolean" && typeof value !== "boolean") return [`${path} expected boolean`]; + if (resolved.type === "null" && value !== null) return [`${path} expected null`]; + } + + if (typeof value === "string" && typeof resolved.pattern === "string" && !(new RegExp(resolved.pattern).test(value))) { + return [`${path} failed pattern ${resolved.pattern}`]; + } + + if (Array.isArray(value) && isObject(resolved.items)) { + return value.flatMap((item, index) => validate(resolved.items, item, root, `${path}[${index}]`)); + } + + if (isObject(value)) { + const required = Array.isArray(resolved.required) ? resolved.required : []; + const errors: string[] = []; + for (const key of required) { + if (typeof key === "string" && !(key in value)) errors.push(`${path}.${key} is required`); + } + if (isObject(resolved.properties)) { + for (const [key, child] of Object.entries(resolved.properties)) { + if (key in value) errors.push(...validate(child, value[key], root, `${path}.${key}`)); + } + if (resolved.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!(key in resolved.properties)) errors.push(`${path}.${key} is not allowed`); + } + } + } + return errors; + } + + return []; +} + +function httpResponse(operationId: string): JsonObject { + const http = artifact.http; + const operation = isObject(http) ? http[operationId] : undefined; + if (!isObject(operation) || !isObject(operation.response)) throw new Error(`missing operation ${operationId}`); + return operation.response; +} + +async function getJson(path: string): Promise { + const res = await fetch(`${base}${path}`); + expect(res.status).toBe(200); + return await res.json(); +} + +beforeAll(async () => { + await new Promise((resolve) => { + (server as Server).listen(0, "127.0.0.1", () => { + const port = ((server as Server).address() as AddressInfo).port; + base = `http://127.0.0.1:${port}`; + resolve(); + }); + }); +}); + +afterAll(() => { + (server as Server).close(); + rmSync(TEST_DEV_DIR, { recursive: true, force: true }); +}); + +describe("control api generated schema against runtime responses", () => { + test("validates representative real HTTP responses", async () => { + const samples: Array<[string, unknown]> = [ + ["getInfo", await getJson("/api/info")], + ["listSessions", await getJson("/api/sessions")], + ["getSettings", await getJson("/api/settings")], + ]; + + for (const [operationId, payload] of samples) { + expect(validate(httpResponse(operationId), payload, artifact), operationId).toEqual([]); + } + }); +}); diff --git a/tests/unit/__snapshots__/control-api-schema.test.ts.snap b/tests/unit/__snapshots__/control-api-schema.test.ts.snap new file mode 100644 index 0000000..e686e75 --- /dev/null +++ b/tests/unit/__snapshots__/control-api-schema.test.ts.snap @@ -0,0 +1,1800 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`control api schema generation schema source emits a stable snapshot 1`] = ` +"{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "wolfpack:control-api", + "title": "Wolfpack public control API and event stream schema", + "type": "object", + "version": "1.0.0", + "generatedFrom": "src/control-api/schema.ts", + "artifactPath": "docs/generated/control-api.schema.json", + "compatibility": { + "additiveChanges": [ + "new optional object properties", + "new stable operations", + "new server-to-client event message types" + ], + "breakingChanges": [ + "removing or renaming stable operations, fields, or message types", + "making optional fields required", + "changing auth expectations", + "tightening enum/pattern constraints for stable fields" + ] + }, + "ownership": { + "schemaSource": "src/control-api/schema.ts", + "runtimeSource": "src/server/routes.ts", + "generatedArtifact": "docs/generated/control-api.schema.json", + "compatibilityDocs": "docs/control-api-schema.md" + }, + "trustBoundaries": [ + "schemas publish public client contracts; they do not replace route-side project/session/path validation", + "filesystem containment remains in src/server/validate-project-dir.ts and src/validation.ts", + "broker wire compatibility remains covered by broker codec/protocol tests, not by this schema", + "peer Ralph aggregation still sanitizes remote loop entries before exposing them to clients" + ], + "http": { + "getInfo": { + "route": "GET /api/info", + "stable": true, + "auth": "public", + "response": { + "$id": "wolfpack:control-api:http:getInfo:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getInfo response", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "additionalProperties": false + }, + "errors": [] + }, + "listSessions": { + "route": "GET /api/sessions", + "stable": true, + "auth": "jwt-when-configured", + "response": { + "$id": "wolfpack:control-api:http:listSessions:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listSessions response", + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionSummary" + } + } + }, + "required": [ + "sessions" + ], + "additionalProperties": false + }, + "errors": [] + }, + "listProjects": { + "route": "GET /api/projects", + "stable": true, + "auth": "jwt-when-configured", + "response": { + "$id": "wolfpack:control-api:http:listProjects:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listProjects response", + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "$ref": "#/$defs/ProjectName" + } + } + }, + "required": [ + "projects" + ], + "additionalProperties": false + }, + "errors": [] + }, + "nextSessionName": { + "route": "GET /api/next-session-name", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:nextSessionName:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "nextSessionName request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:nextSessionName:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "nextSessionName response", + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/SessionName" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope" + ] + }, + "createSession": { + "route": "POST /api/create", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:createSession:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "createSession request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + }, + "newProject": { + "$ref": "#/$defs/ProjectName" + }, + "cmd": { + "$ref": "#/$defs/Command" + }, + "sessionName": { + "$ref": "#/$defs/SessionName" + } + }, + "required": [], + "additionalProperties": false, + "anyOf": [ + { + "type": "object", + "properties": {}, + "required": [ + "project" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": {}, + "required": [ + "newProject" + ], + "additionalProperties": true + } + ] + }, + "response": { + "$id": "wolfpack:control-api:http:createSession:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "createSession response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "session": { + "$ref": "#/$defs/SessionName" + } + }, + "required": [ + "ok", + "session" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "409 ErrorEnvelope" + ] + }, + "getSettings": { + "route": "GET /api/settings", + "stable": true, + "auth": "jwt-when-configured", + "response": { + "$id": "wolfpack:control-api:http:getSettings:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getSettings response", + "type": "object", + "properties": { + "settings": { + "$ref": "#/$defs/Settings" + }, + "effective": { + "$ref": "#/$defs/EffectiveSettings" + } + }, + "required": [ + "settings", + "effective" + ], + "additionalProperties": false + }, + "errors": [] + }, + "updateSettings": { + "route": "POST /api/settings", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:updateSettings:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "updateSettings request", + "type": "object", + "properties": { + "agentCmd": { + "$ref": "#/$defs/Command" + }, + "addCmd": { + "$ref": "#/$defs/Command" + }, + "removeCmd": { + "$ref": "#/$defs/Command" + }, + "setCmdEnabled": { + "type": "object", + "properties": { + "cmd": { + "$ref": "#/$defs/Command" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "cmd", + "enabled" + ], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:updateSettings:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "updateSettings response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "settings": { + "$ref": "#/$defs/Settings" + }, + "effective": { + "$ref": "#/$defs/EffectiveSettings" + } + }, + "required": [ + "ok", + "settings", + "effective" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope" + ] + }, + "getBackendStatus": { + "route": "GET /api/backend", + "stable": true, + "auth": "jwt-when-configured", + "response": { + "$id": "wolfpack:control-api:http:getBackendStatus:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getBackendStatus response", + "type": "object", + "properties": { + "brokerAvailable": { + "type": "boolean" + }, + "counts": { + "type": "object", + "properties": { + "tmux": { + "type": "integer" + }, + "broker": { + "type": "integer" + } + }, + "required": [ + "tmux", + "broker" + ], + "additionalProperties": false + } + }, + "required": [ + "brokerAvailable", + "counts" + ], + "additionalProperties": false + }, + "errors": [] + }, + "killSession": { + "route": "POST /api/kill", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:killSession:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "killSession request", + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/SessionName" + } + }, + "required": [ + "session" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:killSession:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "killSession response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope" + ] + }, + "resizeSession": { + "route": "POST /api/resize", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:resizeSession:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "resizeSession request", + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/SessionName" + }, + "cols": { + "type": "integer" + }, + "rows": { + "type": "integer" + } + }, + "required": [ + "session", + "cols", + "rows" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:resizeSession:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "resizeSession response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope" + ] + }, + "discoverPeers": { + "route": "GET /api/discover", + "stable": true, + "auth": "jwt-when-configured", + "response": { + "$id": "wolfpack:control-api:http:discoverPeers:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "discoverPeers response", + "type": "object", + "properties": { + "peers": { + "type": "array", + "items": { + "$ref": "#/$defs/Peer" + } + }, + "error": { + "type": "string" + } + }, + "required": [ + "peers" + ], + "additionalProperties": false + }, + "errors": [] + }, + "capturePane": { + "route": "GET /api/poll", + "stable": false, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:capturePane:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "capturePane request", + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/SessionName" + } + }, + "required": [ + "session" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:capturePane:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "capturePane response", + "type": "object", + "properties": { + "pane": { + "type": "string" + } + }, + "required": [ + "pane" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope" + ] + }, + "copyText": { + "route": "GET /api/copy-text", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:copyText:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "copyText request", + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/SessionName" + } + }, + "required": [ + "session" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:copyText:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "copyText response", + "type": "string", + "contentMediaType": "text/plain" + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope" + ] + }, + "getGitStatus": { + "route": "GET /api/git-status", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:getGitStatus:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getGitStatus request", + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/ProjectName" + } + }, + "required": [ + "session" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:getGitStatus:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getGitStatus response", + "type": "object", + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "500 ErrorEnvelope" + ] + }, + "listRalphLoops": { + "route": "GET /api/ralph", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:listRalphLoops:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listRalphLoops request", + "type": "object", + "properties": { + "aggregate": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:listRalphLoops:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listRalphLoops response", + "type": "object", + "properties": { + "loops": { + "type": "array", + "items": { + "$ref": "#/$defs/RalphLoop" + } + } + }, + "required": [ + "loops" + ], + "additionalProperties": false + }, + "errors": [] + }, + "listRalphBranches": { + "route": "GET /api/ralph/branches", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:listRalphBranches:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listRalphBranches request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:listRalphBranches:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listRalphBranches response", + "type": "object", + "properties": { + "branches": { + "type": "array", + "items": { + "$ref": "#/$defs/BranchName" + } + }, + "current": { + "type": "string" + } + }, + "required": [ + "branches", + "current" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "500 ErrorEnvelope" + ] + }, + "listRalphPlans": { + "route": "GET /api/ralph/plans", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:listRalphPlans:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listRalphPlans request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:listRalphPlans:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "listRalphPlans response", + "type": "object", + "properties": { + "plans": { + "type": "array", + "items": { + "$ref": "#/$defs/PlanFile" + } + } + }, + "required": [ + "plans" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope" + ] + }, + "getRalphLog": { + "route": "GET /api/ralph/log", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:getRalphLog:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getRalphLog request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:getRalphLog:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getRalphLog response", + "type": "object", + "properties": { + "log": { + "type": "string" + }, + "totalLines": { + "type": "integer" + } + }, + "required": [ + "log", + "totalLines" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "500 ErrorEnvelope" + ] + }, + "startRalph": { + "route": "POST /api/ralph/start", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:startRalph:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "startRalph request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + }, + "iterations": { + "type": "integer" + }, + "planFile": { + "$ref": "#/$defs/PlanFile" + }, + "agent": { + "type": "string" + }, + "newBranch": { + "$ref": "#/$defs/BranchName" + }, + "sourceBranch": { + "$ref": "#/$defs/BranchName" + }, + "format": { + "type": "boolean" + }, + "cleanup": { + "type": "boolean" + }, + "auditFix": { + "type": "boolean" + }, + "worktree": { + "$ref": "#/$defs/WorktreeMode" + }, + "worktreeBranch": { + "$ref": "#/$defs/BranchName" + }, + "worktreeBase": { + "$ref": "#/$defs/BranchName" + }, + "sandbox": { + "type": "boolean" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:startRalph:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "startRalph response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "pid": { + "type": "integer" + }, + "branch": { + "type": "string" + }, + "worktree": { + "enum": [ + "plan", + "task" + ] + } + }, + "required": [ + "ok", + "pid" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "409 ErrorEnvelope", + "500 ErrorEnvelope" + ] + }, + "getRalphTaskCount": { + "route": "GET /api/ralph/task-count", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:getRalphTaskCount:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getRalphTaskCount request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + }, + "plan": { + "$ref": "#/$defs/PlanFile" + } + }, + "required": [ + "project", + "plan" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:getRalphTaskCount:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getRalphTaskCount response", + "$ref": "#/$defs/TaskCount" + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope" + ] + }, + "cancelRalph": { + "route": "POST /api/ralph/cancel", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:cancelRalph:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "cancelRalph request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:cancelRalph:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "cancelRalph response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "killed": { + "type": "integer" + } + }, + "required": [ + "ok", + "killed" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "500 ErrorEnvelope" + ] + }, + "dismissRalph": { + "route": "POST /api/ralph/dismiss", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:dismissRalph:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "dismissRalph request", + "type": "object", + "properties": { + "project": { + "$ref": "#/$defs/ProjectName" + }, + "deletePlan": { + "type": "boolean" + } + }, + "required": [ + "project" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:dismissRalph:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "dismissRalph response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "deleted": { + "type": "array", + "items": { + "type": "string" + } + }, + "failed": { + "type": "array", + "items": { + "type": "string" + } + }, + "worktreeCleanup": { + "type": "object", + "properties": { + "removed": { + "type": "array", + "items": { + "type": "string" + } + }, + "kept": { + "type": "string" + } + }, + "required": [ + "removed", + "kept" + ], + "additionalProperties": false + } + }, + "required": [ + "ok", + "deleted", + "failed" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "404 ErrorEnvelope", + "409 ErrorEnvelope" + ] + }, + "getVapidPublicKey": { + "route": "GET /api/push/vapid-key", + "stable": true, + "auth": "jwt-when-configured", + "response": { + "$id": "wolfpack:control-api:http:getVapidPublicKey:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "getVapidPublicKey response", + "type": "object", + "properties": { + "publicKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "publicKey" + ], + "additionalProperties": false + }, + "errors": [] + }, + "subscribePush": { + "route": "POST /api/push/subscribe", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:subscribePush:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "subscribePush request", + "$ref": "#/$defs/PushSubscription" + }, + "response": { + "$id": "wolfpack:control-api:http:subscribePush:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "subscribePush response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope", + "429 ErrorEnvelope" + ] + }, + "unsubscribePush": { + "route": "POST /api/push/unsubscribe", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:unsubscribePush:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "unsubscribePush request", + "type": "object", + "properties": { + "endpoint": { + "type": "string" + } + }, + "required": [ + "endpoint" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:unsubscribePush:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "unsubscribePush response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "additionalProperties": false + }, + "errors": [ + "400 ErrorEnvelope" + ] + }, + "sendNotification": { + "route": "POST /api/notify", + "stable": true, + "auth": "jwt-when-configured", + "request": { + "$id": "wolfpack:control-api:http:sendNotification:request", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "sendNotification request", + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": false + }, + "response": { + "$id": "wolfpack:control-api:http:sendNotification:response", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "sendNotification response", + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "sent": { + "type": "integer" + }, + "failed": { + "type": "integer" + } + }, + "required": [ + "ok" + ], + "additionalProperties": true + }, + "errors": [ + "400 ErrorEnvelope", + "429 ErrorEnvelope" + ] + } + }, + "websocket": { + "/ws/pty": { + "auth": "jwt-when-configured", + "query": { + "$id": "wolfpack:control-api:ws:pty:query", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty query", + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/SessionName" + }, + "reset": { + "type": "boolean" + }, + "token": { + "type": "string" + } + }, + "required": [ + "session" + ], + "additionalProperties": false + }, + "messages": { + "attach": { + "stable": true, + "direction": "client-to-server", + "schema": { + "$id": "wolfpack:control-api:ws:pty:attach", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty attach", + "type": "object", + "properties": { + "type": { + "const": "attach" + }, + "cols": { + "type": "integer" + }, + "rows": { + "type": "integer" + }, + "skipPrefill": { + "type": "boolean" + }, + "prefillMode": { + "$ref": "#/$defs/PrefillMode" + }, + "takeControl": { + "type": "boolean" + } + }, + "required": [ + "type", + "cols", + "rows" + ], + "additionalProperties": false + } + }, + "layout_stable": { + "stable": true, + "direction": "client-to-server", + "schema": { + "$id": "wolfpack:control-api:ws:pty:layout_stable", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty layout_stable", + "type": "object", + "properties": { + "type": { + "const": "layout_stable" + }, + "cols": { + "type": "integer" + }, + "rows": { + "type": "integer" + } + }, + "required": [ + "type", + "cols", + "rows" + ], + "additionalProperties": false + } + }, + "resize": { + "stable": true, + "direction": "client-to-server", + "schema": { + "$id": "wolfpack:control-api:ws:pty:resize", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty resize", + "type": "object", + "properties": { + "type": { + "const": "resize" + }, + "cols": { + "type": "integer" + }, + "rows": { + "type": "integer" + } + }, + "required": [ + "type", + "cols", + "rows" + ], + "additionalProperties": false + } + }, + "take_control": { + "stable": true, + "direction": "client-to-server", + "schema": { + "$id": "wolfpack:control-api:ws:pty:take_control", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty take_control", + "type": "object", + "properties": { + "type": { + "const": "take_control" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "attach_ack": { + "stable": true, + "direction": "server-to-client", + "schema": { + "$id": "wolfpack:control-api:ws:pty:attach_ack", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty attach_ack", + "type": "object", + "properties": { + "type": { + "const": "attach_ack" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "prefill_done": { + "stable": true, + "direction": "server-to-client", + "schema": { + "$id": "wolfpack:control-api:ws:pty:prefill_done", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty prefill_done", + "type": "object", + "properties": { + "type": { + "const": "prefill_done" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "prefill_viewport": { + "stable": true, + "direction": "server-to-client", + "schema": { + "$id": "wolfpack:control-api:ws:pty:prefill_viewport", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty prefill_viewport", + "type": "object", + "properties": { + "type": { + "const": "prefill_viewport" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "pty_ready": { + "stable": true, + "direction": "server-to-client", + "schema": { + "$id": "wolfpack:control-api:ws:pty:pty_ready", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty pty_ready", + "type": "object", + "properties": { + "type": { + "const": "pty_ready" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "viewer_conflict": { + "stable": true, + "direction": "server-to-client", + "schema": { + "$id": "wolfpack:control-api:ws:pty:viewer_conflict", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty viewer_conflict", + "type": "object", + "properties": { + "type": { + "const": "viewer_conflict" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "control_granted": { + "stable": true, + "direction": "server-to-client", + "schema": { + "$id": "wolfpack:control-api:ws:pty:control_granted", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "/ws/pty control_granted", + "type": "object", + "properties": { + "type": { + "const": "control_granted" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + } + }, + "binaryFrames": { + "client-to-server": "raw PTY input bytes, capped by runtime MAX_PTY_BINARY_BYTES", + "server-to-client": "raw PTY output/prefill bytes" + } + } + }, + "ralph": { + "responseFile": { + "path": ".ralph-response.json", + "schema": { + "$id": "wolfpack:control-api:ralph:response-file", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Ralph structured response file", + "$ref": "#/$defs/RalphIterationResponse" + } + } + }, + "$defs": { + "ErrorEnvelope": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "additionalProperties": true + }, + "SessionName": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "ProjectName": { + "type": "string", + "pattern": "^[a-zA-Z0-9._-]+$" + }, + "PlanFile": { + "type": "string", + "pattern": "^(?:[a-zA-Z0-9._-]+\\\\.md|\\\\.plans/[a-zA-Z0-9._-]+\\\\.md)$" + }, + "BranchName": { + "type": "string", + "pattern": "^[A-Za-z0-9._/-]+$" + }, + "Command": { + "type": "string", + "minLength": 1 + }, + "TriageStatus": { + "enum": [ + "running", + "idle" + ] + }, + "PublicSessionIdentity": { + "type": "object", + "properties": { + "wolfpackSessionId": { + "type": "string" + }, + "wolfpackSessionName": { + "$ref": "#/$defs/SessionName" + }, + "projectPath": { + "type": "string" + }, + "agentKind": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "restoredAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "externalAgent": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "redactedId": { + "type": "string" + }, + "capturedAt": { + "type": "string" + }, + "source": { + "enum": [ + "env", + "broker_env", + "ralph_launch" + ] + } + }, + "required": [ + "provider", + "redactedId", + "capturedAt", + "source" + ], + "additionalProperties": false + } + }, + "required": [ + "wolfpackSessionId", + "wolfpackSessionName", + "projectPath", + "agentKind", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + }, + "PrefillMode": { + "enum": [ + "full", + "viewport", + "none" + ] + }, + "WorktreeMode": { + "enum": [ + false, + "false", + "plan", + "task" + ] + }, + "CmdEntry": { + "type": "object", + "properties": { + "cmd": { + "$ref": "#/$defs/Command" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "cmd", + "enabled" + ], + "additionalProperties": false + }, + "Settings": { + "type": "object", + "properties": { + "agentCmd": { + "type": "string" + }, + "cmds": { + "type": "array", + "items": { + "$ref": "#/$defs/CmdEntry" + } + } + }, + "required": [ + "agentCmd", + "cmds" + ], + "additionalProperties": false + }, + "EffectiveSettings": { + "type": "object", + "properties": { + "agentCmd": { + "type": "string" + }, + "cmds": { + "type": "array", + "items": { + "$ref": "#/$defs/Command" + } + } + }, + "required": [ + "agentCmd", + "cmds" + ], + "additionalProperties": false + }, + "SessionSummary": { + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/SessionName" + }, + "lastLine": { + "type": "string" + }, + "triage": { + "$ref": "#/$defs/TriageStatus" + }, + "identity": { + "$ref": "#/$defs/PublicSessionIdentity" + } + }, + "required": [ + "name", + "lastLine", + "triage" + ], + "additionalProperties": false + }, + "Peer": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "name", + "url" + ], + "additionalProperties": true + }, + "RalphLoop": { + "type": "object", + "properties": { + "project": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "completed": { + "type": "boolean" + }, + "audit": { + "type": "boolean" + }, + "cleanup": { + "type": "boolean" + }, + "cleanupEnabled": { + "type": "boolean" + }, + "auditFixEnabled": { + "type": "boolean" + }, + "iteration": { + "type": "integer" + }, + "totalIterations": { + "type": "integer" + }, + "agent": { + "type": "string" + }, + "planFile": { + "type": "string" + }, + "progressFile": { + "type": "string" + }, + "started": { + "type": "string" + }, + "finished": { + "type": "string" + }, + "lastOutput": { + "type": "string" + }, + "pid": { + "type": "integer" + }, + "tasksDone": { + "type": "integer" + }, + "tasksTotal": { + "type": "integer" + }, + "worktreeMode": { + "type": "string" + }, + "worktreeBranch": { + "type": "string" + }, + "sandbox": { + "type": "string" + }, + "machineName": { + "type": "string" + }, + "machineUrl": { + "type": "string" + } + }, + "required": [ + "project", + "active", + "completed", + "audit", + "cleanup", + "cleanupEnabled", + "auditFixEnabled", + "iteration", + "totalIterations", + "agent", + "planFile", + "progressFile", + "started", + "finished", + "lastOutput", + "pid", + "tasksDone", + "tasksTotal", + "worktreeMode", + "worktreeBranch" + ], + "additionalProperties": true + }, + "TaskCount": { + "type": "object", + "properties": { + "done": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "done", + "total", + "issues" + ], + "additionalProperties": false + }, + "PushSubscription": { + "type": "object", + "properties": { + "endpoint": { + "type": "string" + }, + "keys": { + "type": "object", + "properties": { + "p256dh": { + "type": "string" + }, + "auth": { + "type": "string" + } + }, + "required": [ + "p256dh", + "auth" + ], + "additionalProperties": false + } + }, + "required": [ + "endpoint", + "keys" + ], + "additionalProperties": false + }, + "RalphIterationResponse": { + "type": "object", + "properties": { + "version": { + "const": 1 + }, + "status": { + "enum": [ + "done", + "needs_subtasks" + ] + }, + "prereqs": { + "type": "array", + "items": { + "type": "string" + } + }, + "tests": { + "type": "array", + "items": { + "type": "string" + } + }, + "done": { + "type": "array", + "items": { + "type": "string" + } + }, + "subtasks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "version", + "status", + "prereqs", + "tests", + "done", + "subtasks" + ], + "additionalProperties": false + } + } +}" +`; diff --git a/tests/unit/control-api-schema.test.ts b/tests/unit/control-api-schema.test.ts new file mode 100644 index 0000000..b673c69 --- /dev/null +++ b/tests/unit/control-api-schema.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { + CONTROL_API_SCHEMA_ARTIFACT, + buildControlApiSchema, +} from "../../src/control-api/schema.ts"; +import { + generateControlApiSchemaText, + validateControlApiSchemaArtifact, +} from "../../scripts/gen-control-api-schema.ts"; + +type JsonObject = Record; + +const artifact = JSON.parse(readFileSync(CONTROL_API_SCHEMA_ARTIFACT, "utf-8")) as JsonObject; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function resolveRef(schema: JsonObject, root: JsonObject): JsonObject { + const ref = schema.$ref; + if (typeof ref !== "string") return schema; + const prefix = "#/$defs/"; + if (!ref.startsWith(prefix)) throw new Error(`unsupported ref ${ref}`); + const name = ref.slice(prefix.length); + const defs = root.$defs; + if (!isObject(defs) || !isObject(defs[name])) throw new Error(`missing ref ${ref}`); + return defs[name] as JsonObject; +} + +function validate(schema: unknown, value: unknown, root: JsonObject, path = "$"): string[] { + if (!isObject(schema)) return []; + const resolved = resolveRef(schema, root); + if (resolved !== schema) return validate(resolved, value, root, path); + + if (Array.isArray(resolved.anyOf)) { + const variants = resolved.anyOf.map((candidate) => validate(candidate, value, root, path)); + return variants.some((errors) => errors.length === 0) + ? [] + : [`${path} did not match anyOf: ${variants.map((errors) => errors.join(", ")).join(" | ")}`]; + } + + if ("const" in resolved && value !== resolved.const) { + return [`${path} expected const ${JSON.stringify(resolved.const)}`]; + } + + if (Array.isArray(resolved.enum) && !resolved.enum.some((candidate) => candidate === value)) { + return [`${path} expected one of ${JSON.stringify(resolved.enum)}`]; + } + + if (typeof resolved.type === "string") { + if (resolved.type === "object" && !isObject(value)) return [`${path} expected object`]; + if (resolved.type === "array" && !Array.isArray(value)) return [`${path} expected array`]; + if (resolved.type === "string" && typeof value !== "string") return [`${path} expected string`]; + if (resolved.type === "number" && typeof value !== "number") return [`${path} expected number`]; + if (resolved.type === "integer" && (!Number.isInteger(value))) return [`${path} expected integer`]; + if (resolved.type === "boolean" && typeof value !== "boolean") return [`${path} expected boolean`]; + if (resolved.type === "null" && value !== null) return [`${path} expected null`]; + } + + if (typeof value === "string" && typeof resolved.pattern === "string" && !(new RegExp(resolved.pattern).test(value))) { + return [`${path} failed pattern ${resolved.pattern}`]; + } + + if (Array.isArray(value) && isObject(resolved.items)) { + return value.flatMap((item, index) => validate(resolved.items, item, root, `${path}[${index}]`)); + } + + if (isObject(value) && isObject(resolved.properties)) { + const required = Array.isArray(resolved.required) ? resolved.required : []; + const errors: string[] = []; + for (const key of required) { + if (typeof key === "string" && !(key in value)) errors.push(`${path}.${key} is required`); + } + for (const [key, child] of Object.entries(resolved.properties)) { + if (key in value) errors.push(...validate(child, value[key], root, `${path}.${key}`)); + } + if (resolved.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!(key in resolved.properties)) errors.push(`${path}.${key} is not allowed`); + } + } + return errors; + } + + return []; +} + +function httpOperation(operationId: string): JsonObject { + const http = artifact.http; + if (!isObject(http) || !isObject(http[operationId])) throw new Error(`missing operation ${operationId}`); + return http[operationId] as JsonObject; +} + +function httpRequest(operationId: string): JsonObject { + const operation = httpOperation(operationId); + if (!isObject(operation.request)) throw new Error(`missing request schema for ${operationId}`); + return operation.request; +} + +function httpResponse(operationId: string): JsonObject { + const operation = httpOperation(operationId); + if (!isObject(operation.response)) throw new Error(`missing response schema for ${operationId}`); + return operation.response; +} + +function wsMessage(name: string): JsonObject { + const websocket = artifact.websocket; + const pty = isObject(websocket) ? websocket["/ws/pty"] : undefined; + const messages = isObject(pty) ? pty.messages : undefined; + const message = isObject(messages) ? messages[name] : undefined; + if (!isObject(message) || !isObject(message.schema)) throw new Error(`missing ws message ${name}`); + return message.schema; +} + +function ralphResponseFile(): JsonObject { + const ralph = artifact.ralph; + const responseFile = isObject(ralph) ? ralph.responseFile : undefined; + if (!isObject(responseFile) || !isObject(responseFile.schema)) throw new Error("missing ralph response file schema"); + return responseFile.schema; +} + +describe("control api schema generation", () => { + test("generated artifact is current", () => { + expect(readFileSync(CONTROL_API_SCHEMA_ARTIFACT, "utf-8")).toBe(generateControlApiSchemaText()); + }); + + test("schema source emits a stable snapshot", () => { + expect(JSON.stringify(buildControlApiSchema(), null, 2)).toMatchSnapshot(); + }); + + test("artifact has no duplicate contracts or unsupported field types", () => { + expect(validateControlApiSchemaArtifact(artifact)).toEqual([]); + }); +}); + +describe("control api schema docs", () => { + test("documents runtime routes as authoritative validation boundary", () => { + const docs = readFileSync("docs/control-api-schema.md", "utf-8"); + + expect(docs).toContain("Runtime routes remain authoritative"); + }); +}); + +describe("control api schema compatibility samples", () => { + test("create-session request requires project or newProject", () => { + const request = httpRequest("createSession"); + + expect(validate(request, { cmd: "shell" }, artifact)).not.toEqual([]); + expect(validate(request, { project: "wolfpack", cmd: "shell" }, artifact)).toEqual([]); + expect(validate(request, { newProject: "fresh-app" }, artifact)).toEqual([]); + }); + + test("representative http responses satisfy generated schemas", () => { + const samples: Array<[string, unknown]> = [ + ["getInfo", { name: "devbox", version: "1.6.6" }], + ["listSessions", { sessions: [{ name: "wolf-1", lastLine: "ready", triage: "idle" }] }], + ["getSettings", { + settings: { agentCmd: "shell", cmds: [{ cmd: "shell", enabled: true }] }, + effective: { agentCmd: "shell", cmds: ["shell"] }, + }], + ["listRalphLoops", { + loops: [{ + project: "wolfpack", + active: true, + completed: false, + audit: false, + cleanup: false, + cleanupEnabled: true, + auditFixEnabled: false, + iteration: 1, + totalIterations: 5, + agent: "codex", + planFile: ".plans/c-generated-control-api-schema.md", + progressFile: "progress.txt", + started: "2026-07-11T00:00:00Z", + finished: "", + lastOutput: "running", + pid: 12345, + tasksDone: 0, + tasksTotal: 4, + worktreeMode: "task", + worktreeBranch: "feature/example", + sandbox: "true", + machineName: "devbox", + machineUrl: "", + }], + }], + ]; + + for (const [operationId, payload] of samples) { + expect(validate(httpResponse(operationId), payload, artifact), operationId).toEqual([]); + } + }); + + test("representative websocket control messages satisfy generated schemas", () => { + const samples: Array<[string, unknown]> = [ + ["attach", { type: "attach", cols: 120, rows: 40, prefillMode: "full" }], + ["resize", { type: "resize", cols: 100, rows: 30 }], + ["layout_stable", { type: "layout_stable", cols: 100, rows: 30 }], + ["take_control", { type: "take_control" }], + ["attach_ack", { type: "attach_ack" }], + ["prefill_done", { type: "prefill_done" }], + ["prefill_viewport", { type: "prefill_viewport" }], + ["pty_ready", { type: "pty_ready" }], + ["viewer_conflict", { type: "viewer_conflict" }], + ["control_granted", { type: "control_granted" }], + ]; + + for (const [messageName, payload] of samples) { + expect(validate(wsMessage(messageName), payload, artifact), messageName).toEqual([]); + } + }); + + test("representative ralph response file satisfies generated schema", () => { + expect(validate(ralphResponseFile(), { + version: 1, + status: "needs_subtasks", + prereqs: ["branch exists"], + tests: ["bun test tests/unit/control-api-schema.test.ts"], + done: ["schema source defined"], + subtasks: ["wire generated schema into docs"], + }, artifact)).toEqual([]); + }); +});