diff --git a/apps/contracts/openapi.json b/apps/contracts/openapi.json index 9ccc00572a..b63c933305 100644 --- a/apps/contracts/openapi.json +++ b/apps/contracts/openapi.json @@ -3845,6 +3845,1629 @@ } } } + }, + "/api/v1/schedules": { + "get": { + "operationId": "listSchedules", + "responses": { + "200": { + "description": "The visible scheduled work.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "fireCount": { + "type": "integer", + "minimum": 0 + }, + "lastFireAt": { + "type": "string", + "nullable": true + }, + "lastFireSessionId": { + "type": "string" + }, + "maxFires": { + "type": "integer", + "minimum": 0 + }, + "mode": { + "type": "string", + "enum": [ + "default", + "plan", + "acceptEdits" + ] + }, + "modelId": { + "type": "string" + }, + "mutating": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "nextFireAt": { + "type": "string", + "nullable": true + }, + "oneShotMaxRetries": { + "type": "integer", + "minimum": 0 + }, + "oneShotRetry": { + "type": "boolean" + }, + "owner": { + "type": "string" + }, + "profile": { + "type": "string", + "enum": [ + "all", + "noFilesystem" + ] + }, + "prompt": { + "type": "string" + }, + "providerId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "claimed", + "completed", + "paused", + "running", + "scheduled" + ] + }, + "trigger": { + "oneOf": [ + { + "type": "object", + "properties": { + "expression": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": [ + "cron" + ] + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "expression", + "kind", + "timezone" + ] + }, + { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "kind": { + "type": "string", + "enum": [ + "once" + ] + } + }, + "required": [ + "at", + "kind" + ] + } + ] + } + }, + "required": [ + "enabled", + "fireCount", + "lastFireAt", + "lastFireSessionId", + "maxFires", + "mode", + "modelId", + "mutating", + "name", + "nextFireAt", + "oneShotMaxRetries", + "oneShotRetry", + "owner", + "profile", + "prompt", + "providerId", + "status", + "trigger" + ] + } + }, + "reason": { + "type": "string" + }, + "supported": { + "type": "boolean" + } + }, + "required": [ + "items", + "reason", + "supported" + ] + } + } + } + }, + "500": { + "description": "The request could not be completed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "503": { + "description": "The Mecatl runtime or scheduler is unavailable.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + } + } + }, + "post": { + "operationId": "createSchedule", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "maxFires": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "default": 0 + }, + "mode": { + "type": "string", + "enum": [ + "default", + "plan", + "acceptEdits" + ], + "default": "plan" + }, + "mutating": { + "type": "boolean", + "default": false + }, + "oneShotMaxRetries": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "default": 0 + }, + "oneShotRetry": { + "type": "boolean", + "default": false + }, + "profile": { + "type": "string", + "enum": [ + "all", + "noFilesystem" + ], + "default": "all" + }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 1000000 + }, + "trigger": { + "oneOf": [ + { + "type": "object", + "properties": { + "expression": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": [ + "cron" + ] + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "expression", + "kind", + "timezone" + ] + }, + { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "kind": { + "type": "string", + "enum": [ + "once" + ] + } + }, + "required": [ + "at", + "kind" + ] + } + ] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 160 + } + }, + "required": [ + "prompt", + "trigger", + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "The created schedule.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "fireCount": { + "type": "integer", + "minimum": 0 + }, + "lastFireAt": { + "type": "string", + "nullable": true + }, + "lastFireSessionId": { + "type": "string" + }, + "maxFires": { + "type": "integer", + "minimum": 0 + }, + "mode": { + "type": "string", + "enum": [ + "default", + "plan", + "acceptEdits" + ] + }, + "modelId": { + "type": "string" + }, + "mutating": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "nextFireAt": { + "type": "string", + "nullable": true + }, + "oneShotMaxRetries": { + "type": "integer", + "minimum": 0 + }, + "oneShotRetry": { + "type": "boolean" + }, + "owner": { + "type": "string" + }, + "profile": { + "type": "string", + "enum": [ + "all", + "noFilesystem" + ] + }, + "prompt": { + "type": "string" + }, + "providerId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "claimed", + "completed", + "paused", + "running", + "scheduled" + ] + }, + "trigger": { + "oneOf": [ + { + "type": "object", + "properties": { + "expression": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": [ + "cron" + ] + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "expression", + "kind", + "timezone" + ] + }, + { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "kind": { + "type": "string", + "enum": [ + "once" + ] + } + }, + "required": [ + "at", + "kind" + ] + } + ] + } + }, + "required": [ + "enabled", + "fireCount", + "lastFireAt", + "lastFireSessionId", + "maxFires", + "mode", + "modelId", + "mutating", + "name", + "nextFireAt", + "oneShotMaxRetries", + "oneShotRetry", + "owner", + "profile", + "prompt", + "providerId", + "status", + "trigger" + ] + } + } + } + }, + "400": { + "description": "The schedule is invalid, for example an unknown time zone.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "500": { + "description": "The request could not be completed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "501": { + "description": "The Mecatl runtime or scheduler is unavailable.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "503": { + "description": "The Mecatl runtime or scheduler is unavailable.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + } + } + } + }, + "/api/v1/schedules/{scheduleName}": { + "put": { + "operationId": "updateSchedule", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "in": "path", + "name": "scheduleName" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "maxFires": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "default": 0 + }, + "mode": { + "type": "string", + "enum": [ + "default", + "plan", + "acceptEdits" + ], + "default": "plan" + }, + "mutating": { + "type": "boolean", + "default": false + }, + "oneShotMaxRetries": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "default": 0 + }, + "oneShotRetry": { + "type": "boolean", + "default": false + }, + "profile": { + "type": "string", + "enum": [ + "all", + "noFilesystem" + ], + "default": "all" + }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 1000000 + }, + "trigger": { + "oneOf": [ + { + "type": "object", + "properties": { + "expression": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": [ + "cron" + ] + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "expression", + "kind", + "timezone" + ] + }, + { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "kind": { + "type": "string", + "enum": [ + "once" + ] + } + }, + "required": [ + "at", + "kind" + ] + } + ] + } + }, + "required": [ + "prompt", + "trigger" + ] + } + } + } + }, + "responses": { + "200": { + "description": "The updated schedule.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "fireCount": { + "type": "integer", + "minimum": 0 + }, + "lastFireAt": { + "type": "string", + "nullable": true + }, + "lastFireSessionId": { + "type": "string" + }, + "maxFires": { + "type": "integer", + "minimum": 0 + }, + "mode": { + "type": "string", + "enum": [ + "default", + "plan", + "acceptEdits" + ] + }, + "modelId": { + "type": "string" + }, + "mutating": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "nextFireAt": { + "type": "string", + "nullable": true + }, + "oneShotMaxRetries": { + "type": "integer", + "minimum": 0 + }, + "oneShotRetry": { + "type": "boolean" + }, + "owner": { + "type": "string" + }, + "profile": { + "type": "string", + "enum": [ + "all", + "noFilesystem" + ] + }, + "prompt": { + "type": "string" + }, + "providerId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "claimed", + "completed", + "paused", + "running", + "scheduled" + ] + }, + "trigger": { + "oneOf": [ + { + "type": "object", + "properties": { + "expression": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": [ + "cron" + ] + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "expression", + "kind", + "timezone" + ] + }, + { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "kind": { + "type": "string", + "enum": [ + "once" + ] + } + }, + "required": [ + "at", + "kind" + ] + } + ] + } + }, + "required": [ + "enabled", + "fireCount", + "lastFireAt", + "lastFireSessionId", + "maxFires", + "mode", + "modelId", + "mutating", + "name", + "nextFireAt", + "oneShotMaxRetries", + "oneShotRetry", + "owner", + "profile", + "prompt", + "providerId", + "status", + "trigger" + ] + } + } + } + }, + "400": { + "description": "The schedule is invalid, for example an unknown time zone.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "409": { + "description": "The update changes the schedule's trigger, which cannot be edited; create a new schedule instead.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "500": { + "description": "The request could not be completed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "501": { + "description": "The Mecatl runtime or scheduler is unavailable.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "503": { + "description": "The Mecatl runtime or scheduler is unavailable.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + } + } + }, + "delete": { + "operationId": "deleteSchedule", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "in": "path", + "name": "scheduleName" + } + ], + "responses": { + "204": { + "description": "The schedule was deleted." + }, + "500": { + "description": "The request could not be completed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "501": { + "description": "The Mecatl runtime or scheduler is unavailable.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "503": { + "description": "The Mecatl runtime or scheduler is unavailable.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + } + } + } + }, + "/api/v1/schedules/{scheduleName}/actions": { + "post": { + "operationId": "actOnSchedule", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "in": "path", + "name": "scheduleName" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "fire", + "pause", + "resume" + ] + } + }, + "required": [ + "action" + ] + } + } + } + }, + "responses": { + "204": { + "description": "The schedule action was accepted." + }, + "500": { + "description": "The request could not be completed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "501": { + "description": "The Mecatl runtime or scheduler is unavailable.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "503": { + "description": "The Mecatl runtime or scheduler is unavailable.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + } + } + } + }, + "/api/v1/schedules/{scheduleName}/fires": { + "get": { + "operationId": "listScheduleFires", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "in": "path", + "name": "scheduleName" + } + ], + "responses": { + "200": { + "description": "The schedule run history.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "deadline": { + "type": "string", + "nullable": true + }, + "error": { + "type": "string" + }, + "firedAt": { + "type": "string", + "nullable": true + }, + "id": { + "type": "string" + }, + "inFlight": { + "type": "boolean" + }, + "progressAt": { + "type": "string", + "nullable": true + }, + "scheduleName": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "startedAt": { + "type": "string", + "nullable": true + }, + "stop": { + "type": "string" + } + }, + "required": [ + "deadline", + "error", + "firedAt", + "id", + "inFlight", + "progressAt", + "scheduleName", + "sessionId", + "startedAt", + "stop" + ] + } + } + }, + "required": [ + "items" + ] + } + } + } + }, + "500": { + "description": "The request could not be completed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "501": { + "description": "The Mecatl runtime or scheduler is unavailable.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + }, + "503": { + "description": "The Mecatl runtime or scheduler is unavailable.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "instance", + "status", + "title", + "type" + ] + } + } + } + } + } + } } } } diff --git a/apps/contracts/src/generated/@tanstack/react-query.gen.ts b/apps/contracts/src/generated/@tanstack/react-query.gen.ts index 245343260f..5a66b44a4e 100644 --- a/apps/contracts/src/generated/@tanstack/react-query.gen.ts +++ b/apps/contracts/src/generated/@tanstack/react-query.gen.ts @@ -4,8 +4,8 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { cancelRun, clearSession, compactSession, completeAuthLogin, createSession, deleteSession, forkSession, getAuthSession, getHealth, getRuntime, getSessionDetail, getSessionTranscript, listSessions, logoutAuthSession, type Options, renameSession, resolveRunPermission, setSessionMode, startAuthLogin, steerRun } from '../sdk.gen'; -import type { CancelRunData, CancelRunError, CancelRunResponse, ClearSessionData, ClearSessionError, ClearSessionResponse, CompactSessionData, CompactSessionError, CompactSessionResponse, CompleteAuthLoginData, CompleteAuthLoginError, CreateSessionData, CreateSessionError, CreateSessionResponse, DeleteSessionData, DeleteSessionError, DeleteSessionResponse, ForkSessionData, ForkSessionError, ForkSessionResponse, GetAuthSessionData, GetAuthSessionResponse, GetHealthData, GetHealthResponse, GetRuntimeData, GetRuntimeError, GetRuntimeResponse, GetSessionDetailData, GetSessionDetailError, GetSessionDetailResponse, GetSessionTranscriptData, GetSessionTranscriptError, GetSessionTranscriptResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, LogoutAuthSessionData, LogoutAuthSessionError, LogoutAuthSessionResponse, RenameSessionData, RenameSessionError, RenameSessionResponse, ResolveRunPermissionData, ResolveRunPermissionError, ResolveRunPermissionResponse, SetSessionModeData, SetSessionModeError, SetSessionModeResponse, StartAuthLoginData, StartAuthLoginError, SteerRunData, SteerRunError, SteerRunResponse } from '../types.gen'; +import { actOnSchedule, cancelRun, clearSession, compactSession, completeAuthLogin, createSchedule, createSession, deleteSchedule, deleteSession, forkSession, getAuthSession, getHealth, getRuntime, getSessionDetail, getSessionTranscript, listScheduleFires, listSchedules, listSessions, logoutAuthSession, type Options, renameSession, resolveRunPermission, setSessionMode, startAuthLogin, steerRun, updateSchedule } from '../sdk.gen'; +import type { ActOnScheduleData, ActOnScheduleError, ActOnScheduleResponse, CancelRunData, CancelRunError, CancelRunResponse, ClearSessionData, ClearSessionError, ClearSessionResponse, CompactSessionData, CompactSessionError, CompactSessionResponse, CompleteAuthLoginData, CompleteAuthLoginError, CreateScheduleData, CreateScheduleError, CreateScheduleResponse, CreateSessionData, CreateSessionError, CreateSessionResponse, DeleteScheduleData, DeleteScheduleError, DeleteScheduleResponse, DeleteSessionData, DeleteSessionError, DeleteSessionResponse, ForkSessionData, ForkSessionError, ForkSessionResponse, GetAuthSessionData, GetAuthSessionResponse, GetHealthData, GetHealthResponse, GetRuntimeData, GetRuntimeError, GetRuntimeResponse, GetSessionDetailData, GetSessionDetailError, GetSessionDetailResponse, GetSessionTranscriptData, GetSessionTranscriptError, GetSessionTranscriptResponse, ListScheduleFiresData, ListScheduleFiresError, ListScheduleFiresResponse, ListSchedulesData, ListSchedulesError, ListSchedulesResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, LogoutAuthSessionData, LogoutAuthSessionError, LogoutAuthSessionResponse, RenameSessionData, RenameSessionError, RenameSessionResponse, ResolveRunPermissionData, ResolveRunPermissionError, ResolveRunPermissionResponse, SetSessionModeData, SetSessionModeError, SetSessionModeResponse, StartAuthLoginData, StartAuthLoginError, SteerRunData, SteerRunError, SteerRunResponse, UpdateScheduleData, UpdateScheduleError, UpdateScheduleResponse } from '../types.gen'; export type QueryKey = [ Pick & { @@ -313,3 +313,89 @@ export const resolveRunPermissionMutation = (options?: Partial) => createQueryKey('listSchedules', options); + +export const listSchedulesOptions = (options?: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await listSchedules({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: listSchedulesQueryKey(options) +}); + +export const createScheduleMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await createSchedule({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const deleteScheduleMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteSchedule({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const updateScheduleMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await updateSchedule({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const actOnScheduleMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await actOnSchedule({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const listScheduleFiresQueryKey = (options: Options) => createQueryKey('listScheduleFires', options); + +export const listScheduleFiresOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await listScheduleFires({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: listScheduleFiresQueryKey(options) +}); diff --git a/apps/contracts/src/generated/index.ts b/apps/contracts/src/generated/index.ts index 068740b782..f414614aff 100644 --- a/apps/contracts/src/generated/index.ts +++ b/apps/contracts/src/generated/index.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 // This file is auto-generated by @hey-api/openapi-ts -export { cancelRun, clearSession, compactSession, completeAuthLogin, createSession, deleteSession, forkSession, getAuthSession, getHealth, getRuntime, getSessionDetail, getSessionTranscript, listSessions, logoutAuthSession, type Options, renameSession, resolveRunPermission, retrySession, setSessionMode, startAuthLogin, startRun, steerRun, watchSessionActivity } from './sdk.gen'; -export type { CancelRunData, CancelRunError, CancelRunErrors, CancelRunResponse, CancelRunResponses, ClearSessionData, ClearSessionError, ClearSessionErrors, ClearSessionResponse, ClearSessionResponses, ClientOptions, CompactSessionData, CompactSessionError, CompactSessionErrors, CompactSessionResponse, CompactSessionResponses, CompleteAuthLoginData, CompleteAuthLoginError, CompleteAuthLoginErrors, CreateSessionData, CreateSessionError, CreateSessionErrors, CreateSessionResponse, CreateSessionResponses, DeleteSessionData, DeleteSessionError, DeleteSessionErrors, DeleteSessionResponse, DeleteSessionResponses, ForkSessionData, ForkSessionError, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, GetAuthSessionData, GetAuthSessionResponse, GetAuthSessionResponses, GetHealthData, GetHealthResponse, GetHealthResponses, GetRuntimeData, GetRuntimeError, GetRuntimeErrors, GetRuntimeResponse, GetRuntimeResponses, GetSessionDetailData, GetSessionDetailError, GetSessionDetailErrors, GetSessionDetailResponse, GetSessionDetailResponses, GetSessionTranscriptData, GetSessionTranscriptError, GetSessionTranscriptErrors, GetSessionTranscriptResponse, GetSessionTranscriptResponses, ListSessionsData, ListSessionsError, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LogoutAuthSessionData, LogoutAuthSessionError, LogoutAuthSessionErrors, LogoutAuthSessionResponse, LogoutAuthSessionResponses, RenameSessionData, RenameSessionError, RenameSessionErrors, RenameSessionResponse, RenameSessionResponses, ResolveRunPermissionData, ResolveRunPermissionError, ResolveRunPermissionErrors, ResolveRunPermissionResponse, ResolveRunPermissionResponses, RetrySessionData, RetrySessionError, RetrySessionErrors, RetrySessionResponse, RetrySessionResponses, SetSessionModeData, SetSessionModeError, SetSessionModeErrors, SetSessionModeResponse, SetSessionModeResponses, StartAuthLoginData, StartAuthLoginError, StartAuthLoginErrors, StartRunData, StartRunError, StartRunErrors, StartRunResponse, StartRunResponses, SteerRunData, SteerRunError, SteerRunErrors, SteerRunResponse, SteerRunResponses, WatchSessionActivityData, WatchSessionActivityError, WatchSessionActivityErrors, WatchSessionActivityResponse, WatchSessionActivityResponses } from './types.gen'; +export { actOnSchedule, cancelRun, clearSession, compactSession, completeAuthLogin, createSchedule, createSession, deleteSchedule, deleteSession, forkSession, getAuthSession, getHealth, getRuntime, getSessionDetail, getSessionTranscript, listScheduleFires, listSchedules, listSessions, logoutAuthSession, type Options, renameSession, resolveRunPermission, retrySession, setSessionMode, startAuthLogin, startRun, steerRun, updateSchedule, watchSessionActivity } from './sdk.gen'; +export type { ActOnScheduleData, ActOnScheduleError, ActOnScheduleErrors, ActOnScheduleResponse, ActOnScheduleResponses, CancelRunData, CancelRunError, CancelRunErrors, CancelRunResponse, CancelRunResponses, ClearSessionData, ClearSessionError, ClearSessionErrors, ClearSessionResponse, ClearSessionResponses, ClientOptions, CompactSessionData, CompactSessionError, CompactSessionErrors, CompactSessionResponse, CompactSessionResponses, CompleteAuthLoginData, CompleteAuthLoginError, CompleteAuthLoginErrors, CreateScheduleData, CreateScheduleError, CreateScheduleErrors, CreateScheduleResponse, CreateScheduleResponses, CreateSessionData, CreateSessionError, CreateSessionErrors, CreateSessionResponse, CreateSessionResponses, DeleteScheduleData, DeleteScheduleError, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionError, DeleteSessionErrors, DeleteSessionResponse, DeleteSessionResponses, ForkSessionData, ForkSessionError, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, GetAuthSessionData, GetAuthSessionResponse, GetAuthSessionResponses, GetHealthData, GetHealthResponse, GetHealthResponses, GetRuntimeData, GetRuntimeError, GetRuntimeErrors, GetRuntimeResponse, GetRuntimeResponses, GetSessionDetailData, GetSessionDetailError, GetSessionDetailErrors, GetSessionDetailResponse, GetSessionDetailResponses, GetSessionTranscriptData, GetSessionTranscriptError, GetSessionTranscriptErrors, GetSessionTranscriptResponse, GetSessionTranscriptResponses, ListScheduleFiresData, ListScheduleFiresError, ListScheduleFiresErrors, ListScheduleFiresResponse, ListScheduleFiresResponses, ListSchedulesData, ListSchedulesError, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponses, ListSessionsData, ListSessionsError, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LogoutAuthSessionData, LogoutAuthSessionError, LogoutAuthSessionErrors, LogoutAuthSessionResponse, LogoutAuthSessionResponses, RenameSessionData, RenameSessionError, RenameSessionErrors, RenameSessionResponse, RenameSessionResponses, ResolveRunPermissionData, ResolveRunPermissionError, ResolveRunPermissionErrors, ResolveRunPermissionResponse, ResolveRunPermissionResponses, RetrySessionData, RetrySessionError, RetrySessionErrors, RetrySessionResponse, RetrySessionResponses, SetSessionModeData, SetSessionModeError, SetSessionModeErrors, SetSessionModeResponse, SetSessionModeResponses, StartAuthLoginData, StartAuthLoginError, StartAuthLoginErrors, StartRunData, StartRunError, StartRunErrors, StartRunResponse, StartRunResponses, SteerRunData, SteerRunError, SteerRunErrors, SteerRunResponse, SteerRunResponses, UpdateScheduleData, UpdateScheduleError, UpdateScheduleErrors, UpdateScheduleResponse, UpdateScheduleResponses, WatchSessionActivityData, WatchSessionActivityError, WatchSessionActivityErrors, WatchSessionActivityResponse, WatchSessionActivityResponses } from './types.gen'; diff --git a/apps/contracts/src/generated/sdk.gen.ts b/apps/contracts/src/generated/sdk.gen.ts index 837f51b888..1bf134ef74 100644 --- a/apps/contracts/src/generated/sdk.gen.ts +++ b/apps/contracts/src/generated/sdk.gen.ts @@ -3,7 +3,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, ServerSentEventsResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { CancelRunData, CancelRunErrors, CancelRunResponses, ClearSessionData, ClearSessionErrors, ClearSessionResponses, CompactSessionData, CompactSessionErrors, CompactSessionResponses, CompleteAuthLoginData, CompleteAuthLoginErrors, CreateSessionData, CreateSessionErrors, CreateSessionResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetAuthSessionData, GetAuthSessionResponses, GetHealthData, GetHealthResponses, GetRuntimeData, GetRuntimeErrors, GetRuntimeResponses, GetSessionDetailData, GetSessionDetailErrors, GetSessionDetailResponses, GetSessionTranscriptData, GetSessionTranscriptErrors, GetSessionTranscriptResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, LogoutAuthSessionData, LogoutAuthSessionErrors, LogoutAuthSessionResponses, RenameSessionData, RenameSessionErrors, RenameSessionResponses, ResolveRunPermissionData, ResolveRunPermissionErrors, ResolveRunPermissionResponses, RetrySessionData, RetrySessionErrors, RetrySessionResponse, RetrySessionResponses, SetSessionModeData, SetSessionModeErrors, SetSessionModeResponses, StartAuthLoginData, StartAuthLoginErrors, StartRunData, StartRunErrors, StartRunResponse, StartRunResponses, SteerRunData, SteerRunErrors, SteerRunResponses, WatchSessionActivityData, WatchSessionActivityErrors, WatchSessionActivityResponse, WatchSessionActivityResponses } from './types.gen'; +import type { ActOnScheduleData, ActOnScheduleErrors, ActOnScheduleResponses, CancelRunData, CancelRunErrors, CancelRunResponses, ClearSessionData, ClearSessionErrors, ClearSessionResponses, CompactSessionData, CompactSessionErrors, CompactSessionResponses, CompleteAuthLoginData, CompleteAuthLoginErrors, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, CreateSessionData, CreateSessionErrors, CreateSessionResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetAuthSessionData, GetAuthSessionResponses, GetHealthData, GetHealthResponses, GetRuntimeData, GetRuntimeErrors, GetRuntimeResponses, GetSessionDetailData, GetSessionDetailErrors, GetSessionDetailResponses, GetSessionTranscriptData, GetSessionTranscriptErrors, GetSessionTranscriptResponses, ListScheduleFiresData, ListScheduleFiresErrors, ListScheduleFiresResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, LogoutAuthSessionData, LogoutAuthSessionErrors, LogoutAuthSessionResponses, RenameSessionData, RenameSessionErrors, RenameSessionResponses, ResolveRunPermissionData, ResolveRunPermissionErrors, ResolveRunPermissionResponses, RetrySessionData, RetrySessionErrors, RetrySessionResponse, RetrySessionResponses, SetSessionModeData, SetSessionModeErrors, SetSessionModeResponses, StartAuthLoginData, StartAuthLoginErrors, StartRunData, StartRunErrors, StartRunResponse, StartRunResponses, SteerRunData, SteerRunErrors, SteerRunResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, WatchSessionActivityData, WatchSessionActivityErrors, WatchSessionActivityResponse, WatchSessionActivityResponses } from './types.gen'; export type Options = Options2 & { /** @@ -111,3 +111,36 @@ export const resolveRunPermission = (optio ...options.headers } }); + +export const listSchedules = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/schedules', ...options }); + +export const createSchedule = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/v1/schedules', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const deleteSchedule = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/schedules/{scheduleName}', ...options }); + +export const updateSchedule = (options: Options): RequestResult => (options.client ?? client).put({ + url: '/api/v1/schedules/{scheduleName}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const actOnSchedule = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/v1/schedules/{scheduleName}/actions', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const listScheduleFires = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/schedules/{scheduleName}/fires', ...options }); diff --git a/apps/contracts/src/generated/types.gen.ts b/apps/contracts/src/generated/types.gen.ts index 43f6ac3f9d..d721bd97d9 100644 --- a/apps/contracts/src/generated/types.gen.ts +++ b/apps/contracts/src/generated/types.gen.ts @@ -1310,3 +1310,487 @@ export type ResolveRunPermissionResponses = { }; export type ResolveRunPermissionResponse = ResolveRunPermissionResponses[keyof ResolveRunPermissionResponses]; + +export type ListSchedulesData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/schedules'; +}; + +export type ListSchedulesErrors = { + /** + * The request could not be completed. + */ + 500: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The Mecatl runtime or scheduler is unavailable. + */ + 503: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; +}; + +export type ListSchedulesError = ListSchedulesErrors[keyof ListSchedulesErrors]; + +export type ListSchedulesResponses = { + /** + * The visible scheduled work. + */ + 200: { + items: Array<{ + enabled: boolean; + fireCount: number; + lastFireAt: string; + lastFireSessionId: string; + maxFires: number; + mode: 'default' | 'plan' | 'acceptEdits'; + modelId: string; + mutating: boolean; + name: string; + nextFireAt: string; + oneShotMaxRetries: number; + oneShotRetry: boolean; + owner: string; + profile: 'all' | 'noFilesystem'; + prompt: string; + providerId: string; + status: 'claimed' | 'completed' | 'paused' | 'running' | 'scheduled'; + trigger: { + expression: string; + kind: 'cron'; + timezone: string; + } | { + at: string; + kind: 'once'; + }; + }>; + reason: string; + supported: boolean; + }; +}; + +export type ListSchedulesResponse = ListSchedulesResponses[keyof ListSchedulesResponses]; + +export type CreateScheduleData = { + body: { + maxFires?: number; + mode?: 'default' | 'plan' | 'acceptEdits'; + mutating?: boolean; + oneShotMaxRetries?: number; + oneShotRetry?: boolean; + profile?: 'all' | 'noFilesystem'; + prompt: string; + trigger: { + expression: string; + kind: 'cron'; + timezone: string; + } | { + at: string; + kind: 'once'; + }; + name: string; + }; + path?: never; + query?: never; + url: '/api/v1/schedules'; +}; + +export type CreateScheduleErrors = { + /** + * The schedule is invalid, for example an unknown time zone. + */ + 400: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The request could not be completed. + */ + 500: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The Mecatl runtime or scheduler is unavailable. + */ + 501: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The Mecatl runtime or scheduler is unavailable. + */ + 503: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; +}; + +export type CreateScheduleError = CreateScheduleErrors[keyof CreateScheduleErrors]; + +export type CreateScheduleResponses = { + /** + * The created schedule. + */ + 201: { + enabled: boolean; + fireCount: number; + lastFireAt: string; + lastFireSessionId: string; + maxFires: number; + mode: 'default' | 'plan' | 'acceptEdits'; + modelId: string; + mutating: boolean; + name: string; + nextFireAt: string; + oneShotMaxRetries: number; + oneShotRetry: boolean; + owner: string; + profile: 'all' | 'noFilesystem'; + prompt: string; + providerId: string; + status: 'claimed' | 'completed' | 'paused' | 'running' | 'scheduled'; + trigger: { + expression: string; + kind: 'cron'; + timezone: string; + } | { + at: string; + kind: 'once'; + }; + }; +}; + +export type CreateScheduleResponse = CreateScheduleResponses[keyof CreateScheduleResponses]; + +export type DeleteScheduleData = { + body?: never; + path: { + scheduleName: string; + }; + query?: never; + url: '/api/v1/schedules/{scheduleName}'; +}; + +export type DeleteScheduleErrors = { + /** + * The request could not be completed. + */ + 500: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The Mecatl runtime or scheduler is unavailable. + */ + 501: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The Mecatl runtime or scheduler is unavailable. + */ + 503: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; +}; + +export type DeleteScheduleError = DeleteScheduleErrors[keyof DeleteScheduleErrors]; + +export type DeleteScheduleResponses = { + /** + * The schedule was deleted. + */ + 204: void; +}; + +export type DeleteScheduleResponse = DeleteScheduleResponses[keyof DeleteScheduleResponses]; + +export type UpdateScheduleData = { + body: { + maxFires?: number; + mode?: 'default' | 'plan' | 'acceptEdits'; + mutating?: boolean; + oneShotMaxRetries?: number; + oneShotRetry?: boolean; + profile?: 'all' | 'noFilesystem'; + prompt: string; + trigger: { + expression: string; + kind: 'cron'; + timezone: string; + } | { + at: string; + kind: 'once'; + }; + }; + path: { + scheduleName: string; + }; + query?: never; + url: '/api/v1/schedules/{scheduleName}'; +}; + +export type UpdateScheduleErrors = { + /** + * The schedule is invalid, for example an unknown time zone. + */ + 400: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The update changes the schedule's trigger, which cannot be edited; create a new schedule instead. + */ + 409: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The request could not be completed. + */ + 500: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The Mecatl runtime or scheduler is unavailable. + */ + 501: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The Mecatl runtime or scheduler is unavailable. + */ + 503: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; +}; + +export type UpdateScheduleError = UpdateScheduleErrors[keyof UpdateScheduleErrors]; + +export type UpdateScheduleResponses = { + /** + * The updated schedule. + */ + 200: { + enabled: boolean; + fireCount: number; + lastFireAt: string; + lastFireSessionId: string; + maxFires: number; + mode: 'default' | 'plan' | 'acceptEdits'; + modelId: string; + mutating: boolean; + name: string; + nextFireAt: string; + oneShotMaxRetries: number; + oneShotRetry: boolean; + owner: string; + profile: 'all' | 'noFilesystem'; + prompt: string; + providerId: string; + status: 'claimed' | 'completed' | 'paused' | 'running' | 'scheduled'; + trigger: { + expression: string; + kind: 'cron'; + timezone: string; + } | { + at: string; + kind: 'once'; + }; + }; +}; + +export type UpdateScheduleResponse = UpdateScheduleResponses[keyof UpdateScheduleResponses]; + +export type ActOnScheduleData = { + body: { + action: 'fire' | 'pause' | 'resume'; + }; + path: { + scheduleName: string; + }; + query?: never; + url: '/api/v1/schedules/{scheduleName}/actions'; +}; + +export type ActOnScheduleErrors = { + /** + * The request could not be completed. + */ + 500: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The Mecatl runtime or scheduler is unavailable. + */ + 501: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The Mecatl runtime or scheduler is unavailable. + */ + 503: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; +}; + +export type ActOnScheduleError = ActOnScheduleErrors[keyof ActOnScheduleErrors]; + +export type ActOnScheduleResponses = { + /** + * The schedule action was accepted. + */ + 204: void; +}; + +export type ActOnScheduleResponse = ActOnScheduleResponses[keyof ActOnScheduleResponses]; + +export type ListScheduleFiresData = { + body?: never; + path: { + scheduleName: string; + }; + query?: never; + url: '/api/v1/schedules/{scheduleName}/fires'; +}; + +export type ListScheduleFiresErrors = { + /** + * The request could not be completed. + */ + 500: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The Mecatl runtime or scheduler is unavailable. + */ + 501: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; + /** + * The Mecatl runtime or scheduler is unavailable. + */ + 503: { + code: string; + detail: string; + instance: string; + status: number; + title: string; + type: string; + }; +}; + +export type ListScheduleFiresError = ListScheduleFiresErrors[keyof ListScheduleFiresErrors]; + +export type ListScheduleFiresResponses = { + /** + * The schedule run history. + */ + 200: { + items: Array<{ + deadline: string; + error: string; + firedAt: string; + id: string; + inFlight: boolean; + progressAt: string; + scheduleName: string; + sessionId: string; + startedAt: string; + stop: string; + }>; + }; +}; + +export type ListScheduleFiresResponse = ListScheduleFiresResponses[keyof ListScheduleFiresResponses]; diff --git a/apps/contracts/src/index.ts b/apps/contracts/src/index.ts index a4b9357686..3c6d7f3d83 100644 --- a/apps/contracts/src/index.ts +++ b/apps/contracts/src/index.ts @@ -5,3 +5,4 @@ export * from "./schemas/chat.ts"; export * from "./schemas/health.ts"; export * from "./schemas/problem-details.ts"; export * from "./schemas/runtime.ts"; +export * from "./schemas/schedules.ts"; diff --git a/apps/contracts/src/schemas/schedules.ts b/apps/contracts/src/schemas/schedules.ts new file mode 100644 index 0000000000..b817d79410 --- /dev/null +++ b/apps/contracts/src/schemas/schedules.ts @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { z } from "zod"; +import { sessionToolAccessSchema } from "./chat.ts"; + +/** + * The daemon's fire counters are signed 32-bit protobuf fields, so a larger + * value cannot be represented: reject it here rather than let serialisation + * coerce or fail on it. + */ +export const maximumInt32 = 2_147_483_647; + +export const scheduleModeSchema = z.enum(["default", "plan", "acceptEdits"]); + +/** Each segment of an IANA zone name starts with an uppercase letter. */ +const ianaZoneName = /^[A-Z][A-Za-z0-9_+-]*(?:\/[A-Z][A-Za-z0-9_+-]*)*$/u; + +/** + * Whether `value` names an IANA time zone the daemon can load. The daemon + * silently falls back to UTC for a name it cannot load, so a typo would run + * the schedule in the wrong zone; the empty string is the explicit UTC choice + * and is accepted. + * + * `Intl.supportedValuesOf("timeZone")` lists only canonical zones (it omits + * `UTC` and aliases such as `US/Eastern`, which the daemon accepts), so the + * check asks `Intl.DateTimeFormat` instead. That constructor also accepts + * numeric offsets and case-folded names the daemon's zone database rejects; + * the name pattern and the case check exclude both. + */ +export function isScheduleTimeZone(value: string): boolean { + if (value === "") return true; + if (!ianaZoneName.test(value)) return false; + let resolved: string; + try { + resolved = new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone; + } catch { + return false; + } + return resolved === value || resolved.toLowerCase() !== value.toLowerCase(); +} + +export const scheduleTriggerSchema = z.discriminatedUnion("kind", [ + z.object({ + expression: z.string().trim().min(1), + kind: z.literal("cron"), + timezone: z + .string() + .trim() + .refine(isScheduleTimeZone, { + error: (issue) => + `Unknown time zone ${JSON.stringify(issue.input)}; use an IANA name such as Europe/Rome, or leave it empty for UTC`, + }), + }), + z.object({ + at: z.iso.datetime({ offset: true }), + kind: z.literal("once"), + }), +]); + +export const scheduleWriteSchema = z.object({ + maxFires: z.number().int().nonnegative().max(maximumInt32).default(0), + mode: scheduleModeSchema.default("plan"), + mutating: z.boolean().default(false), + oneShotMaxRetries: z.number().int().nonnegative().max(maximumInt32).default(0), + oneShotRetry: z.boolean().default(false), + profile: sessionToolAccessSchema.default("all"), + prompt: z.string().trim().min(1).max(1_000_000), + trigger: scheduleTriggerSchema, +}); + +export const createScheduleRequestSchema = scheduleWriteSchema.extend({ + name: z.string().trim().min(1).max(160), +}); + +export const updateScheduleRequestSchema = scheduleWriteSchema; + +export const scheduleSchema = z.object({ + enabled: z.boolean(), + fireCount: z.number().int().nonnegative(), + lastFireAt: z.string().nullable(), + lastFireSessionId: z.string(), + maxFires: z.number().int().nonnegative(), + mode: scheduleModeSchema, + modelId: z.string(), + mutating: z.boolean(), + name: z.string(), + nextFireAt: z.string().nullable(), + oneShotMaxRetries: z.number().int().nonnegative(), + oneShotRetry: z.boolean(), + owner: z.string(), + profile: sessionToolAccessSchema, + prompt: z.string(), + providerId: z.string(), + status: z.enum(["claimed", "completed", "paused", "running", "scheduled"]), + trigger: scheduleTriggerSchema, +}); + +export const listSchedulesResponseSchema = z.object({ + items: z.array(scheduleSchema), + reason: z.string(), + supported: z.boolean(), +}); + +export const scheduleFireSchema = z.object({ + deadline: z.string().nullable(), + error: z.string(), + firedAt: z.string().nullable(), + id: z.string(), + inFlight: z.boolean(), + progressAt: z.string().nullable(), + scheduleName: z.string(), + sessionId: z.string(), + startedAt: z.string().nullable(), + stop: z.string(), +}); + +export const listScheduleFiresResponseSchema = z.object({ + items: z.array(scheduleFireSchema), +}); + +export const scheduleActionRequestSchema = z.object({ + action: z.enum(["fire", "pause", "resume"]), +}); + +export type CreateScheduleRequest = z.infer; +export type ListScheduleFiresResponse = z.infer; +export type ListSchedulesResponse = z.infer; +export type ScheduleResponse = z.infer; +export type UpdateScheduleRequest = z.infer; diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 2202b221f6..d8f2359e0f 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -26,8 +26,10 @@ import { spaHandler } from "./http/static.js"; import { type Logger, silentLogger } from "./log.js"; import { type ChatService, createMecatlChatService } from "./mecatl/chat.js"; import { type MecatlRuntime, RuntimeNotReadyError } from "./mecatl/runtime.js"; +import { createMecatlScheduleService, type ScheduleService } from "./mecatl/schedules.js"; import { registerAuthRoutes } from "./routes/auth.js"; import { registerChatRoutes } from "./routes/chat.js"; +import { registerScheduleRoutes } from "./routes/schedules.js"; export const openApiInfo = { info: { title: "Mecatl Studio API", version: "1.0.0" }, @@ -71,6 +73,7 @@ export interface AppDependencies { readonly activity?: ActivityLimits; readonly authentication?: AuthenticationService; readonly chat?: ChatService; + readonly schedules?: ScheduleService; readonly logger?: Logger; readonly runtime?: MecatlRuntime; /** How long a feature request waits for compatibility negotiation before `503`. */ @@ -175,6 +178,22 @@ export function createApp(dependencies: AppDependencies = {}) { dependencies.chat ?? (runtime === undefined ? undefined : createMecatlChatService(runtime.client)); registerChatRoutes(app, chat, dependencies.activity); + // Capability read LIVE from the negotiated snapshot: false until negotiation + // completes or when the deployment has scheduling off. + const schedulingSupported = () => { + if (runtime === undefined) return false; + try { + return runtime.snapshot().capabilities.scheduling; + } catch { + return false; + } + }; + const schedules = + dependencies.schedules ?? + (runtime === undefined + ? undefined + : createMecatlScheduleService(runtime.client, schedulingSupported)); + registerScheduleRoutes(app, schedules); if (dependencies.webDist !== undefined) app.use("*", spaHandler(dependencies.webDist)); diff --git a/apps/server/src/mecatl/schedules.test.ts b/apps/server/src/mecatl/schedules.test.ts new file mode 100644 index 0000000000..2a6ee1eee3 --- /dev/null +++ b/apps/server/src/mecatl/schedules.test.ts @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + createScheduleRequestSchema, + isScheduleTimeZone, + maximumInt32, + scheduleTriggerSchema, +} from "@mecatl-studio/contracts"; +import type { Client } from "@stacklok-oss/mecatl-sdk"; +import { PermissionMode } from "@stacklok-oss/mecatl-sdk/gen"; +import { describe, expect, it, vi } from "vitest"; +import { createMecatlScheduleService, ScheduleTriggerImmutableError } from "./schedules"; + +function baseWriteRequest() { + return { + maxFires: 0, + mode: "plan" as const, + mutating: false, + oneShotMaxRetries: 0, + oneShotRetry: false, + profile: "all" as const, + prompt: "Summarize the day", + trigger: { expression: "0 9 * * *", kind: "cron" as const, timezone: "Europe/Rome" }, + }; +} + +/** A minimal, response-mapping-safe SDK schedule, with the given wire profile. */ +function sdkSchedule(profile: string) { + return { + schedule: { + spec: { + maxFires: 0, + mode: PermissionMode.PLAN, + mutating: false, + name: "daily-summary", + oneShotMaxRetries: 0, + oneShotRetry: false, + profile, + prompt: "Summarize the day", + timezone: "Europe/Rome", + trigger: { cron: "0 9 * * *" }, + }, + state: {}, + }, + }; +} + +describe("Mecatl schedule tool profile", () => { + it("forwards the no-filesystem profile to the SDK spec on create", async () => { + const create = vi.fn().mockResolvedValue(sdkSchedule("no-fs")); + const service = createMecatlScheduleService( + { schedules: { create } } as unknown as Client, + true, + ); + + await service.create({ ...baseWriteRequest(), name: "daily-summary", profile: "noFilesystem" }); + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ spec: expect.objectContaining({ profile: "no-fs" }) }), + ); + }); + + it("defaults to the all-tools profile on create", async () => { + const create = vi.fn().mockResolvedValue(sdkSchedule("")); + const service = createMecatlScheduleService( + { schedules: { create } } as unknown as Client, + true, + ); + + await service.create({ ...baseWriteRequest(), name: "daily-summary", profile: "all" }); + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ spec: expect.objectContaining({ profile: "" }) }), + ); + }); + + it("decodes the SDK's profile back into the response enum", async () => { + const create = vi.fn().mockResolvedValue(sdkSchedule("no-fs")); + const service = createMecatlScheduleService( + { schedules: { create } } as unknown as Client, + true, + ); + + const response = await service.create({ + ...baseWriteRequest(), + name: "daily-summary", + profile: "noFilesystem", + }); + + expect(response.profile).toBe("noFilesystem"); + }); + + it("re-sends the request's profile on update rather than the schedule's prior value", async () => { + const get = vi.fn().mockResolvedValue(sdkSchedule("no-fs")); + const update = vi.fn().mockResolvedValue(sdkSchedule("")); + const service = createMecatlScheduleService( + { schedules: { get, update } } as unknown as Client, + true, + ); + + await service.update("daily-summary", { ...baseWriteRequest(), profile: "all" }); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ spec: expect.objectContaining({ profile: "" }) }), + ); + }); +}); + +describe("Mecatl schedule spec mapping", () => { + const ts = (seconds: number) => ({ nanos: 0, seconds: BigInt(seconds) }); + const spec = (over: Record) => ({ + schedule: { + spec: { + maxFires: 0, + mode: PermissionMode.PLAN, + mutating: false, + name: "s", + oneShotMaxRetries: 0, + oneShotRetry: false, + profile: "", + prompt: "p", + timezone: "", + trigger: { cron: "0 9 * * *" }, + ...over, + }, + state: {}, + }, + }); + + it("maps cron and one-shot triggers to the SDK spec and back", async () => { + const create = vi + .fn() + .mockResolvedValueOnce(spec({ maxFires: 3, timezone: "Europe/Rome" })) + .mockResolvedValueOnce( + spec({ + oneShotMaxRetries: 2, + oneShotRetry: true, + trigger: { cron: "", oneShot: ts(1_800_000_000) }, + }), + ); + const service = createMecatlScheduleService( + { schedules: { create } } as unknown as Client, + true, + ); + + const cron = await service.create({ + ...baseWriteRequest(), + maxFires: 3, + name: "s", + oneShotMaxRetries: 5, + oneShotRetry: true, + }); + expect(create.mock.calls[0]?.[0].spec).toMatchObject({ + maxFires: 3, + oneShotMaxRetries: 0, + oneShotRetry: false, + timezone: "Europe/Rome", + trigger: { cron: "0 9 * * *", oneShot: undefined }, + }); + expect(cron.trigger).toEqual({ + expression: "0 9 * * *", + kind: "cron", + timezone: "Europe/Rome", + }); + + const once = await service.create({ + ...baseWriteRequest(), + maxFires: 3, + name: "s", + oneShotMaxRetries: 2, + oneShotRetry: true, + trigger: { at: "2027-01-15T08:00:00.000Z", kind: "once" }, + }); + expect(create.mock.calls[1]?.[0].spec).toMatchObject({ + maxFires: 0, + oneShotMaxRetries: 2, + oneShotRetry: true, + timezone: "", + trigger: { cron: "", oneShot: { seconds: 1_800_000_000n } }, + }); + expect(once.trigger).toEqual({ at: "2027-01-15T08:00:00.000Z", kind: "once" }); + }); + + it("preserves the schedule's existing limits, selector, and parts on update", async () => { + const current = spec({ + carryContext: true, + fireTimeout: ts(600), + limits: { maxConsecutiveFailures: 4, maxToolCalls: 40, maxTurns: 9 }, + misfire: 2, + parts: [{ kind: "text" }], + selector: { modelId: "claude", providerId: "anthropic" }, + singleton: true, + timezone: "Europe/Rome", + }); + const get = vi.fn().mockResolvedValue(current); + const update = vi.fn().mockResolvedValue(current); + const service = createMecatlScheduleService( + { schedules: { get, update } } as unknown as Client, + true, + ); + + const result = await service.update("s", { ...baseWriteRequest(), prompt: "new prompt" }); + + expect(get).toHaveBeenCalledWith(expect.objectContaining({ name: "s" })); + expect(update.mock.calls[0]?.[0].spec).toMatchObject({ + carryContext: true, + fireTimeout: { seconds: 600n }, + limits: { maxConsecutiveFailures: 4, maxToolCalls: 40, maxTurns: 9 }, + misfire: 2, + name: "s", + parts: [{ kind: "text" }], + prompt: "new prompt", + selector: { modelId: "claude", providerId: "anthropic" }, + singleton: true, + }); + expect(result).toMatchObject({ modelId: "claude", providerId: "anthropic" }); + }); + + it("round-trips the permission mode with plan as the default", async () => { + const create = vi + .fn() + .mockResolvedValueOnce(spec({ mode: PermissionMode.DEFAULT })) + .mockResolvedValueOnce(spec({ mode: PermissionMode.ACCEPT_EDITS })) + .mockResolvedValueOnce(spec({ mode: PermissionMode.PLAN })); + const service = createMecatlScheduleService( + { schedules: { create } } as unknown as Client, + true, + ); + + const a = await service.create({ ...baseWriteRequest(), mode: "default", name: "s" }); + const b = await service.create({ ...baseWriteRequest(), mode: "acceptEdits", name: "s" }); + const c = await service.create({ ...baseWriteRequest(), name: "s" }); + expect(create.mock.calls.map((call) => call[0].spec.mode)).toEqual([ + PermissionMode.DEFAULT, + PermissionMode.ACCEPT_EDITS, + PermissionMode.PLAN, + ]); + expect([a.mode, b.mode, c.mode]).toEqual(["default", "acceptEdits", "plan"]); + }); + + it("derives schedule status from enabled, in-flight, and pending claims", async () => { + const rows = [ + { name: "paused", state: { enabled: false } }, + { name: "running", state: { enabled: true, lastFireStartedAt: ts(1_700_000_000) } }, + { name: "claimed", state: { enabled: true, lastFireSessionId: "pending" } }, + { + name: "scheduled", + state: { enabled: true, lastFireSessionId: "sess-9", nextFireAt: ts(1_800_000_000) }, + }, + ].map(({ name, state }) => ({ ...spec({ name }).schedule, state })); + const list = vi.fn().mockResolvedValue({ schedules: rows.reverse() }); + const service = createMecatlScheduleService({ schedules: { list } } as unknown as Client, true); + + const response = await service.list(); + + expect(response.supported).toBe(true); + expect(response.items.map((item) => [item.name, item.status])).toEqual([ + ["claimed", "claimed"], + ["paused", "paused"], + ["running", "running"], + ["scheduled", "scheduled"], + ]); + expect(response.items.find((item) => item.name === "claimed")?.lastFireSessionId).toBe(""); + expect(response.items.find((item) => item.name === "scheduled")).toMatchObject({ + lastFireSessionId: "sess-9", + nextFireAt: "2027-01-15T08:00:00.000Z", + }); + + const off = createMecatlScheduleService( + { schedules: { list } } as unknown as Client, + () => false, + ); + await expect(off.list()).resolves.toEqual({ + items: [], + reason: "Scheduling is not enabled on this Mecatl deployment.", + supported: false, + }); + }); + + it("lists fires newest first with in-flight derived from a missing stop", async () => { + const listFires = vi.fn().mockResolvedValue({ + fires: [ + { + err: "", + firedAt: ts(100), + id: "old", + scheduleName: "s", + sessionId: "a", + stop: "end_turn", + }, + { err: "", firedAt: ts(300), id: "", scheduleName: "s", sessionId: "ghost", stop: "" }, + { err: "boom", firedAt: ts(200), id: "live", scheduleName: "s", sessionId: "b", stop: "" }, + ], + }); + const service = createMecatlScheduleService( + { schedules: { listFires } } as unknown as Client, + true, + ); + + const response = await service.listFires("s"); + + expect(response.items.map((fire) => fire.id)).toEqual(["live", "old"]); + expect(response.items[0]).toMatchObject({ error: "boom", inFlight: true, stop: "" }); + expect(response.items[1]).toMatchObject({ inFlight: false, stop: "end_turn" }); + expect(response.items[1]?.firedAt).toBe("1970-01-01T00:01:40.000Z"); + }); +}); + +describe("Mecatl schedule terminal state and numeric bounds", () => { + const ts = (seconds: number) => ({ nanos: 0, seconds: BigInt(seconds) }); + const row = (name: string, state: Record) => ({ + spec: { + maxFires: 0, + mode: PermissionMode.PLAN, + mutating: false, + name, + oneShotMaxRetries: 0, + oneShotRetry: false, + profile: "", + prompt: "p", + timezone: "", + trigger: { cron: "0 9 * * *" }, + }, + state, + }); + + it("reports a fired one-shot and an exhausted cron as completed", async () => { + const list = vi.fn().mockResolvedValue({ + schedules: [ + // A one-shot that has run: the daemon's claim disabled it and cleared + // its next fire in the same write. + row("one-shot-done", { enabled: false, fireCount: 1, lastFireAt: ts(1_700_000_000) }), + // A cron that reached maxFires: same shape, several fires. + row("cron-exhausted", { enabled: false, fireCount: 5, lastFireAt: ts(1_700_000_000) }), + // Never fired and no next fire yet: still scheduled, not completed. + row("fresh", { enabled: true, fireCount: 0 }), + // A future fire pending. + row("upcoming", { enabled: true, fireCount: 2, nextFireAt: ts(1_900_000_000) }), + // Paused by an operator: disabled with its next fire kept. + row("paused", { enabled: false, fireCount: 3, nextFireAt: ts(1_900_000_000) }), + ], + }); + const service = createMecatlScheduleService({ schedules: { list } } as unknown as Client, true); + + const response = await service.list(); + + expect(response.items.map((item) => [item.name, item.status])).toEqual([ + ["cron-exhausted", "completed"], + ["fresh", "scheduled"], + ["one-shot-done", "completed"], + ["paused", "paused"], + ["upcoming", "scheduled"], + ]); + // A completed schedule keeps its fire count and reports the daemon's + // disabled flag as it is. + const done = response.items.find((item) => item.name === "one-shot-done"); + expect(done).toMatchObject({ enabled: false, fireCount: 1, nextFireAt: null }); + }); + + it("refuses counts a signed 32-bit daemon field cannot represent", () => { + const base = { + mode: "plan" as const, + mutating: false, + name: "s", + oneShotRetry: false, + profile: "all" as const, + prompt: "p", + trigger: { expression: "0 9 * * *", kind: "cron" as const, timezone: "UTC" }, + }; + // The ceiling itself is representable. + expect( + createScheduleRequestSchema.safeParse({ + ...base, + maxFires: maximumInt32, + oneShotMaxRetries: maximumInt32, + }).success, + ).toBe(true); + for (const bad of [ + { maxFires: maximumInt32 + 1, oneShotMaxRetries: 0 }, + { maxFires: 0, oneShotMaxRetries: maximumInt32 + 1 }, + { maxFires: Number.MAX_SAFE_INTEGER + 2, oneShotMaxRetries: 0 }, + { maxFires: 1.5, oneShotMaxRetries: 0 }, + { maxFires: -1, oneShotMaxRetries: 0 }, + { maxFires: Number.NaN, oneShotMaxRetries: 0 }, + { maxFires: Number.POSITIVE_INFINITY, oneShotMaxRetries: 0 }, + ]) { + expect( + createScheduleRequestSchema.safeParse({ ...base, ...bad }).success, + JSON.stringify(bad), + ).toBe(false); + } + }); +}); + +describe("Mecatl schedule trigger immutability", () => { + const ts = (seconds: number) => ({ nanos: 0, seconds: BigInt(seconds) }); + const cronSpec = { + maxFires: 0, + mode: PermissionMode.PLAN, + mutating: false, + name: "s", + oneShotMaxRetries: 0, + oneShotRetry: false, + profile: "", + prompt: "p", + timezone: "Europe/Rome", + trigger: { cron: "0 9 * * *" }, + }; + // 1_800_000_000 is 2027-01-15T08:00:00Z. + const onceSpec = { ...cronSpec, timezone: "", trigger: { cron: "", oneShot: ts(1_800_000_000) } }; + const serviceOver = (spec: Record) => { + const get = vi.fn().mockResolvedValue({ schedule: { spec, state: {} } }); + const update = vi.fn().mockResolvedValue({ schedule: { spec, state: {} } }); + const service = createMecatlScheduleService( + { schedules: { get, update } } as unknown as Client, + true, + ); + return { service, update }; + }; + + it("refuses an update that changes the trigger without calling the SDK update", async () => { + const cases = [ + [cronSpec, { expression: "0 10 * * *", kind: "cron", timezone: "Europe/Rome" }], + [cronSpec, { expression: "0 9 * * *", kind: "cron", timezone: "UTC" }], + [cronSpec, { at: "2027-01-15T08:00:00.000Z", kind: "once" }], + [onceSpec, { at: "2027-01-19T08:00:00.000Z", kind: "once" }], + [onceSpec, { expression: "0 9 * * *", kind: "cron", timezone: "" }], + ] as const; + for (const [stored, trigger] of cases) { + const { service, update } = serviceOver(stored); + await expect( + service.update("s", { ...baseWriteRequest(), trigger }), + JSON.stringify(trigger), + ).rejects.toBeInstanceOf(ScheduleTriggerImmutableError); + expect(update).not.toHaveBeenCalled(); + } + }); + + it("applies a non-trigger edit when the trigger names the stored instant", async () => { + const { service, update } = serviceOver(onceSpec); + + await service.update("s", { + ...baseWriteRequest(), + prompt: "new prompt", + // The stored instant, written with a numeric offset. + trigger: { at: "2027-01-15T10:00:00+02:00", kind: "once" }, + }); + + expect(update.mock.calls[0]?.[0].spec).toMatchObject({ + prompt: "new prompt", + timezone: "", + trigger: { cron: "", oneShot: { seconds: 1_800_000_000n } }, + }); + }); + + it("applies a non-trigger edit to a recurring schedule", async () => { + const { service, update } = serviceOver(cronSpec); + + await service.update("s", { ...baseWriteRequest(), maxFires: 4, prompt: "new prompt" }); + + expect(update.mock.calls[0]?.[0].spec).toMatchObject({ + maxFires: 4, + prompt: "new prompt", + timezone: "Europe/Rome", + trigger: { cron: "0 9 * * *" }, + }); + }); +}); + +describe("Mecatl schedule trigger validation", () => { + const cron = (timezone: string) => ({ + expression: "0 9 * * *", + kind: "cron" as const, + timezone, + }); + + it("accepts IANA time zones and the empty UTC choice", () => { + for (const timezone of ["", "UTC", "Europe/Rome", "US/Eastern", "Etc/GMT+5"]) { + expect(isScheduleTimeZone(timezone), timezone).toBe(true); + expect(scheduleTriggerSchema.safeParse(cron(timezone)).success, timezone).toBe(true); + } + }); + + it("refuses a time zone the daemon would silently replace with UTC", () => { + for (const timezone of ["Mars/Olympus", "europe/rome", "EUROPE/ROME", "+02:00", "Local"]) { + expect(isScheduleTimeZone(timezone), timezone).toBe(false); + expect(scheduleTriggerSchema.safeParse(cron(timezone)).success, timezone).toBe(false); + } + }); + + it("accepts a one-shot instant with a numeric UTC offset but not a bare local time", () => { + for (const at of ["2027-01-15T08:00:00.000Z", "2027-01-15T10:00:00+02:00"]) { + expect(scheduleTriggerSchema.safeParse({ at, kind: "once" }).success, at).toBe(true); + } + expect( + scheduleTriggerSchema.safeParse({ at: "2027-01-15T10:00:00", kind: "once" }).success, + ).toBe(false); + }); +}); diff --git a/apps/server/src/mecatl/schedules.ts b/apps/server/src/mecatl/schedules.ts new file mode 100644 index 0000000000..e43bd8166a --- /dev/null +++ b/apps/server/src/mecatl/schedules.ts @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { + CreateScheduleRequest, + ListScheduleFiresResponse, + ListSchedulesResponse, + ScheduleResponse, + UpdateScheduleRequest, +} from "@mecatl-studio/contracts"; +import type { Client } from "@stacklok-oss/mecatl-sdk"; +import { + PermissionMode, + type Schedule, + type ScheduleFire, + type ScheduleSpec, +} from "@stacklok-oss/mecatl-sdk/gen"; + +/** + * The daemon keeps a schedule's stored next fire when its spec is updated, so + * a changed trigger would be saved but never take effect: a one-shot moved to + * a later day still fires at the old instant, and a finished one-shot stays + * finished. Updates therefore refuse any trigger change. + */ +export class ScheduleTriggerImmutableError extends Error { + constructor(name: string) { + super( + `The trigger of schedule "${name}" cannot be changed. To change when it runs, create a new schedule and delete this one.`, + ); + this.name = "ScheduleTriggerImmutableError"; + } +} + +export interface ScheduleService { + readonly supported: boolean; + create(request: CreateScheduleRequest): Promise; + delete(name: string): Promise; + fire(name: string): Promise; + list(): Promise; + listFires(name: string): Promise; + pause(name: string): Promise; + resume(name: string): Promise; + update(name: string, request: UpdateScheduleRequest): Promise; +} + +export function createMecatlScheduleService( + client: Client, + supported: boolean | (() => boolean), +): ScheduleService { + const isSupported = typeof supported === "function" ? supported : () => supported; + + return { + get supported() { + return isSupported(); + }, + + async create(request) { + const response = await client.schedules.create({ + $typeName: "mecatl.v1.CreateScheduleRequest", + spec: toSdkSpec(request.name, request), + }); + return scheduleFromSdk(response.schedule); + }, + + async delete(name) { + await client.schedules.delete({ $typeName: "mecatl.v1.DeleteScheduleRequest", name }); + }, + + async fire(name) { + await client.schedules.fireNow({ $typeName: "mecatl.v1.FireNowRequest", name }); + }, + + async list() { + if (!isSupported()) { + return { + items: [], + reason: "Scheduling is not enabled on this Mecatl deployment.", + supported: false, + }; + } + const response = await client.schedules.list({ + $typeName: "mecatl.v1.ListSchedulesRequest", + }); + return { + items: response.schedules + .map(scheduleFromSdk) + .sort((left, right) => left.name.localeCompare(right.name)), + reason: "", + supported: true, + }; + }, + + async listFires(name) { + const response = await client.schedules.listFires({ + $typeName: "mecatl.v1.ListFiresRequest", + scheduleName: name, + }); + return { + items: response.fires + .flatMap((fire) => (fire.id ? [fireFromSdk(fire)] : [])) + .sort((left, right) => (right.firedAt ?? "").localeCompare(left.firedAt ?? "")), + }; + }, + + async pause(name) { + await client.schedules.pause({ $typeName: "mecatl.v1.PauseScheduleRequest", name }); + }, + + async resume(name) { + await client.schedules.resume({ $typeName: "mecatl.v1.ResumeScheduleRequest", name }); + }, + + async update(name, request) { + const current = await client.schedules.get({ + $typeName: "mecatl.v1.GetScheduleRequest", + name, + }); + const stored = current.schedule?.spec; + if (stored && !sameTrigger(request.trigger, stored)) { + throw new ScheduleTriggerImmutableError(name); + } + const response = await client.schedules.update({ + $typeName: "mecatl.v1.UpdateScheduleRequest", + spec: toSdkSpec(name, request, stored), + }); + return scheduleFromSdk(response.schedule); + }, + }; +} + +function toSdkSpec( + name: string, + request: UpdateScheduleRequest, + current?: ScheduleSpec, +): ScheduleSpec { + const cron = request.trigger.kind === "cron" ? request.trigger : undefined; + const once = request.trigger.kind === "once" ? request.trigger : undefined; + + return { + $typeName: "mecatl.v1.ScheduleSpec", + carryContext: current?.carryContext ?? false, + createdAt: undefined, + fireTimeout: current?.fireTimeout, + limits: current?.limits ?? { + $typeName: "mecatl.v1.Limits", + maxConsecutiveFailures: 0, + maxToolCalls: 0, + maxTurns: 0, + }, + maxFires: cron ? request.maxFires : 0, + misfire: current?.misfire ?? 0, + mode: modeToSdk(request.mode), + mutating: request.mutating, + name, + oneShotMaxRetries: once && request.oneShotRetry ? request.oneShotMaxRetries : 0, + oneShotRetry: Boolean(once && request.oneShotRetry), + owner: undefined, + parts: current?.parts ?? [], + profile: profileToSdk(request.profile), + prompt: request.prompt, + selector: current?.selector, + singleton: current?.singleton ?? false, + timezone: cron?.timezone ?? "", + trigger: { + $typeName: "mecatl.v1.TriggerSpec", + cron: cron?.expression ?? "", + oneShot: once ? isoToTimestamp(once.at) : undefined, + }, + }; +} + +/** Whether a requested trigger names the same firing rule as the stored spec. */ +function sameTrigger(requested: UpdateScheduleRequest["trigger"], stored: ScheduleSpec): boolean { + const cron = stored.trigger?.cron ?? ""; + if (requested.kind === "cron") { + return cron !== "" && requested.expression === cron && requested.timezone === stored.timezone; + } + if (cron !== "") return false; + const storedAt = timestampToIso(stored.trigger?.oneShot); + // Compare instants, not strings: the same moment may be written with a + // numeric offset or in UTC. + return storedAt !== null && new Date(requested.at).getTime() === new Date(storedAt).getTime(); +} + +function scheduleFromSdk(schedule: Schedule | undefined): ScheduleResponse { + const spec = schedule?.spec; + const state = schedule?.state; + if (!spec?.name || !spec.trigger) { + throw new Error("Mecatl returned an incomplete schedule"); + } + + const lastFireSessionId = state?.lastFireSessionId ?? ""; + const running = timestampToIso(state?.lastFireStartedAt) !== null; + const nextFireAt = timestampToIso(state?.nextFireAt); + const fireCount = state?.fireCount ?? 0; + // A one-shot that has run, or a cron that reached maxFires, has nothing + // left to do: the daemon's claim clears the next fire and disables it in + // the same write. That is "completed", not "paused" — resuming it would + // schedule nothing. An in-flight last fire still reads as running. + const finished = nextFireAt === null && fireCount > 0; + const status = running + ? "running" + : lastFireSessionId === "pending" + ? "claimed" + : finished + ? "completed" + : !state?.enabled + ? "paused" + : "scheduled"; + + return { + enabled: state?.enabled ?? false, + fireCount, + lastFireAt: timestampToIso(state?.lastFireAt), + lastFireSessionId: lastFireSessionId === "pending" ? "" : lastFireSessionId, + maxFires: spec.maxFires, + mode: modeFromSdk(spec.mode), + modelId: spec.selector?.modelId ?? "", + mutating: spec.mutating, + name: spec.name, + nextFireAt, + oneShotMaxRetries: spec.oneShotMaxRetries, + oneShotRetry: spec.oneShotRetry, + owner: spec.owner?.name || spec.owner?.subject || "", + profile: profileFromSdk(spec.profile), + prompt: spec.prompt, + providerId: spec.selector?.providerId ?? "", + status, + trigger: spec.trigger.cron + ? { expression: spec.trigger.cron, kind: "cron", timezone: spec.timezone } + : { at: requiredTimestamp(spec.trigger.oneShot), kind: "once" }, + }; +} + +function fireFromSdk(fire: ScheduleFire) { + return { + deadline: timestampToIso(fire.deadline), + error: fire.err, + firedAt: timestampToIso(fire.firedAt), + id: fire.id, + inFlight: !fire.stop, + progressAt: timestampToIso(fire.progressAt), + scheduleName: fire.scheduleName, + sessionId: fire.sessionId, + startedAt: timestampToIso(fire.startedAt), + stop: fire.stop, + }; +} + +function modeToSdk(mode: UpdateScheduleRequest["mode"]): PermissionMode { + if (mode === "default") return PermissionMode.DEFAULT; + if (mode === "acceptEdits") return PermissionMode.ACCEPT_EDITS; + return PermissionMode.PLAN; +} + +function modeFromSdk(mode: PermissionMode): ScheduleResponse["mode"] { + if (mode === PermissionMode.DEFAULT) return "default"; + if (mode === PermissionMode.ACCEPT_EDITS) return "acceptEdits"; + return "plan"; +} + +function profileToSdk(profile: UpdateScheduleRequest["profile"]): string { + return profile === "noFilesystem" ? "no-fs" : ""; +} + +function profileFromSdk(profile: string): ScheduleResponse["profile"] { + return profile === "no-fs" ? "noFilesystem" : "all"; +} + +function isoToTimestamp(value: string) { + const milliseconds = new Date(value).getTime(); + const seconds = Math.floor(milliseconds / 1_000); + return { + $typeName: "google.protobuf.Timestamp" as const, + nanos: (milliseconds - seconds * 1_000) * 1_000_000, + seconds: BigInt(seconds), + }; +} + +function requiredTimestamp(value: Parameters[0]): string { + const result = timestampToIso(value); + if (result === null) throw new Error("Mecatl returned a one-shot schedule without a time"); + return result; +} + +function timestampToIso(value: { nanos: number; seconds: bigint } | undefined): string | null { + if (!value || value.seconds <= 0n) return null; + return new Date( + Number(value.seconds) * 1_000 + Math.floor(value.nanos / 1_000_000), + ).toISOString(); +} diff --git a/apps/server/src/routes/schedules.test.ts b/apps/server/src/routes/schedules.test.ts new file mode 100644 index 0000000000..f34d7cbee2 --- /dev/null +++ b/apps/server/src/routes/schedules.test.ts @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { ScheduleResponse } from "@mecatl-studio/contracts"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createApp } from "../app"; +import { type ScheduleService, ScheduleTriggerImmutableError } from "../mecatl/schedules"; +import { csrfHeaders } from "../testing/fakes"; + +const example: ScheduleResponse = { + enabled: true, + fireCount: 2, + lastFireAt: "2026-09-15T09:00:00.000Z", + lastFireSessionId: "session-1", + maxFires: 0, + mode: "plan", + modelId: "", + mutating: false, + name: "daily-summary", + nextFireAt: "2026-09-16T09:00:00.000Z", + oneShotMaxRetries: 0, + oneShotRetry: false, + owner: "", + profile: "all", + prompt: "Summarize the day", + providerId: "", + status: "scheduled", + trigger: { expression: "0 9 * * *", kind: "cron", timezone: "Europe/Rome" }, +}; + +const calls: string[] = []; +const schedules: ScheduleService = { + supported: true, + async create() { + calls.push("create"); + return example; + }, + async delete() { + calls.push("delete"); + }, + async fire() { + calls.push("fire"); + }, + async list() { + return { items: [example], reason: "", supported: true }; + }, + async listFires() { + return { items: [] }; + }, + async pause() { + calls.push("pause"); + }, + async resume() { + calls.push("resume"); + }, + async update() { + calls.push("update"); + return example; + }, +}; + +const app = createApp({ schedules }); + +const mutating = (body?: unknown, method = "POST") => ({ + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + headers: csrfHeaders("t", { "Content-Type": "application/json" }), + method, +}); + +describe("schedule routes", () => { + beforeEach(() => calls.splice(0)); + + it("lists normalized schedules", async () => { + const response = await app.request("/api/v1/schedules"); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ items: [{ name: "daily-summary" }] }); + }); + + it("routes lifecycle actions", async () => { + for (const action of ["fire", "pause", "resume"] as const) { + const response = await app.request( + "/api/v1/schedules/daily-summary/actions", + mutating({ action }), + ); + expect(response.status).toBe(204); + } + const removed = await app.request( + "/api/v1/schedules/daily-summary", + mutating(undefined, "DELETE"), + ); + expect(removed.status).toBe(204); + expect(calls).toEqual(["fire", "pause", "resume", "delete"]); + }); + + it("answers 409 schedule_trigger_immutable when an update changes the trigger", async () => { + const refusing = createApp({ + schedules: { + ...schedules, + async update(name) { + calls.push("update"); + throw new ScheduleTriggerImmutableError(name); + }, + }, + }); + const { name: _name, ...body } = example; + const response = await refusing.request( + "/api/v1/schedules/daily-summary", + mutating({ ...body, trigger: { at: "2027-01-15T08:00:00.000Z", kind: "once" } }, "PUT"), + ); + expect(response.status).toBe(409); + expect(response.headers.get("Content-Type")).toContain("application/problem+json"); + expect(await response.json()).toMatchObject({ + code: "schedule_trigger_immutable", + detail: expect.stringContaining("create a new schedule"), + status: 409, + }); + }); + + it("still applies an update that keeps the trigger", async () => { + const { name: _name, ...body } = example; + const response = await app.request( + "/api/v1/schedules/daily-summary", + mutating({ ...body, prompt: "Summarize the week" }, "PUT"), + ); + expect(response.status).toBe(200); + expect(calls).toEqual(["update"]); + }); + + it("refuses an unknown cron time zone with a 400 problem before reaching Mecatl", async () => { + const { name: _name, ...body } = example; + const trigger = { expression: "0 9 * * *", kind: "cron", timezone: "Mars/Olympus" }; + for (const [path, init] of [ + ["/api/v1/schedules", mutating({ ...body, name: "x", trigger })], + ["/api/v1/schedules/daily-summary", mutating({ ...body, trigger }, "PUT")], + ] as const) { + const response = await app.request(path, init); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + code: "invalid_schedule", + detail: expect.stringContaining("Mars/Olympus"), + }); + } + expect(calls).toEqual([]); + }); + + it("accepts an empty time zone and a one-shot instant with a numeric offset", async () => { + const { name: _name, ...body } = example; + for (const trigger of [ + { expression: "0 9 * * *", kind: "cron", timezone: "" }, + { at: "2027-01-15T10:00:00+02:00", kind: "once" }, + ]) { + const response = await app.request( + "/api/v1/schedules", + mutating({ ...body, name: "x", trigger }), + ); + expect(response.status, JSON.stringify(trigger)).toBe(201); + } + expect(calls).toEqual(["create", "create"]); + }); + + it("capability-disables unsupported mutations", async () => { + const unsupported = createApp({ schedules: { ...schedules, supported: false } }); + const response = await unsupported.request( + "/api/v1/schedules/daily-summary", + mutating(undefined, "DELETE"), + ); + expect(response.status).toBe(501); + expect(await response.json()).toMatchObject({ code: "schedule_unsupported" }); + + const list = await unsupported.request("/api/v1/schedules"); + expect(list.status).toBe(200); + for (const [path, init] of [ + ["/api/v1/schedules", mutating({ ...example, name: "x" })], + ["/api/v1/schedules/daily-summary", mutating(example, "PUT")], + ["/api/v1/schedules/daily-summary/actions", mutating({ action: "fire" })], + ["/api/v1/schedules/daily-summary/fires", undefined], + ] as const) { + const blocked = await unsupported.request(path, init); + expect(blocked.status).toBe(501); + } + }); + + it("answers 503 runtime_unavailable on every schedules route without a runtime", async () => { + const detached = createApp(); + for (const [path, init] of [ + ["/api/v1/schedules", undefined], + ["/api/v1/schedules", mutating({ ...example, name: "x" })], + ["/api/v1/schedules/daily-summary", mutating(example, "PUT")], + ["/api/v1/schedules/daily-summary/actions", mutating({ action: "pause" })], + ["/api/v1/schedules/daily-summary", mutating(undefined, "DELETE")], + ["/api/v1/schedules/daily-summary/fires", undefined], + ] as const) { + const response = await detached.request(path, init); + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ code: "runtime_unavailable" }); + } + }); + + it("schedule mutations require the CSRF pair and a session when interactive login is active", async () => { + for (const [path, init] of [ + ["/api/v1/schedules", { body: "{}", method: "POST" }], + ["/api/v1/schedules/daily-summary", { body: "{}", method: "PUT" }], + ["/api/v1/schedules/daily-summary/actions", { body: "{}", method: "POST" }], + ["/api/v1/schedules/daily-summary", { method: "DELETE" }], + ] as const) { + const response = await app.request(path, { + ...init, + headers: { "Content-Type": "application/json" }, + }); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code: "cross_site_request" }); + } + const gated = createApp({ + authentication: { + clear: () => undefined, + completeLogin: async () => { + throw new Error("unused"); + }, + credential: async () => ({ status: "anonymous" }), + logout: async () => undefined, + noteLoginComplete: () => undefined, + noteLoginFailure: () => undefined, + save: async () => undefined, + startLogin: async () => "https://issuer.example.com/authorize", + }, + schedules, + }); + const list = await gated.request("/api/v1/schedules"); + expect(list.status).toBe(401); + const fire = await gated.request( + "/api/v1/schedules/daily-summary/actions", + mutating({ action: "fire" }), + ); + expect(fire.status).toBe(401); + expect(calls).toEqual([]); + }); +}); diff --git a/apps/server/src/routes/schedules.ts b/apps/server/src/routes/schedules.ts new file mode 100644 index 0000000000..ae615c630c --- /dev/null +++ b/apps/server/src/routes/schedules.ts @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi"; +import { + createScheduleRequestSchema, + listScheduleFiresResponseSchema, + listSchedulesResponseSchema, + problemDetailsSchema, + scheduleActionRequestSchema, + scheduleSchema, + updateScheduleRequestSchema, +} from "@mecatl-studio/contracts"; +import type { AppEnv } from "../http/env.js"; +import { problem } from "../http/problem.js"; +import { type ScheduleService, ScheduleTriggerImmutableError } from "../mecatl/schedules.js"; + +const scheduleParameters = z.object({ + scheduleName: z + .string() + .min(1) + .openapi({ param: { in: "path", name: "scheduleName" } }), +}); + +const errorResponse = { + content: { "application/problem+json": { schema: problemDetailsSchema } }, + description: "The request could not be completed.", +} as const; + +const invalidResponse = { + content: { "application/problem+json": { schema: problemDetailsSchema } }, + description: "The schedule is invalid, for example an unknown time zone.", +} as const; + +const unavailableResponse = { + content: { "application/problem+json": { schema: problemDetailsSchema } }, + description: "The Mecatl runtime or scheduler is unavailable.", +} as const; + +const listRoute = createRoute({ + method: "get", + operationId: "listSchedules", + path: "/api/v1/schedules", + responses: { + 200: { + content: { "application/json": { schema: listSchedulesResponseSchema } }, + description: "The visible scheduled work.", + }, + 500: errorResponse, + 503: unavailableResponse, + }, +}); + +const createRouteDefinition = createRoute({ + method: "post", + operationId: "createSchedule", + path: "/api/v1/schedules", + request: { + body: { + content: { "application/json": { schema: createScheduleRequestSchema } }, + required: true, + }, + }, + responses: { + 201: { + content: { "application/json": { schema: scheduleSchema } }, + description: "The created schedule.", + }, + 400: invalidResponse, + 500: errorResponse, + 501: unavailableResponse, + 503: unavailableResponse, + }, +}); + +const updateRoute = createRoute({ + method: "put", + operationId: "updateSchedule", + path: "/api/v1/schedules/{scheduleName}", + request: { + body: { + content: { "application/json": { schema: updateScheduleRequestSchema } }, + required: true, + }, + params: scheduleParameters, + }, + responses: { + 200: { + content: { "application/json": { schema: scheduleSchema } }, + description: "The updated schedule.", + }, + 400: invalidResponse, + 409: { + content: { "application/problem+json": { schema: problemDetailsSchema } }, + description: + "The update changes the schedule's trigger, which cannot be edited; create a new schedule instead.", + }, + 500: errorResponse, + 501: unavailableResponse, + 503: unavailableResponse, + }, +}); + +const actionRoute = createRoute({ + method: "post", + operationId: "actOnSchedule", + path: "/api/v1/schedules/{scheduleName}/actions", + request: { + body: { + content: { "application/json": { schema: scheduleActionRequestSchema } }, + required: true, + }, + params: scheduleParameters, + }, + responses: { + 204: { description: "The schedule action was accepted." }, + 500: errorResponse, + 501: unavailableResponse, + 503: unavailableResponse, + }, +}); + +const deleteRoute = createRoute({ + method: "delete", + operationId: "deleteSchedule", + path: "/api/v1/schedules/{scheduleName}", + request: { params: scheduleParameters }, + responses: { + 204: { description: "The schedule was deleted." }, + 500: errorResponse, + 501: unavailableResponse, + 503: unavailableResponse, + }, +}); + +const firesRoute = createRoute({ + method: "get", + operationId: "listScheduleFires", + path: "/api/v1/schedules/{scheduleName}/fires", + request: { params: scheduleParameters }, + responses: { + 200: { + content: { "application/json": { schema: listScheduleFiresResponseSchema } }, + description: "The schedule run history.", + }, + 500: errorResponse, + 501: unavailableResponse, + 503: unavailableResponse, + }, +}); + +export function registerScheduleRoutes( + app: OpenAPIHono, + schedules: ScheduleService | undefined, +) { + app.openapi(listRoute, async (context) => { + if (!schedules) return unavailable(context); + return context.json(await schedules.list(), 200); + }); + + app.openapi( + createRouteDefinition, + async (context) => { + if (!schedules) return unavailable(context); + if (!schedules.supported) return unsupported(context); + return context.json(await schedules.create(context.req.valid("json")), 201); + }, + (result, context) => (result.success ? undefined : invalidSchedule(context, result.error)), + ); + + app.openapi( + updateRoute, + async (context) => { + if (!schedules) return unavailable(context); + if (!schedules.supported) return unsupported(context); + const { scheduleName } = context.req.valid("param"); + try { + return context.json(await schedules.update(scheduleName, context.req.valid("json")), 200); + } catch (error) { + if (!(error instanceof ScheduleTriggerImmutableError)) throw error; + return problem( + context, + 409, + "schedule_trigger_immutable", + "Schedule trigger cannot change", + error.message, + ); + } + }, + (result, context) => (result.success ? undefined : invalidSchedule(context, result.error)), + ); + + app.openapi(actionRoute, async (context) => { + if (!schedules) return unavailable(context); + if (!schedules.supported) return unsupported(context); + const { scheduleName } = context.req.valid("param"); + const { action } = context.req.valid("json"); + await schedules[action](scheduleName); + return context.body(null, 204); + }); + + app.openapi(deleteRoute, async (context) => { + if (!schedules) return unavailable(context); + if (!schedules.supported) return unsupported(context); + await schedules.delete(context.req.valid("param").scheduleName); + return context.body(null, 204); + }); + + app.openapi(firesRoute, async (context) => { + if (!schedules) return unavailable(context); + if (!schedules.supported) return unsupported(context); + return context.json(await schedules.listFires(context.req.valid("param").scheduleName), 200); + }); +} + +function unavailable(context: Parameters[0]) { + return problem( + context, + 503, + "runtime_unavailable", + "Mecatl runtime unavailable", + "The BFF has no Mecatl runtime connection.", + ); +} + +/** + * A schedule body the contract rejects, answered as a problem whose detail + * names the first offending field, so the form can show why it was refused. + */ +function invalidSchedule(context: Parameters[0], error: z.ZodError) { + const issue = error.issues[0]; + const field = issue?.path.join("."); + const detail = issue + ? `${field ? `${field}: ` : ""}${issue.message}` + : "The schedule is not valid."; + return problem(context, 400, "invalid_schedule", "Invalid schedule", detail); +} + +function unsupported(context: Parameters[0]) { + return problem( + context, + 501, + "schedule_unsupported", + "Scheduling unavailable", + "Scheduling is not enabled on this Mecatl deployment.", + ); +} diff --git a/apps/web/src/components/shell/nav-items.ts b/apps/web/src/components/shell/nav-items.ts index 3d60fc898a..ae32195a88 100644 --- a/apps/web/src/components/shell/nav-items.ts +++ b/apps/web/src/components/shell/nav-items.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import { MessageCircle } from "lucide-react"; +import { Clock3, MessageCircle } from "lucide-react"; /** * Primary navigation. Each feature plan appends its own entry (schedules, @@ -13,4 +13,10 @@ export const navItems = [ to: "/workspace/chat", icon: MessageCircle, }, + { + key: "schedules", + label: "Scheduled", + to: "/workspace/schedules", + icon: Clock3, + }, ] as const; diff --git a/apps/web/src/features/schedules/cron-builder.test.ts b/apps/web/src/features/schedules/cron-builder.test.ts new file mode 100644 index 0000000000..6c3e8832f7 --- /dev/null +++ b/apps/web/src/features/schedules/cron-builder.test.ts @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + builderToCron, + type CronBuilderShape, + cronToBuilder, + describeCron, + ordinal, +} from "./cron-builder"; + +describe("builderToCron", () => { + it("derives each repeat shape", () => { + expect(builderToCron({ repeat: "daily", time: "09:00" })).toBe("0 9 * * *"); + expect(builderToCron({ repeat: "weekdays", time: "17:30" })).toBe("30 17 * * 1-5"); + expect(builderToCron({ repeat: "weekly", time: "08:15", weekday: 5 })).toBe("15 8 * * 5"); + expect(builderToCron({ repeat: "weekly", time: "00:00", weekday: 0 })).toBe("0 0 * * 0"); + expect(builderToCron({ repeat: "monthly", time: "23:59", monthday: 28 })).toBe("59 23 28 * *"); + }); + + it("defaults a missing weekday/monthday", () => { + expect(builderToCron({ repeat: "weekly", time: "09:00" })).toBe("0 9 * * 1"); + expect(builderToCron({ repeat: "monthly", time: "09:00" })).toBe("0 9 1 * *"); + }); + + it("never derives an empty or malformed cron from a blank/invalid time", () => { + expect(builderToCron({ repeat: "daily", time: "" })).toBe("0 9 * * *"); + expect(builderToCron({ repeat: "daily", time: "nonsense" })).toBe("0 9 * * *"); + expect(builderToCron({ repeat: "daily", time: "25:00" })).toBe("0 9 * * *"); + expect(builderToCron({ repeat: "daily", time: "12:75" })).toBe("0 9 * * *"); + }); + + it("clamps out-of-range day picks instead of emitting them", () => { + expect(builderToCron({ repeat: "weekly", time: "09:00", weekday: 9 })).toBe("0 9 * * 6"); + expect(builderToCron({ repeat: "monthly", time: "09:00", monthday: 31 })).toBe("0 9 28 * *"); + expect(builderToCron({ repeat: "monthly", time: "09:00", monthday: 0 })).toBe("0 9 1 * *"); + }); + + it("derives the interval shapes, ignoring the time control", () => { + expect(builderToCron({ every: 15, repeat: "interval", time: "17:30", unit: "minutes" })).toBe( + "*/15 * * * *", + ); + expect(builderToCron({ every: 2, repeat: "interval", time: "", unit: "hours" })).toBe( + "0 */2 * * *", + ); + // One hour is the canonical `0 * * * *`, not a `*/1` step. + expect(builderToCron({ every: 1, repeat: "interval", time: "", unit: "hours" })).toBe( + "0 * * * *", + ); + // A one-minute step keeps the step form so it reads back as an interval. + expect(builderToCron({ every: 1, repeat: "interval", time: "", unit: "minutes" })).toBe( + "*/1 * * * *", + ); + }); + + it("defaults a missing interval to every 30 minutes", () => { + expect(builderToCron({ repeat: "interval", time: "09:00" })).toBe("*/30 * * * *"); + expect(builderToCron({ repeat: "interval", time: "09:00", unit: "hours" })).toBe("0 * * * *"); + }); + + it("clamps the interval step to the unit's range", () => { + expect(builderToCron({ every: 0, repeat: "interval", time: "", unit: "minutes" })).toBe( + "*/1 * * * *", + ); + expect(builderToCron({ every: 90, repeat: "interval", time: "", unit: "minutes" })).toBe( + "*/59 * * * *", + ); + expect(builderToCron({ every: 30, repeat: "interval", time: "", unit: "hours" })).toBe( + "0 */23 * * *", + ); + expect(builderToCron({ every: 0, repeat: "interval", time: "", unit: "hours" })).toBe( + "0 * * * *", + ); + expect( + builderToCron({ every: Number.NaN, repeat: "interval", time: "", unit: "minutes" }), + ).toBe("*/1 * * * *"); + }); +}); + +describe("cronToBuilder", () => { + it("recognises the fixed-time builder shapes", () => { + expect(cronToBuilder("0 9 * * *")).toEqual({ + every: 30, + monthday: 1, + repeat: "daily", + time: "09:00", + unit: "minutes", + weekday: 1, + }); + expect(cronToBuilder("30 17 * * 1-5")).toMatchObject({ repeat: "weekdays", time: "17:30" }); + expect(cronToBuilder("15 8 * * 5")).toMatchObject({ + repeat: "weekly", + time: "08:15", + weekday: 5, + }); + expect(cronToBuilder("0 0 * * 0")).toMatchObject({ repeat: "weekly", weekday: 0 }); + expect(cronToBuilder("59 23 28 * *")).toMatchObject({ + monthday: 28, + repeat: "monthly", + time: "23:59", + }); + }); + + it("tolerates padded numerics and extra whitespace", () => { + expect(cronToBuilder(" 00 09 * * * ")).toMatchObject({ repeat: "daily", time: "09:00" }); + }); + + it("recognises the interval shapes", () => { + expect(cronToBuilder("*/15 * * * *")).toMatchObject({ + every: 15, + repeat: "interval", + unit: "minutes", + }); + expect(cronToBuilder("*/1 * * * *")).toMatchObject({ + every: 1, + repeat: "interval", + unit: "minutes", + }); + expect(cronToBuilder("0 */2 * * *")).toMatchObject({ + every: 2, + repeat: "interval", + unit: "hours", + }); + expect(cronToBuilder("0 * * * *")).toMatchObject({ + every: 1, + repeat: "interval", + unit: "hours", + }); + expect(cronToBuilder("0 */1 * * *")).toMatchObject({ + every: 1, + repeat: "interval", + unit: "hours", + }); + }); + + const exotic = [ + "*/15 * * * 1-5", // step minute pinned to weekdays + "*/5 9 * * *", // step minute within a fixed hour + "*/60 * * * *", // step beyond the minute range + "*/0 * * * *", // zero step + "0 */24 * * *", // step beyond the hour range + "30 * * * *", // fixed minute every hour is not a builder shape + "* * * * *", // every minute spelled without a step + "0 9 * * 1,3,5", // day list + "0 9 * * 2-4", // range other than 1-5 + "0 9 * * 7", // out-of-range weekday + "0 9 1 1 *", // pinned month + "0 9 29 * *", // monthday beyond 28 + "0 9 1-5 * *", // day-of-month range + "0 9,17 * * *", // hour list + "60 9 * * *", // out-of-range minute + "0 24 * * *", // out-of-range hour + "0 9 * *", // four fields + "0 9 * * * *", // six fields + "", // empty + "@daily", // macro + ]; + it.each(exotic)("files %j under custom", (cron) => { + expect(cronToBuilder(cron).repeat).toBe("custom"); + }); + + it("returns neutral control defaults for custom", () => { + expect(cronToBuilder("0 9 * * 1,3,5")).toEqual({ + every: 30, + monthday: 1, + repeat: "custom", + time: "09:00", + unit: "minutes", + weekday: 1, + }); + }); +}); + +describe("round-trips", () => { + const shapes: CronBuilderShape[] = [ + { repeat: "daily", time: "09:00" }, + { repeat: "daily", time: "00:00" }, + { repeat: "daily", time: "23:59" }, + { repeat: "weekdays", time: "06:45" }, + ...Array.from({ length: 7 }, (_, weekday) => ({ + repeat: "weekly" as const, + time: "12:30", + weekday, + })), + ...[1, 2, 14, 27, 28].map((monthday) => ({ + repeat: "monthly" as const, + time: "07:05", + monthday, + })), + ...[1, 5, 15, 30, 59].map((every) => ({ + repeat: "interval" as const, + time: "09:00", + every, + unit: "minutes" as const, + })), + ...[1, 2, 6, 12, 23].map((every) => ({ + repeat: "interval" as const, + time: "09:00", + every, + unit: "hours" as const, + })), + ]; + + it.each(shapes)("builder → cron → builder is lossless for %j", (shape) => { + const cron = builderToCron(shape); + const back = cronToBuilder(cron); + expect(back.repeat).toBe(shape.repeat); + expect(back.time).toBe(shape.time); + if (shape.repeat === "weekly") expect(back.weekday).toBe(shape.weekday); + if (shape.repeat === "monthly") expect(back.monthday).toBe(shape.monthday); + if (shape.repeat === "interval") { + expect(back.every).toBe(shape.every); + expect(back.unit).toBe(shape.unit); + } + // And the derived string is stable through a second derivation. + if (back.repeat === "custom") throw new Error("round-trip lost shape"); + expect(builderToCron({ ...back, repeat: back.repeat })).toBe(cron); + }); + + it("cron → builder → cron is stable for builder-shaped strings", () => { + for (const cron of [ + "0 9 * * *", + "30 17 * * 1-5", + "15 8 * * 5", + "59 23 28 * *", + "*/15 * * * *", + "0 */2 * * *", + "0 * * * *", + ]) { + const parsed = cronToBuilder(cron); + if (parsed.repeat === "custom") throw new Error(`unexpected custom for ${cron}`); + expect(builderToCron({ ...parsed, repeat: parsed.repeat })).toBe(cron); + } + }); +}); + +describe("ordinal", () => { + it("suffixes 1st/2nd/3rd/nth, with the 11-13 exception", () => { + expect(ordinal(1)).toBe("1st"); + expect(ordinal(2)).toBe("2nd"); + expect(ordinal(3)).toBe("3rd"); + expect(ordinal(4)).toBe("4th"); + expect(ordinal(11)).toBe("11th"); + expect(ordinal(12)).toBe("12th"); + expect(ordinal(13)).toBe("13th"); + expect(ordinal(21)).toBe("21st"); + expect(ordinal(28)).toBe("28th"); + }); +}); + +describe("describeCron", () => { + it("describes the builder's own shapes in plain English", () => { + expect(describeCron("0 9 * * *")).toBe("Daily at 9:00 AM"); + expect(describeCron("30 17 * * 1-5")).toBe("Weekdays at 5:30 PM"); + expect(describeCron("15 8 * * 5")).toBe("Weekly on Friday at 8:15 AM"); + expect(describeCron("59 23 28 * *")).toBe("Monthly on the 28th at 11:59 PM"); + expect(describeCron("*/15 * * * *")).toBe("Every 15 minutes"); + expect(describeCron("*/1 * * * *")).toBe("Every minute"); + expect(describeCron("0 */2 * * *")).toBe("Every 2 hours"); + expect(describeCron("0 * * * *")).toBe("Every hour"); + }); + + it("falls back to the raw expression for anything it doesn't recognise", () => { + expect(describeCron("0 9 * * 1,3,5")).toBe("0 9 * * 1,3,5"); + expect(describeCron("@daily")).toBe("@daily"); + }); +}); diff --git a/apps/web/src/features/schedules/cron-builder.ts b/apps/web/src/features/schedules/cron-builder.ts new file mode 100644 index 0000000000..0f9b21bec9 --- /dev/null +++ b/apps/web/src/features/schedules/cron-builder.ts @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * The structured cron editor's derivation layer. The schedule form keeps + * carrying the cron STRING on the wire (the daemon owns cron grammar + * validation); this module is purely a nicer editor for that string. + * `builderToCron` derives the string from the builder's controls, and + * `cronToBuilder` is its best-effort inverse: it recognises exactly the + * shapes the builder can produce and files everything else (steps, lists, + * exotic ranges) under `custom`, where the raw string is edited directly + * and preserved byte-for-byte. + */ + +export type CronRepeat = "daily" | "weekdays" | "weekly" | "monthly" | "interval" | "custom"; + +/** The unit an `interval` repeat counts in. */ +export type CronIntervalUnit = "minutes" | "hours"; + +/** The builder's parsed view of a cron string. */ +export interface CronBuilder { + /** Day of month for `monthly` (1–28 — the days every month has). */ + monthday: number; + repeat: CronRepeat; + /** "HH:MM", 24-hour — the `` value. */ + time: string; + /** Unit for `interval`. */ + unit: CronIntervalUnit; + /** Step for `interval` (1–59 minutes or 1–23 hours). */ + every: number; + /** Cron day-of-week for `weekly` (0 = Sunday … 6 = Saturday). */ + weekday: number; +} + +/** What `builderToCron` needs; `custom` never derives (the raw string wins). */ +export interface CronBuilderShape { + every?: number; + monthday?: number; + repeat: Exclude; + time: string; + unit?: CronIntervalUnit; + weekday?: number; +} + +/** The builder can never derive an empty cron: a blank time falls back here. */ +const DEFAULT_HOUR = 9; +const DEFAULT_MINUTE = 0; + +function parseTime(time: string): { hour: number; minute: number } { + const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); + if (!match) return { hour: DEFAULT_HOUR, minute: DEFAULT_MINUTE }; + const hour = Number(match[1]); + const minute = Number(match[2]); + if (hour > 23 || minute > 59) return { hour: DEFAULT_HOUR, minute: DEFAULT_MINUTE }; + return { hour, minute }; +} + +function clampInt(n: number, min: number, max: number): number { + if (!Number.isFinite(n)) return min; + return Math.min(max, Math.max(min, Math.trunc(n))); +} + +/** + * Derive the cron string for a builder state. Total by construction: an + * invalid/cleared time falls back to 09:00 and out-of-range day picks are + * clamped, so the derived cron is never empty and never malformed. + */ +export function builderToCron(shape: CronBuilderShape): string { + const { hour, minute } = parseTime(shape.time); + const at = `${minute} ${hour}`; + switch (shape.repeat) { + case "daily": + return `${at} * * *`; + case "weekdays": + return `${at} * * 1-5`; + case "weekly": { + const weekday = clampInt(shape.weekday ?? 1, 0, 6); + return `${at} * * ${weekday}`; + } + case "monthly": { + const monthday = clampInt(shape.monthday ?? 1, 1, 28); + return `${at} ${monthday} * *`; + } + case "interval": { + // The "every N" shapes ignore the time control entirely. + if ((shape.unit ?? "minutes") === "hours") { + const every = clampInt(shape.every ?? 1, 1, 23); + return every === 1 ? "0 * * * *" : `0 */${every} * * *`; + } + return `*/${clampInt(shape.every ?? 30, 1, 59)} * * * *`; + } + default: + return `${at} * * *`; + } +} + +const CUSTOM: CronBuilder = { + every: 30, + monthday: 1, + repeat: "custom", + time: "09:00", + unit: "minutes", + weekday: 1, +}; + +/** + * Best-effort inverse of `builderToCron`, recognising exactly the shapes it + * emits: the interval steps (`*\/N * * * *`, `0 *\/N * * *`, `0 * * * *`) and + * the single numeric minute+hour shapes (month always `*`). Anything else — + * other steps, lists, ranges beyond `1-5`, multiple fields — maps to `custom` + * with neutral defaults for the unused controls; the caller keeps the raw + * string. + */ +export function cronToBuilder(cron: string): CronBuilder { + const parts = cron.trim().split(/\s+/); + if (parts.length !== 5) return CUSTOM; + const [min = "", hour = "", dom = "", mon = "", dow = ""] = parts; + if (mon !== "*") return CUSTOM; + + // Interval shapes come first: their minute/hour fields are steps, not + // numerics, so the fixed-time parse below would file them under custom. + if (dom === "*" && dow === "*") { + const minuteStep = /^\*\/(\d{1,2})$/.exec(min); + if (minuteStep && hour === "*") { + const every = Number(minuteStep[1]); + if (every >= 1 && every <= 59) + return { ...CUSTOM, every, repeat: "interval", unit: "minutes" }; + return CUSTOM; + } + if (min === "0") { + if (hour === "*") return { ...CUSTOM, every: 1, repeat: "interval", unit: "hours" }; + const hourStep = /^\*\/(\d{1,2})$/.exec(hour); + if (hourStep) { + const every = Number(hourStep[1]); + if (every >= 1 && every <= 23) + return { ...CUSTOM, every, repeat: "interval", unit: "hours" }; + return CUSTOM; + } + } + } + + if (!/^\d{1,2}$/.test(min) || !/^\d{1,2}$/.test(hour)) return CUSTOM; + const minute = Number(min); + const hourNum = Number(hour); + if (minute > 59 || hourNum > 23) return CUSTOM; + const time = `${String(hourNum).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; + const base = { ...CUSTOM, time }; + + if (dom === "*") { + if (dow === "*") return { ...base, repeat: "daily" }; + if (dow === "1-5") return { ...base, repeat: "weekdays" }; + if (/^[0-6]$/.test(dow)) return { ...base, repeat: "weekly", weekday: Number(dow) }; + return CUSTOM; + } + if (dow === "*" && /^\d{1,2}$/.test(dom)) { + const monthday = Number(dom); + if (monthday >= 1 && monthday <= 28) return { ...base, monthday, repeat: "monthly" }; + } + return CUSTOM; +} + +const CRON_DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/** "1st", "2nd", "3rd", "4th", … */ +export function ordinal(n: number): string { + const rem10 = n % 10; + const rem100 = n % 100; + if (rem10 === 1 && rem100 !== 11) return `${n}st`; + if (rem10 === 2 && rem100 !== 12) return `${n}nd`; + if (rem10 === 3 && rem100 !== 13) return `${n}rd`; + return `${n}th`; +} + +function formatClock(hour: number, minute: number): string { + const period = hour < 12 ? "AM" : "PM"; + const h12 = hour % 12 === 0 ? 12 : hour % 12; + return `${h12}:${String(minute).padStart(2, "0")} ${period}`; +} + +/** + * Render a standard 5-field cron expression in plain English for the common + * shapes scheduled tasks use (daily/weekly at a time, every N hours or + * minutes). Anything it doesn't recognise falls back to the raw expression, + * so the exact cron stays visible. + */ +export function describeCron(expr: string): string { + const parts = expr.trim().split(/\s+/); + if (parts.length !== 5) return expr; + const [min = "", hour = "", dom = "", mon = "", dow = ""] = parts; + const everyHour = /^\*\/(\d+)$/.exec(hour); + const everyMin = /^\*\/(\d+)$/.exec(min); + const numMin = /^\d+$/.test(min); + const numHour = /^\d+$/.test(hour); + + if (everyMin && hour === "*" && dom === "*" && mon === "*" && dow === "*") { + return Number(everyMin[1]) === 1 ? "Every minute" : `Every ${everyMin[1]} minutes`; + } + if (min === "0" && hour === "*" && dom === "*" && mon === "*" && dow === "*") { + return "Every hour"; + } + if (everyHour && numMin && dom === "*" && mon === "*" && dow === "*") { + return Number(everyHour[1]) === 1 ? "Every hour" : `Every ${everyHour[1]} hours`; + } + if (numMin && numHour && mon === "*") { + const time = formatClock(Number(hour), Number(min)); + if (dom === "*") { + if (dow === "*") return `Daily at ${time}`; + if (dow === "1-5") return `Weekdays at ${time}`; + if (/^[0-6]$/.test(dow)) return `Weekly on ${CRON_DAYS[Number(dow)]} at ${time}`; + } else if (dow === "*" && /^\d{1,2}$/.test(dom)) { + const day = Number(dom); + if (day >= 1 && day <= 31) return `Monthly on the ${ordinal(day)} at ${time}`; + } + } + return expr; +} diff --git a/apps/web/src/features/schedules/schedule-detail.test.ts b/apps/web/src/features/schedules/schedule-detail.test.ts new file mode 100644 index 0000000000..ea3dc4fa6b --- /dev/null +++ b/apps/web/src/features/schedules/schedule-detail.test.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { ListScheduleFiresResponse } from "@mecatl-studio/contracts/generated"; +import { describe, expect, it } from "vitest"; +import { + canRunNow, + canTogglePause, + fireDurationMs, + sortScheduleFires, + timezoneLabel, +} from "./schedule-detail"; + +type Fire = ListScheduleFiresResponse["items"][number]; + +function fire(overrides: Partial & Pick): Fire { + return { + deadline: "", + error: "", + firedAt: "", + inFlight: false, + progressAt: "", + scheduleName: "daily", + sessionId: "", + startedAt: "", + stop: "completed", + ...overrides, + }; +} + +describe("schedule fire history", () => { + it("sorts by duration with newest fire as the tie break", () => { + const items = [ + fire({ + firedAt: "2026-01-02T00:00:00.000Z", + id: "newer", + progressAt: "2026-01-02T00:00:02.000Z", + startedAt: "2026-01-02T00:00:00.000Z", + }), + fire({ + firedAt: "2026-01-01T00:00:00.000Z", + id: "older", + progressAt: "2026-01-01T00:00:02.000Z", + startedAt: "2026-01-01T00:00:00.000Z", + }), + fire({ + firedAt: "2026-01-03T00:00:00.000Z", + id: "longest", + progressAt: "2026-01-03T00:00:03.000Z", + startedAt: "2026-01-03T00:00:00.000Z", + }), + ]; + + expect(sortScheduleFires(items, "duration", "asc").map((item) => item.id)).toEqual([ + "newer", + "older", + "longest", + ]); + }); + + it("uses the current time for an in-flight duration", () => { + expect( + fireDurationMs( + fire({ id: "live", inFlight: true, startedAt: "2026-01-01T00:00:00.000Z" }), + Date.parse("2026-01-01T00:00:04.500Z"), + ), + ).toBe(4_500); + }); +}); + +describe("schedule actions and labels", () => { + it("offers Run now only for a schedule with work left to run", () => { + for (const status of ["scheduled", "claimed"] as const) { + expect(canRunNow({ status }), status).toBe(true); + } + for (const status of ["running", "paused", "completed"] as const) { + expect(canRunNow({ status }), status).toBe(false); + } + }); + + it("offers neither Pause nor Resume for a completed schedule", () => { + expect(canTogglePause({ status: "completed" })).toBe(false); + for (const status of ["scheduled", "claimed", "running", "paused"] as const) { + expect(canTogglePause({ status }), status).toBe(true); + } + }); + + it("labels an empty cron time zone as UTC", () => { + expect(timezoneLabel("")).toBe("UTC"); + expect(timezoneLabel("Europe/Rome")).toBe("Europe/Rome"); + }); +}); diff --git a/apps/web/src/features/schedules/schedule-detail.tsx b/apps/web/src/features/schedules/schedule-detail.tsx new file mode 100644 index 0000000000..288bb45017 --- /dev/null +++ b/apps/web/src/features/schedules/schedule-detail.tsx @@ -0,0 +1,531 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { + ListScheduleFiresResponse, + ListSchedulesResponse, +} from "@mecatl-studio/contracts/generated"; +import { + actOnScheduleMutation, + deleteScheduleMutation, + listScheduleFiresOptions, + listSchedulesOptions, + listSchedulesQueryKey, + updateScheduleMutation, +} from "@mecatl-studio/contracts/query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { ArrowLeft, ChevronDown, ChevronUp, Clock3, Pause, Play, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../../components/ui/alert-dialog"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; +import { ScheduleForm } from "./schedule-form"; + +type Schedule = ListSchedulesResponse["items"][number]; +type ScheduleFire = ListScheduleFiresResponse["items"][number]; +export type FireSortKey = "duration" | "fired" | "outcome"; +type SortDirection = "asc" | "desc"; + +export function ScheduleDetail({ scheduleName }: { scheduleName: string }) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const schedules = useQuery(listSchedulesOptions()); + const fires = useQuery({ + ...listScheduleFiresOptions({ path: { scheduleName } }), + enabled: schedules.data?.supported === true, + refetchInterval: (query) => + query.state.data?.items.some((fire) => fire.inFlight) ? 5_000 : false, + }); + const update = useMutation(updateScheduleMutation()); + const remove = useMutation(deleteScheduleMutation()); + const action = useMutation(actOnScheduleMutation()); + const [editing, setEditing] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [error, setError] = useState(); + const schedule = schedules.data?.items.find((item) => item.name === scheduleName); + + async function refresh() { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: listSchedulesQueryKey() }), + fires.refetch(), + ]); + } + + async function runAction(name: "fire" | "pause" | "resume") { + setError(undefined); + try { + await action.mutateAsync({ body: { action: name }, path: { scheduleName } }); + await refresh(); + } catch (caught) { + setError(errorMessage(caught)); + } + } + + async function deleteItem() { + setError(undefined); + try { + await remove.mutateAsync({ path: { scheduleName } }); + await queryClient.invalidateQueries({ queryKey: listSchedulesQueryKey() }); + await navigate({ search: { schedule: undefined }, to: "/workspace/schedules" }); + } catch (caught) { + setError(errorMessage(caught)); + setConfirmDelete(false); + } + } + + if (schedules.isPending) return ; + if (schedules.isError) return ; + if (!schedules.data.supported) + return ; + if (!schedule) + return ( + + ); + + const busy = action.isPending || remove.isPending || update.isPending; + return ( +
+
+ + +
+
+
+

+ {schedule.name} +

+ + {modeLabel(schedule.mode)} + {schedule.mutating && writes enabled} +
+

+ {schedule.prompt} +

+
+
+ + + + +
+
+ + {error && ( +

{error}

+ )} + +
+ + + + + + + + + + {schedule.trigger.kind === "cron" ? ( + <> + + + + + ) : ( + <> + + + + )} + + + + +
+ +
+
+ +

Run history

+
+ +
+
+ + {editing && ( + setEditing(false)} + onSubmit={async (body) => { + const { name: _name, ...updateBody } = body; + await update.mutateAsync({ body: updateBody, path: { scheduleName } }); + setEditing(false); + await refresh(); + }} + schedule={schedule} + /> + )} + + + + + Delete “{schedule.name}”? + + The schedule stops firing. Past run transcripts stay in the session store. + + + + Cancel + void deleteItem()}> + Delete + + + + +
+ ); +} + +function ScheduleFireHistory({ + error, + fires, + loading, +}: { + error?: string; + fires?: ScheduleFire[]; + loading: boolean; +}) { + const [sortKey, setSortKey] = useState("fired"); + const [direction, setDirection] = useState("desc"); + const sorted = useMemo( + () => sortScheduleFires(fires ?? [], sortKey, direction), + [direction, fires, sortKey], + ); + + function changeSort(key: FireSortKey) { + if (sortKey === key) setDirection((value) => (value === "asc" ? "desc" : "asc")); + else { + setSortKey(key); + setDirection(key === "fired" ? "desc" : "asc"); + } + } + + if (loading) return ; + if (error) return ; + if (sorted.length === 0) return ; + + return ( +
+ + + + + + + + + + + {sorted.map((fire) => ( + + + + + + + ))} + +
Session
+ {fire.firedAt ? formatDate(fire.firedAt) : "—"} + + {formatDuration(fireDurationMs(fire))} + +
+ + {fireOutcome(fire)} + + {fire.error && {fire.error}} +
+
+ {fire.sessionId ? ( + + ) : ( + — + )} +
+
+ ); +} + +function SortHeader({ + direction, + label, + name, + onSort, + selected, +}: { + direction: SortDirection; + label: string; + name: FireSortKey; + onSort: (key: FireSortKey) => void; + selected: boolean; +}) { + return ( + + + + ); +} + +/** + * Whether "Run now" is offered: not while a fire is running, and not for a + * paused or completed schedule, which has nothing scheduled to run. + */ +export function canRunNow(schedule: Pick) { + return ( + schedule.status !== "running" && schedule.status !== "paused" && schedule.status !== "completed" + ); +} + +/** + * A completed schedule has no next fire: resuming it would schedule nothing, + * so neither Pause nor Resume applies. + */ +export function canTogglePause(schedule: Pick) { + return schedule.status !== "completed"; +} + +/** The daemon runs a cron schedule with no time zone in UTC. */ +export function timezoneLabel(timezone: string) { + return timezone || "UTC"; +} + +export function sortScheduleFires( + fires: readonly ScheduleFire[], + key: FireSortKey, + direction: SortDirection, + now = Date.now(), +) { + const multiplier = direction === "asc" ? 1 : -1; + return [...fires].sort((left, right) => { + let comparison: number; + if (key === "duration") + comparison = (fireDurationMs(left, now) ?? -1) - (fireDurationMs(right, now) ?? -1); + else if (key === "outcome") comparison = fireOutcome(left).localeCompare(fireOutcome(right)); + else comparison = dateValue(left.firedAt) - dateValue(right.firedAt); + return comparison * multiplier || dateValue(right.firedAt) - dateValue(left.firedAt); + }); +} + +export function fireDurationMs(fire: ScheduleFire, now = Date.now()) { + const started = dateValue(fire.startedAt); + if (!started) return null; + const end = fire.inFlight + ? now + : dateValue(fire.progressAt) || dateValue(fire.deadline) || started; + return Math.max(0, end - started); +} + +function fireOutcome(fire: ScheduleFire) { + if (fire.inFlight) return fire.startedAt ? "In flight" : "Claimed"; + return fire.error ? "Failed" : fire.stop || "Completed"; +} + +function dateValue(value: string | null) { + if (!value) return 0; + const result = Date.parse(value); + return Number.isNaN(result) ? 0 : result; +} + +function formatDuration(milliseconds: number | null) { + if (milliseconds === null) return "—"; + const seconds = milliseconds / 1_000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const minutes = Math.floor(seconds / 60); + return `${minutes}m ${Math.round(seconds - minutes * 60)}s`; +} + +function FactGroup({ children, title }: { children: React.ReactNode; title: string }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function Fact({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function StatusBadge({ status }: { status: Schedule["status"] }) { + const variant = + status === "running" + ? "success" + : status === "paused" + ? "muted" + : status === "claimed" + ? "warning" + : "info"; + return {status}; +} + +function DetailState({ text, title }: { text: string; title?: string }) { + return ( +
+
+ {title &&

{title}

} +

{text}

+ +
+
+ ); +} + +function HistoryState({ destructive, text }: { destructive?: boolean; text: string }) { + return ( +

+ {text} +

+ ); +} + +function modeLabel(mode: Schedule["mode"]) { + return mode === "acceptEdits" ? "Accept edits" : mode === "plan" ? "Plan" : "Default"; +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format( + new Date(value), + ); +} + +function errorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "detail" in error) return String(error.detail); + return error instanceof Error ? error.message : "The request could not be completed."; +} diff --git a/apps/web/src/features/schedules/schedule-form-value.test.ts b/apps/web/src/features/schedules/schedule-form-value.test.ts new file mode 100644 index 0000000000..d39ed5d916 --- /dev/null +++ b/apps/web/src/features/schedules/schedule-form-value.test.ts @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + bodyFromValue, + displayedValue, + isTriggerEditable, + phraseTriggerPatch, + type Schedule, + TRIGGER_FIELDS, + type TriggerPatch, + triggerFromValue, + valueFromSchedule, + withTriggerPatch, +} from "./schedule-form-value"; +import { oneShotInstant, toLocalDateTimeInput } from "./schedule-phrase"; + +function schedule(overrides: Partial = {}): Schedule { + return { + enabled: true, + fireCount: 0, + lastFireAt: "", + lastFireSessionId: "", + maxFires: 0, + mode: "plan", + modelId: "", + mutating: false, + name: "daily-summary", + nextFireAt: "2027-01-15T08:00:00.000Z", + oneShotMaxRetries: 0, + oneShotRetry: false, + owner: "", + profile: "all", + prompt: "Summarize the day", + providerId: "", + status: "scheduled", + trigger: { expression: "0 9 * * *", kind: "cron", timezone: "Europe/Rome" }, + ...overrides, + }; +} + +describe("schedule form value mapping", () => { + it("saving an unchanged form sends exactly the stored mode and write flag", () => { + const stored = [ + { mode: "plan", mutating: false }, + { mode: "plan", mutating: true }, + { mode: "default", mutating: true }, + { mode: "acceptEdits", mutating: true }, + ] as const; + for (const permission of stored) { + const existing = schedule(permission); + const body = bodyFromValue(valueFromSchedule(existing), existing); + expect({ mode: body.mode, mutating: body.mutating }, JSON.stringify(permission)).toEqual( + permission, + ); + } + }); + + it("sends an existing schedule's trigger exactly as stored", () => { + for (const trigger of [ + { expression: "0 9 * * *", kind: "cron", timezone: "" }, + { expression: "*/15 * * * *", kind: "cron", timezone: "Europe/Rome" }, + { at: "2027-01-15T08:00:00.000Z", kind: "once" }, + ] as const) { + const existing = schedule({ trigger }); + const value = valueFromSchedule(existing); + // Even a stale field value cannot move the trigger of a saved schedule. + const body = bodyFromValue({ ...value, cron: "0 10 * * *", timezone: "UTC" }, existing); + expect(body.trigger).toEqual(trigger); + } + }); + + it("maps the write toggle of a new schedule to its permission mode", () => { + const value = valueFromSchedule(); + expect(bodyFromValue(value)).toMatchObject({ mode: "plan", mutating: false }); + expect(bodyFromValue({ ...value, allowWrites: true })).toMatchObject({ + mode: "acceptEdits", + mutating: true, + }); + expect(bodyFromValue({ ...value, allowWrites: true, writeMode: "default" })).toMatchObject({ + mode: "default", + mutating: true, + }); + }); + + it("turning writes off on a writing schedule returns it to read-only plan mode", () => { + const existing = schedule({ mode: "acceptEdits", mutating: true }); + const body = bodyFromValue({ ...valueFromSchedule(existing), allowWrites: false }, existing); + expect(body).toMatchObject({ mode: "plan", mutating: false }); + }); +}); + +const STORED_TRIGGERS = [ + { expression: "*/15 * * * *", kind: "cron", timezone: "Europe/Rome" }, + { at: "2027-01-15T08:00:00.000Z", kind: "once" }, +] as const; + +// A Wednesday, 10:00 local: anchors the one-shot phrases. +const phraseNow = new Date(2026, 8, 16, 10, 0); + +/** + * Every edit a trigger control can make: the tabs, the cron builder and + * expression, the timezone, the one-shot date/time, and the phrase field. + */ +function triggerEdits(): TriggerPatch[] { + const phrased = ["in 2 hours", "tomorrow at 8am", "daily at 9am", "*/10 * * * *"].map( + (text) => phraseTriggerPatch(text, phraseNow).patch, + ); + return [ + { triggerKind: "cron" }, + { triggerKind: "once" }, + { cron: "0 10 * * 1-5" }, + { timezone: "UTC" }, + { timezone: "" }, + { oneShotAt: "2030-06-01T12:00" }, + { oneShotAt: toLocalDateTimeInput(Date.now()) }, + { oneShotOriginal: "" }, + ...phrased.filter((patch): patch is TriggerPatch => patch !== null), + ]; +} + +describe("schedule trigger lock", () => { + it("locks every trigger control of an existing schedule", () => { + const edits = triggerEdits(); + // The edits reach every field the server compares as the trigger. + const touched = new Set(edits.flatMap((patch) => Object.keys(patch))); + expect([...touched].sort()).toEqual([...TRIGGER_FIELDS].sort()); + + for (const trigger of STORED_TRIGGERS) { + const existing = schedule({ trigger }); + expect(isTriggerEditable(existing)).toBe(false); + const value = valueFromSchedule(existing); + for (const patch of edits) { + const edited = withTriggerPatch(value, patch, existing); + expect(edited, JSON.stringify(patch)).toBe(value); + expect(bodyFromValue(displayedValue(edited, existing), existing).trigger).toEqual(trigger); + } + } + }); + + it("displays exactly the trigger an existing schedule sends", () => { + for (const trigger of STORED_TRIGGERS) { + const existing = schedule({ trigger }); + const stored = valueFromSchedule(existing); + // A form state whose trigger fields drifted, however that happened. + const drifted = { + ...stored, + cron: "0 10 * * *", + oneShotAt: "2030-06-01T12:00", + oneShotOriginal: "", + prompt: "Edited prompt", + timezone: "UTC", + triggerKind: trigger.kind === "cron" ? "once" : "cron", + } as const; + const shown = displayedValue(drifted, existing); + for (const field of TRIGGER_FIELDS) { + expect(shown[field], field).toBe(stored[field]); + } + // Non-trigger edits still come through. + expect(shown.prompt).toBe("Edited prompt"); + expect(triggerFromValue(shown, existing)).toEqual(trigger); + if (trigger.kind === "once") { + // The date/time input renders the stored instant, not another one. + expect(oneShotInstant(shown.oneShotAt, shown.oneShotOriginal)).toBe(trigger.at); + } else { + expect({ expression: shown.cron, timezone: shown.timezone }).toEqual({ + expression: trigger.expression, + timezone: trigger.timezone, + }); + } + } + }); + + it("leaves the retry fields of an existing one-shot schedule editable", () => { + const existing = schedule({ trigger: STORED_TRIGGERS[1] }); + const value = { ...valueFromSchedule(existing), oneShotMaxRetries: "5", oneShotRetry: true }; + expect(bodyFromValue(displayedValue(value, existing), existing)).toMatchObject({ + oneShotMaxRetries: 5, + oneShotRetry: true, + trigger: STORED_TRIGGERS[1], + }); + }); + + it("keeps trigger controls editable for a new schedule", () => { + expect(isTriggerEditable()).toBe(true); + const value = valueFromSchedule(); + expect(displayedValue(value)).toBe(value); + + const cron = withTriggerPatch(value, { cron: " 0 10 * * 1-5 ", timezone: " UTC " }); + expect(displayedValue(cron)).toBe(cron); + expect(bodyFromValue(cron).trigger).toEqual({ + expression: "0 10 * * 1-5", + kind: "cron", + timezone: "UTC", + }); + + const phrased = phraseTriggerPatch("tomorrow at 8am", phraseNow).patch; + expect(phrased).not.toBeNull(); + const once = withTriggerPatch(value, phrased ?? {}); + expect(once.triggerKind).toBe("once"); + // The wall clock is canonicalised to the absolute instant it names. + expect(bodyFromValue(once).trigger).toEqual({ + at: new Date(2026, 8, 17, 8, 0).toISOString(), + kind: "once", + }); + + const at = new Date(2030, 5, 1, 12, 0).getTime(); + const typed = withTriggerPatch(value, { + oneShotAt: toLocalDateTimeInput(at), + triggerKind: "once", + }); + expect(bodyFromValue(typed).trigger).toEqual({ at: new Date(at).toISOString(), kind: "once" }); + }); +}); diff --git a/apps/web/src/features/schedules/schedule-form-value.ts b/apps/web/src/features/schedules/schedule-form-value.ts new file mode 100644 index 0000000000..f2fb86d4fc --- /dev/null +++ b/apps/web/src/features/schedules/schedule-form-value.ts @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { CreateScheduleData, ListSchedulesResponse } from "@mecatl-studio/contracts/generated"; +import { + compileSchedulePhrase, + looksLikeCron, + oneShotInstant, + type SchedulePhraseResult, + toLocalDateTimeInput, +} from "./schedule-phrase"; + +export type Schedule = ListSchedulesResponse["items"][number]; +export type ScheduleBody = CreateScheduleData["body"]; + +export interface FormValue { + allowWrites: boolean; + cron: string; + maxFires: string; + name: string; + oneShotAt: string; + /** The schedule's original one-shot instant, re-sent unchanged while the wall clock is unedited. */ + oneShotOriginal: string; + oneShotMaxRetries: string; + oneShotRetry: boolean; + profile: "all" | "noFilesystem"; + prompt: string; + timezone: string; + triggerKind: "cron" | "once"; + /** + * The permission mode a writing schedule runs in. `plan` is only ever + * loaded from a stored writing schedule, so saving it unchanged keeps it. + */ + writeMode: Schedule["mode"]; +} + +export function valueFromSchedule(schedule?: Schedule): FormValue { + const timezone = + schedule?.trigger.kind === "cron" ? schedule.trigger.timezone : browserTimezone(); + return { + allowWrites: schedule?.mutating ?? false, + cron: schedule?.trigger.kind === "cron" ? schedule.trigger.expression : "0 9 * * *", + maxFires: String(schedule?.maxFires ?? 0), + name: schedule?.name ?? "", + oneShotAt: schedule?.trigger.kind === "once" ? toLocalInput(schedule.trigger.at) : "", + oneShotOriginal: schedule?.trigger.kind === "once" ? schedule.trigger.at : "", + oneShotMaxRetries: String(schedule?.oneShotMaxRetries || 3), + oneShotRetry: schedule?.oneShotRetry ?? false, + profile: schedule?.profile ?? "all", + prompt: schedule?.prompt ?? "", + timezone, + triggerKind: schedule?.trigger.kind ?? "cron", + // A read-only schedule is always `plan`; the mode offered once writes are + // switched on is the new-schedule default. + writeMode: schedule?.mutating ? schedule.mode : "acceptEdits", + }; +} + +/** + * The form fields that make up a schedule's trigger: exactly what the server + * compares when it refuses to change the trigger of an existing schedule + * (the kind, the cron expression and timezone, or the one-shot instant). The + * run limits (`maxFires`, the one-shot retry) are not part of it. + */ +export const TRIGGER_FIELDS = [ + "triggerKind", + "cron", + "timezone", + "oneShotAt", + "oneShotOriginal", +] as const satisfies ReadonlyArray; + +export type TriggerPatch = Partial>; + +/** + * Whether the form may change the trigger. Only a new schedule's can be + * edited: the daemon keeps an existing schedule's stored next run on update, + * so changing when it runs means creating a new schedule. + */ +export function isTriggerEditable(schedule?: Schedule): boolean { + return schedule === undefined; +} + +/** + * Applies a trigger edit (kind switch, cron builder, timezone, one-shot date + * and time, or a compiled phrase). An existing schedule's trigger is locked, + * so the edit is dropped and the value is returned unchanged. + */ +export function withTriggerPatch( + value: FormValue, + patch: TriggerPatch, + schedule?: Schedule, +): FormValue { + return isTriggerEditable(schedule) ? { ...value, ...patch } : value; +} + +/** + * The value the form renders. For an existing schedule the trigger fields are + * always re-derived from the stored trigger, whatever the form state holds, so + * the displayed trigger can never differ from the one that is sent. + */ +export function displayedValue(value: FormValue, schedule?: Schedule): FormValue { + if (isTriggerEditable(schedule)) return value; + const stored = valueFromSchedule(schedule); + return { + ...value, + cron: stored.cron, + oneShotAt: stored.oneShotAt, + oneShotOriginal: stored.oneShotOriginal, + timezone: stored.timezone, + triggerKind: stored.triggerKind, + }; +} + +/** + * The trigger the form sends. An existing schedule's trigger cannot be edited + * (the daemon keeps its stored next run), so it is sent exactly as stored. + */ +export function triggerFromValue(value: FormValue, schedule?: Schedule): ScheduleBody["trigger"] { + if (schedule) return schedule.trigger; + return value.triggerKind === "cron" + ? { expression: value.cron.trim(), kind: "cron", timezone: value.timezone.trim() } + : { at: oneShotInstant(value.oneShotAt, value.oneShotOriginal) ?? "", kind: "once" }; +} + +/** The request body for the form. */ +export function bodyFromValue(value: FormValue, schedule?: Schedule): ScheduleBody { + const trigger = triggerFromValue(value, schedule); + return { + maxFires: Math.max(0, Number(value.maxFires) || 0), + mode: value.allowWrites ? value.writeMode : "plan", + mutating: value.allowWrites, + name: value.name.trim(), + oneShotMaxRetries: value.oneShotRetry ? Math.max(0, Number(value.oneShotMaxRetries) || 0) : 0, + oneShotRetry: trigger.kind === "once" && value.oneShotRetry, + profile: value.profile, + prompt: value.prompt.trim(), + trigger, + }; +} + +/** What the phrase input last produced — drives the line under it. */ +export type PhraseOutcome = + | SchedulePhraseResult + | { cron: string; kind: "raw-cron" } + | { kind: "none" }; + +/** + * Compiles the phrase input into the trigger edit it implies, if any. The + * patch still goes through {@link withTriggerPatch}, so a phrase cannot move + * a locked trigger either. + */ +export function phraseTriggerPatch( + text: string, + now?: Date, +): { outcome: PhraseOutcome; patch: TriggerPatch | null } { + const compiled = compileSchedulePhrase(text, now); + if (compiled?.kind === "cron") { + return { outcome: compiled, patch: { cron: compiled.cron, triggerKind: "cron" } }; + } + if (compiled?.kind === "one-shot") { + return { + outcome: compiled, + patch: { oneShotAt: toLocalDateTimeInput(compiled.at), triggerKind: "once" }, + }; + } + if (looksLikeCron(text)) { + // The unmatched five-field fallback: it IS the cron, verbatim; the + // builder lands on Custom (or the shape it happens to parse as). + const cron = text.trim(); + return { outcome: { cron, kind: "raw-cron" }, patch: { cron, triggerKind: "cron" } }; + } + return { outcome: { kind: "none" }, patch: null }; +} + +function browserTimezone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +} + +function toLocalInput(value: string) { + const date = new Date(value); + const offset = date.getTimezoneOffset() * 60_000; + return new Date(date.getTime() - offset).toISOString().slice(0, 16); +} diff --git a/apps/web/src/features/schedules/schedule-form.tsx b/apps/web/src/features/schedules/schedule-form.tsx new file mode 100644 index 0000000000..1107c041b4 --- /dev/null +++ b/apps/web/src/features/schedules/schedule-form.tsx @@ -0,0 +1,569 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useState } from "react"; +import { Button } from "../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../components/ui/dialog"; +import { Input } from "../../components/ui/input"; +import { Textarea } from "../../components/ui/textarea"; +import { + builderToCron, + type CronIntervalUnit, + type CronRepeat, + cronToBuilder, + describeCron, + ordinal, +} from "./cron-builder"; +import { + bodyFromValue, + displayedValue, + type FormValue, + isTriggerEditable, + type PhraseOutcome, + phraseTriggerPatch, + type Schedule, + type ScheduleBody, + type TriggerPatch, + triggerFromValue, + valueFromSchedule, + withTriggerPatch, +} from "./schedule-form-value"; + +const TRIGGER_LOCKED_NOTE = + "The trigger of an existing task can't be changed, because Mecatl keeps its next run time when the task is saved. To change when it runs, create a new scheduled task and delete this one."; + +const NO_PHRASE: PhraseOutcome = { kind: "none" }; + +const PHRASE_PLACEHOLDER = "every 30 minutes · daily at 9am · next monday 3pm · in 2 hours"; +const PHRASE_HINT = 'Not recognised — try "every weekday at 9am" or a cron expression'; + +/** The plain-English preview of what a phrase compiled to. */ +function describePhraseOutcome(outcome: PhraseOutcome): string | null { + if (outcome.kind === "none") return null; + if (outcome.kind === "one-shot") return `Once at ${new Date(outcome.at).toLocaleString()}`; + // A five-field fallback the describer cannot read is still a cron; say so + // rather than echo the raw string bare. + const described = describeCron(outcome.cron); + return outcome.kind === "raw-cron" && described === outcome.cron + ? `Cron expression ${outcome.cron}` + : described; +} + +export function ScheduleForm({ + schedule, + onCancel, + onSubmit, +}: { + schedule?: Schedule; + onCancel: () => void; + onSubmit: (body: ScheduleBody) => Promise; +}) { + const [value, setValue] = useState(() => valueFromSchedule(schedule)); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(); + + // The phrase is an authoring aid: it compiles INTO value.cron/oneShotAt, + // which stay the wire truth. Its text and preview are local; a successful + // compile bumps phraseVersion, which remounts CronFields so its Custom + // pick re-derives from the new string instead of sticking. + const [phrase, setPhrase] = useState(""); + const [phraseOutcome, setPhraseOutcome] = useState(NO_PHRASE); + const [phraseVersion, setPhraseVersion] = useState(0); + + // An existing schedule's trigger is read-only: the daemon keeps the stored + // next run on update, so an edited trigger would never take effect. Every + // trigger edit goes through withTriggerPatch, which drops it when locked, + // and the form renders displayedValue, whose trigger is always the stored + // one for an existing schedule. + const triggerLocked = !isTriggerEditable(schedule); + const shown = displayedValue(value, schedule); + + /** + * Trigger edits made through the structured controls (tabs, cron builder, + * the one-shot date/time) consume the phrase: left in place, its text + * would describe a trigger the form no longer holds. + */ + function changeTrigger(patch: TriggerPatch) { + if (triggerLocked) return; + if (phrase) { + setPhrase(""); + setPhraseOutcome(NO_PHRASE); + } + setValue((current) => withTriggerPatch(current, patch, schedule)); + } + + function handlePhrase(text: string) { + if (triggerLocked) return; + setPhrase(text); + const { outcome, patch } = phraseTriggerPatch(text); + setPhraseOutcome(outcome); + if (patch) { + setPhraseVersion((v) => v + 1); + setValue((current) => withTriggerPatch(current, patch, schedule)); + } + } + + // `plan` is offered only to keep a stored writing schedule's mode as it is. + const offerPlanMode = schedule?.mutating === true && schedule.mode === "plan"; + const phrasePreview = describePhraseOutcome(phraseOutcome); + const phraseNote = phrasePreview ?? (phrase.trim() ? PHRASE_HINT : null); + // Formatted from the very trigger that will be sent, never from the inputs. + const savedOneShot = formatOneShot(triggerFromValue(shown, schedule)); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(undefined); + try { + await onSubmit(bodyFromValue(shown, schedule)); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setSubmitting(false); + } + } + + return ( + !open && onCancel()} open> + +
+ + {schedule ? "Edit scheduled task" : "Schedule a task"} + + Mecatl validates the cadence and runs the prompt unattended. + + + +
+ + setValue({ ...value, name: event.target.value })} + placeholder="daily-summary" + required + value={value.name} + /> + + +