diff --git a/Makefile b/Makefile index 0fa8d00d..38b1c522 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ # use this Makefile; CI calls docker compose / go test directly so the # Makefile can evolve without breaking the build. -.PHONY: help dev up down logs build test e2e-peer-visibility +.PHONY: help dev up down logs build test e2e-peer-visibility openapi-spec openapi-spec-check help: ## Show this help. @grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}' @@ -36,3 +36,23 @@ test: ## Run Go unit tests in workspace-server/. # env contract (CLAUDE_CODE_OAUTH_TOKEN / E2E_MINIMAX_API_KEY / etc). e2e-peer-visibility: ## Run the LOCAL peer-visibility MCP gate vs the running stack (needs `make up` first). bash tests/e2e/test_peer_visibility_mcp_local.sh + +# ─── OpenAPI spec generation (RFC #1706, Phase 1) ───────────────────── +# Regenerate workspace-server/docs/openapi/swagger.{yaml,json} from +# swaggo annotations on the gin handlers. Commit the output. CI runs +# `make openapi-spec-check` to assert no drift between annotations and +# the committed file — if a PR changes a handler but forgets to +# regenerate, CI fails with a diff. +openapi-spec: ## Regenerate OpenAPI spec from workspace-server handler annotations. + @command -v swag >/dev/null 2>&1 || go install github.com/swaggo/swag/cmd/swag@v1.16.4 + cd workspace-server && swag init \ + --generalInfo cmd/server/main.go \ + --output docs/openapi \ + --outputTypes yaml,json \ + --dir . \ + --parseDependency=false \ + --parseInternal=true + +openapi-spec-check: openapi-spec ## CI gate — fail if openapi-spec produces a diff vs the committed file. + @git diff --exit-code -- workspace-server/docs/openapi/ \ + || (echo "openapi-spec is stale — run 'make openapi-spec' and commit the result" && exit 1) diff --git a/workspace-server/cmd/server/main.go b/workspace-server/cmd/server/main.go index d93f1325..87deeebf 100644 --- a/workspace-server/cmd/server/main.go +++ b/workspace-server/cmd/server/main.go @@ -1,3 +1,26 @@ +// Package main runs the per-tenant workspace-server. +// +// @title Molecule AI Workspace Server API +// @version 1.0 +// @description The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706. +// @host api.moleculesai.app +// @BasePath / +// @schemes https +// +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow. +// +// @securityDefinitions.apikey OrgSlugAuth +// @in header +// @name X-Molecule-Org-Slug +// @description Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code. +// +// @securityDefinitions.apikey OrgIdAuth +// @in header +// @name X-Molecule-Org-Id +// @description Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth. package main import ( diff --git a/workspace-server/docs/openapi/swagger.json b/workspace-server/docs/openapi/swagger.json new file mode 100644 index 00000000..a335eaaa --- /dev/null +++ b/workspace-server/docs/openapi/swagger.json @@ -0,0 +1,521 @@ +{ + "schemes": [ + "https" + ], + "swagger": "2.0", + "info": { + "description": "The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706.", + "title": "Molecule AI Workspace Server API", + "contact": {}, + "version": "1.0" + }, + "host": "api.moleculesai.app", + "basePath": "/", + "paths": { + "/workspaces/{id}/schedules": { + "get": { + "security": [ + { + "BearerAuth": [], + "OrgSlugAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "schedules" + ], + "summary": "List schedules for a workspace", + "parameters": [ + { + "type": "string", + "description": "Workspace ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.ScheduleResponse" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [], + "OrgSlugAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "schedules" + ], + "summary": "Create a schedule", + "parameters": [ + { + "type": "string", + "description": "Workspace ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Schedule fields", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateScheduleRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.CreateScheduleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{id}/schedules/{scheduleId}": { + "delete": { + "security": [ + { + "BearerAuth": [], + "OrgSlugAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "schedules" + ], + "summary": "Delete a schedule", + "parameters": [ + { + "type": "string", + "description": "Workspace ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Schedule ID", + "name": "scheduleId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.StatusResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [], + "OrgSlugAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "schedules" + ], + "summary": "Update a schedule", + "parameters": [ + { + "type": "string", + "description": "Workspace ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Schedule ID", + "name": "scheduleId", + "in": "path", + "required": true + }, + { + "description": "Partial schedule fields (only provided keys are updated)", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateScheduleRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ScheduleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{id}/schedules/{scheduleId}/history": { + "get": { + "security": [ + { + "BearerAuth": [], + "OrgSlugAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "schedules" + ], + "summary": "Get past runs of a schedule", + "parameters": [ + { + "type": "string", + "description": "Workspace ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Schedule ID", + "name": "scheduleId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.HistoryEntry" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{id}/schedules/{scheduleId}/run": { + "post": { + "security": [ + { + "BearerAuth": [], + "OrgSlugAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "schedules" + ], + "summary": "Fire a schedule manually", + "parameters": [ + { + "type": "string", + "description": "Workspace ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Schedule ID", + "name": "scheduleId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.RunNowResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "handlers.CreateScheduleRequest": { + "type": "object", + "required": [ + "cron_expr", + "prompt" + ], + "properties": { + "cron_expr": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "timezone": { + "type": "string" + } + } + }, + "handlers.CreateScheduleResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "next_run_at": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "handlers.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "handlers.HistoryEntry": { + "type": "object", + "properties": { + "duration_ms": { + "type": "integer" + }, + "error_detail": { + "type": "string" + }, + "request": { + "type": "object" + }, + "status": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + } + }, + "handlers.RunNowResponse": { + "type": "object", + "properties": { + "prompt": { + "type": "string" + }, + "status": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + } + }, + "handlers.ScheduleResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "cron_expr": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "last_error": { + "type": "string" + }, + "last_run_at": { + "type": "string" + }, + "last_status": { + "type": "string" + }, + "name": { + "type": "string" + }, + "next_run_at": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "run_count": { + "type": "integer" + }, + "source": { + "description": "'template' (seeded by org/import) | 'runtime' (created via Canvas/API). Issue #24.", + "type": "string" + }, + "timezone": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + } + }, + "handlers.StatusResponse": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + }, + "handlers.UpdateScheduleRequest": { + "type": "object", + "properties": { + "cron_expr": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "timezone": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + "OrgIdAuth": { + "description": "Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.", + "type": "apiKey", + "name": "X-Molecule-Org-Id", + "in": "header" + }, + "OrgSlugAuth": { + "description": "Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. \"agents-team\") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code.", + "type": "apiKey", + "name": "X-Molecule-Org-Slug", + "in": "header" + } + } +} \ No newline at end of file diff --git a/workspace-server/docs/openapi/swagger.yaml b/workspace-server/docs/openapi/swagger.yaml new file mode 100644 index 00000000..9413a278 --- /dev/null +++ b/workspace-server/docs/openapi/swagger.yaml @@ -0,0 +1,349 @@ +basePath: / +definitions: + handlers.CreateScheduleRequest: + properties: + cron_expr: + type: string + enabled: + type: boolean + name: + type: string + prompt: + type: string + timezone: + type: string + required: + - cron_expr + - prompt + type: object + handlers.CreateScheduleResponse: + properties: + id: + type: string + next_run_at: + type: string + status: + type: string + type: object + handlers.ErrorResponse: + properties: + error: + type: string + type: object + handlers.HistoryEntry: + properties: + duration_ms: + type: integer + error_detail: + type: string + request: + type: object + status: + type: string + timestamp: + type: string + type: object + handlers.RunNowResponse: + properties: + prompt: + type: string + status: + type: string + workspace_id: + type: string + type: object + handlers.ScheduleResponse: + properties: + created_at: + type: string + cron_expr: + type: string + enabled: + type: boolean + id: + type: string + last_error: + type: string + last_run_at: + type: string + last_status: + type: string + name: + type: string + next_run_at: + type: string + prompt: + type: string + run_count: + type: integer + source: + description: '''template'' (seeded by org/import) | ''runtime'' (created via + Canvas/API). Issue #24.' + type: string + timezone: + type: string + updated_at: + type: string + workspace_id: + type: string + type: object + handlers.StatusResponse: + properties: + status: + type: string + type: object + handlers.UpdateScheduleRequest: + properties: + cron_expr: + type: string + enabled: + type: boolean + name: + type: string + prompt: + type: string + timezone: + type: string + type: object +host: api.moleculesai.app +info: + contact: {} + description: 'The per-tenant workspace-server HTTP API. Single source of truth for + workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, + molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by + clients generated from this spec — see RFC #1706.' + title: Molecule AI Workspace Server API + version: "1.0" +paths: + /workspaces/{id}/schedules: + get: + parameters: + - description: Workspace ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/handlers.ScheduleResponse' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + OrgSlugAuth: [] + summary: List schedules for a workspace + tags: + - schedules + post: + consumes: + - application/json + parameters: + - description: Workspace ID + in: path + name: id + required: true + type: string + - description: Schedule fields + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.CreateScheduleRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handlers.CreateScheduleResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + OrgSlugAuth: [] + summary: Create a schedule + tags: + - schedules + /workspaces/{id}/schedules/{scheduleId}: + delete: + parameters: + - description: Workspace ID + in: path + name: id + required: true + type: string + - description: Schedule ID + in: path + name: scheduleId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.StatusResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + OrgSlugAuth: [] + summary: Delete a schedule + tags: + - schedules + patch: + consumes: + - application/json + parameters: + - description: Workspace ID + in: path + name: id + required: true + type: string + - description: Schedule ID + in: path + name: scheduleId + required: true + type: string + - description: Partial schedule fields (only provided keys are updated) + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.UpdateScheduleRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ScheduleResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + OrgSlugAuth: [] + summary: Update a schedule + tags: + - schedules + /workspaces/{id}/schedules/{scheduleId}/history: + get: + parameters: + - description: Workspace ID + in: path + name: id + required: true + type: string + - description: Schedule ID + in: path + name: scheduleId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/handlers.HistoryEntry' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + OrgSlugAuth: [] + summary: Get past runs of a schedule + tags: + - schedules + /workspaces/{id}/schedules/{scheduleId}/run: + post: + parameters: + - description: Workspace ID + in: path + name: id + required: true + type: string + - description: Schedule ID + in: path + name: scheduleId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.RunNowResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + OrgSlugAuth: [] + summary: Fire a schedule manually + tags: + - schedules +schemes: +- https +securityDefinitions: + BearerAuth: + description: Bearer token issued by Gitea (org-admin or persona PAT) or by the + platform's signup/SSO flow. + in: header + name: Authorization + type: apiKey + OrgIdAuth: + description: Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. + At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth. + in: header + name: X-Molecule-Org-Id + type: apiKey + OrgSlugAuth: + description: Tenant routing header — required on every /workspaces/{id}/* request + so the platform edge can route to the correct per-tenant workspace-server. Either + X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id + (UUID) must be sent; slug is preferred for client code. + in: header + name: X-Molecule-Org-Slug + type: apiKey +swagger: "2.0" diff --git a/workspace-server/internal/handlers/schedules.go b/workspace-server/internal/handlers/schedules.go index 3139a217..e114942d 100644 --- a/workspace-server/internal/handlers/schedules.go +++ b/workspace-server/internal/handlers/schedules.go @@ -15,13 +15,46 @@ import ( "github.com/Molecule-AI/molecule-monorepo/platform/internal/scheduler" ) +// ErrorResponse is returned for 4xx/5xx errors. (OpenAPI doc shape — used by swaggo.) +type ErrorResponse struct { + Error string `json:"error"` +} + +// StatusResponse is returned by mutating endpoints that only echo a status verb. +type StatusResponse struct { + Status string `json:"status"` +} + +// CreateScheduleResponse is returned by POST /workspaces/{id}/schedules. +type CreateScheduleResponse struct { + ID string `json:"id"` + Status string `json:"status"` + NextRunAt time.Time `json:"next_run_at"` +} + +// RunNowResponse is returned by POST /workspaces/{id}/schedules/{scheduleId}/run. +type RunNowResponse struct { + Status string `json:"status"` + WorkspaceID string `json:"workspace_id"` + Prompt string `json:"prompt"` +} + +// HistoryEntry is one row of /workspaces/{id}/schedules/{scheduleId}/history. +type HistoryEntry struct { + Timestamp time.Time `json:"timestamp"` + DurationMs *int `json:"duration_ms"` + Status *string `json:"status"` + ErrorDetail string `json:"error_detail"` + Request json.RawMessage `json:"request" swaggertype:"object"` +} + type ScheduleHandler struct{} func NewScheduleHandler() *ScheduleHandler { return &ScheduleHandler{} } -type scheduleResponse struct { +type ScheduleResponse struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` Name string `json:"name"` @@ -40,6 +73,15 @@ type scheduleResponse struct { } // List returns all schedules for a workspace. +// +// @Summary List schedules for a workspace +// @Tags schedules +// @Produce json +// @Param id path string true "Workspace ID" +// @Success 200 {array} ScheduleResponse +// @Failure 500 {object} ErrorResponse +// @Router /workspaces/{id}/schedules [get] +// @Security BearerAuth && OrgSlugAuth func (h *ScheduleHandler) List(c *gin.Context) { workspaceID := c.Param("id") ctx := c.Request.Context() @@ -58,9 +100,9 @@ func (h *ScheduleHandler) List(c *gin.Context) { } defer rows.Close() - schedules := make([]scheduleResponse, 0) + schedules := make([]ScheduleResponse, 0) for rows.Next() { - var s scheduleResponse + var s ScheduleResponse if err := rows.Scan( &s.ID, &s.WorkspaceID, &s.Name, &s.CronExpr, &s.Timezone, &s.Prompt, &s.Enabled, &s.LastRunAt, &s.NextRunAt, &s.RunCount, @@ -78,7 +120,7 @@ func (h *ScheduleHandler) List(c *gin.Context) { c.JSON(http.StatusOK, schedules) } -type createScheduleRequest struct { +type CreateScheduleRequest struct { Name string `json:"name"` CronExpr string `json:"cron_expr" binding:"required"` Timezone string `json:"timezone"` @@ -87,11 +129,23 @@ type createScheduleRequest struct { } // Create adds a new schedule for a workspace. +// +// @Summary Create a schedule +// @Tags schedules +// @Accept json +// @Produce json +// @Param id path string true "Workspace ID" +// @Param body body CreateScheduleRequest true "Schedule fields" +// @Success 201 {object} CreateScheduleResponse +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /workspaces/{id}/schedules [post] +// @Security BearerAuth && OrgSlugAuth func (h *ScheduleHandler) Create(c *gin.Context) { workspaceID := c.Param("id") ctx := c.Request.Context() - var body createScheduleRequest + var body CreateScheduleRequest if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "cron_expr and prompt are required"}) return @@ -145,7 +199,7 @@ func (h *ScheduleHandler) Create(c *gin.Context) { }) } -type updateScheduleRequest struct { +type UpdateScheduleRequest struct { Name *string `json:"name"` CronExpr *string `json:"cron_expr"` Timezone *string `json:"timezone"` @@ -155,12 +209,26 @@ type updateScheduleRequest struct { // Update modifies a schedule. Uses a fixed UPDATE with COALESCE so only // provided fields are changed — no dynamic SQL construction. +// +// @Summary Update a schedule +// @Tags schedules +// @Accept json +// @Produce json +// @Param id path string true "Workspace ID" +// @Param scheduleId path string true "Schedule ID" +// @Param body body UpdateScheduleRequest true "Partial schedule fields (only provided keys are updated)" +// @Success 200 {object} ScheduleResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /workspaces/{id}/schedules/{scheduleId} [patch] +// @Security BearerAuth && OrgSlugAuth func (h *ScheduleHandler) Update(c *gin.Context) { scheduleID := c.Param("scheduleId") workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR ctx := c.Request.Context() - var body updateScheduleRequest + var body UpdateScheduleRequest if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) return @@ -230,6 +298,17 @@ func (h *ScheduleHandler) Update(c *gin.Context) { } // Delete removes a schedule. +// +// @Summary Delete a schedule +// @Tags schedules +// @Produce json +// @Param id path string true "Workspace ID" +// @Param scheduleId path string true "Schedule ID" +// @Success 200 {object} StatusResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /workspaces/{id}/schedules/{scheduleId} [delete] +// @Security BearerAuth && OrgSlugAuth func (h *ScheduleHandler) Delete(c *gin.Context) { scheduleID := c.Param("scheduleId") workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR @@ -252,6 +331,17 @@ func (h *ScheduleHandler) Delete(c *gin.Context) { } // RunNow manually fires a schedule immediately. +// +// @Summary Fire a schedule manually +// @Tags schedules +// @Produce json +// @Param id path string true "Workspace ID" +// @Param scheduleId path string true "Schedule ID" +// @Success 200 {object} RunNowResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /workspaces/{id}/schedules/{scheduleId}/run [post] +// @Security BearerAuth && OrgSlugAuth func (h *ScheduleHandler) RunNow(c *gin.Context) { scheduleID := c.Param("scheduleId") workspaceID := c.Param("id") @@ -282,6 +372,16 @@ func (h *ScheduleHandler) RunNow(c *gin.Context) { } // History returns recent runs for a schedule from activity_logs. +// +// @Summary Get past runs of a schedule +// @Tags schedules +// @Produce json +// @Param id path string true "Workspace ID" +// @Param scheduleId path string true "Schedule ID" +// @Success 200 {array} HistoryEntry +// @Failure 500 {object} ErrorResponse +// @Router /workspaces/{id}/schedules/{scheduleId}/history [get] +// @Security BearerAuth && OrgSlugAuth func (h *ScheduleHandler) History(c *gin.Context) { scheduleID := c.Param("scheduleId") workspaceID := c.Param("id") @@ -307,17 +407,9 @@ func (h *ScheduleHandler) History(c *gin.Context) { } defer rows.Close() - type historyEntry struct { - Timestamp time.Time `json:"timestamp"` - DurationMs *int `json:"duration_ms"` - Status *string `json:"status"` - ErrorDetail string `json:"error_detail"` - Request json.RawMessage `json:"request"` - } - - entries := make([]historyEntry, 0) + entries := make([]HistoryEntry, 0) for rows.Next() { - var e historyEntry + var e HistoryEntry var reqStr string if err := rows.Scan(&e.Timestamp, &e.DurationMs, &e.Status, &e.ErrorDetail, &reqStr); err != nil { continue @@ -329,11 +421,11 @@ func (h *ScheduleHandler) History(c *gin.Context) { c.JSON(http.StatusOK, entries) } -// scheduleHealthResponse is the read-only health view of a schedule. +// ScheduleHealthResponse is the read-only health view of a schedule. // It deliberately omits prompt and cron_expr so sensitive task content is // never exposed to peer workspaces — only execution-state fields needed to // detect silent cron failures are returned (issue #249). -type scheduleHealthResponse struct { +type ScheduleHealthResponse struct { ID string `json:"id"` Name string `json:"name"` Enabled bool `json:"enabled"` @@ -402,9 +494,9 @@ func (h *ScheduleHandler) Health(c *gin.Context) { } defer rows.Close() - schedules := make([]scheduleHealthResponse, 0) + schedules := make([]ScheduleHealthResponse, 0) for rows.Next() { - var s scheduleHealthResponse + var s ScheduleHealthResponse if err := rows.Scan( &s.ID, &s.Name, &s.Enabled, &s.LastRunAt, &s.NextRunAt, &s.RunCount, &s.LastStatus, &s.LastError, diff --git a/workspace-server/internal/handlers/schedules_test.go b/workspace-server/internal/handlers/schedules_test.go index 335a6121..bddcd452 100644 --- a/workspace-server/internal/handlers/schedules_test.go +++ b/workspace-server/internal/handlers/schedules_test.go @@ -234,7 +234,7 @@ func TestScheduleHealth_SelfCall_Allowed(t *testing.T) { t.Fatalf("expected 200 for self-call, got %d: %s", w.Code, w.Body.String()) } - var resp []scheduleHealthResponse + var resp []ScheduleHealthResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } @@ -284,7 +284,7 @@ func TestScheduleHealth_CanCommunicatePeer_LegacyNoToken(t *testing.T) { t.Fatalf("expected 200 for peer with no tokens, got %d: %s", w.Code, w.Body.String()) } - var resp []scheduleHealthResponse + var resp []ScheduleHealthResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to parse response: %v", err) }