From a923eff7676ad368edd56295bbb34a56c5a27188 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 04:17:14 +0000 Subject: [PATCH 1/7] Add Automations Automations save a schedule that delivers a new Session or instructions to an existing one. One Periodic job records due occurrences and delivers queued invocations through the Session create and input paths, keyed by the invocation id. --- .github/workflows/api-acceptance.yml | 2 +- contracts/agents-api/go-bindings.json | 68 ++ contracts/agents-api/v1/official.gen.go | 303 +++++++++ scripts/generate-public-api.py | 4 +- services/core/cmd/server/http_routes_test.go | 2 + services/core/cmd/server/main.go | 25 +- .../core/internal/api/automation_delivery.go | 151 +++++ services/core/internal/api/automations.go | 428 +++++++++++++ services/core/internal/api/dependencies.go | 26 +- .../core/internal/api/dependencies_test.go | 2 + .../core/internal/api/errors_automations.go | 25 + services/core/internal/api/handler.go | 62 +- .../core/internal/api/official_shapes.gen.go | 48 ++ services/core/internal/api/routing_test.go | 2 +- .../core/internal/automations/automation.go | 242 +++++++ .../internal/automations/automation_test.go | 88 +++ services/core/internal/automations/cron.go | 145 +++++ .../core/internal/automations/cron_test.go | 45 ++ services/core/internal/automations/errors.go | 19 + services/core/internal/automations/service.go | 118 ++++ .../credentialcrypto/automation_target.go | 38 ++ .../environment_setup_test.go | 16 + .../core/internal/db/queries/automations.sql | 96 +++ .../core/internal/db/sqlc/automations.sql.go | 604 ++++++++++++++++++ services/core/internal/db/sqlc/models.go | 30 + .../postgres/automationpg/store.go | 386 +++++++++++ .../providers/configuration_flow_test.go | 2 + .../core/migrations/000012_automations.sql | 43 ++ .../integration/automations_public_test.go | 60 ++ .../integration/automations_store_test.go | 70 ++ .../public_handler_fixture_test.go | 15 + services/core/tests/official_automations.py | 122 ++++ 32 files changed, 3250 insertions(+), 37 deletions(-) create mode 100644 services/core/internal/api/automation_delivery.go create mode 100644 services/core/internal/api/automations.go create mode 100644 services/core/internal/api/errors_automations.go create mode 100644 services/core/internal/automations/automation.go create mode 100644 services/core/internal/automations/automation_test.go create mode 100644 services/core/internal/automations/cron.go create mode 100644 services/core/internal/automations/cron_test.go create mode 100644 services/core/internal/automations/errors.go create mode 100644 services/core/internal/automations/service.go create mode 100644 services/core/internal/credentialcrypto/automation_target.go create mode 100644 services/core/internal/db/queries/automations.sql create mode 100644 services/core/internal/db/sqlc/automations.sql.go create mode 100644 services/core/internal/persistence/postgres/automationpg/store.go create mode 100644 services/core/migrations/000012_automations.sql create mode 100644 services/core/tests/integration/automations_public_test.go create mode 100644 services/core/tests/integration/automations_store_test.go create mode 100644 services/core/tests/official_automations.py diff --git a/.github/workflows/api-acceptance.yml b/.github/workflows/api-acceptance.yml index 1db726e16..97e01f758 100644 --- a/.github/workflows/api-acceptance.yml +++ b/.github/workflows/api-acceptance.yml @@ -64,7 +64,7 @@ jobs: run: | python services/core/tests/official_schema_test.py python services/core/tests/official_client.py - go test ./services/core/tests/integration -run '^(TestFunctionStateOfficialClientReadsAndLiveEvents|TestSavedReferenceRetryOfficialClient|TestAgentUpdateOfficialClient|TestAgentDeletionOfficialClient|TestSessionAgentFilterOfficialClient|TestSessionDeletionOfficialClient|TestEnvironmentInitialFailureOfficialClient|TestSelfHostedInitialCreationOfficialClient|TestSelfHostedCancellationOfficialClient)$' -count=1 + go test ./services/core/tests/integration -run '^(TestFunctionStateOfficialClientReadsAndLiveEvents|TestSavedReferenceRetryOfficialClient|TestAgentUpdateOfficialClient|TestAgentDeletionOfficialClient|TestAutomationsOfficialClient|TestSessionAgentFilterOfficialClient|TestSessionDeletionOfficialClient|TestEnvironmentInitialFailureOfficialClient|TestSelfHostedInitialCreationOfficialClient|TestSelfHostedCancellationOfficialClient)$' -count=1 - uses: ./.github/actions/e2b-provider if: inputs.container - name: Verify the distribution's Core image diff --git a/contracts/agents-api/go-bindings.json b/contracts/agents-api/go-bindings.json index e1a6670ec..44a52eef8 100644 --- a/contracts/agents-api/go-bindings.json +++ b/contracts/agents-api/go-bindings.json @@ -20,6 +20,74 @@ "AgentMessageItem": { "sources": ["#/components/schemas/AgentMessageItemResource"] }, + "Automation": { + "marshal_union": true, + "sources": ["#/components/schemas/AutomationResource"], + "fields": { + "target": {"type": "json.RawMessage"}, + "created_by": {"type": "AutomationCreatedBy"} + }, + "order": ["type", "id", "object", "created_at", "name", "target", "metadata", "created_by", "expression", "timezone", "run_at", "status"] + }, + "AutomationAgentConfig": { + "sources": ["#/components/schemas/AutomationAgentConfigResource"], + "fields": { + "reasoning": {"type": "Reasoning"} + }, + "order": ["model", "reasoning", "text", "service_tier", "instructions", "tools", "multi_agent", "compaction", "experimental"] + }, + "AutomationCreatedBy": { + "marshal_union": true, + "sources": ["#/components/schemas/AutomationCreatedByResource"] + }, + "AutomationDeleted": { + "sources": ["#/components/schemas/DeletedAutomationResource"] + }, + "AutomationEnvironment": { + "marshal_union": true, + "sources": ["#/components/schemas/AutomationEnvironmentResource"], + "fields": { + "container_size": {"type": "json.RawMessage"}, + "packages": {"type": "*EnvironmentPackagesResponse"}, + "network": {"type": "*EnvironmentNetwork"}, + "desktop": {"type": "json.RawMessage"} + }, + "order": ["type", "environment_id", "environment_template_id", "container_size", "packages", "network", "desktop", "capability_directories", "skills", "plugins", "files", "workspace_directory"] + }, + "AutomationInvocation": { + "sources": ["#/components/schemas/AutomationInvocationResource"], + "fields": { + "error": {"type": "*AutomationInvocationError"} + }, + "order": ["id", "object", "automation_id", "created_at", "source", "scheduled_at", "session_id", "status", "skip_reason", "error"] + }, + "AutomationInvocationError": { + "sources": ["#/components/schemas/AutomationInvocationErrorResource"] + }, + "AutomationInvocationList": { + "sources": ["#/paths/~1agents~1automations~1{automation_id}~1invocations/get/responses/200/content/application~1json/schema"], + "order": ["object", "first_id", "last_id", "data", "has_more"] + }, + "AutomationInvocationSource": { + "marshal_union": true, + "sources": ["#/components/schemas/AutomationInvocationSourceResource"], + "fields": { + "automation": {"type": "json.RawMessage"} + } + }, + "AutomationList": { + "sources": ["#/paths/~1agents~1automations/get/responses/200/content/application~1json/schema"], + "order": ["object", "first_id", "last_id", "data", "has_more"] + }, + "AutomationTarget": { + "marshal_union": true, + "sources": ["#/components/schemas/AutomationTargetResource"], + "fields": { + "agent": {"type": "*AutomationAgentConfig"}, + "input": {"type": "[]InputMessage"} + }, + "order": ["type", "agent_id", "agent", "environment", "input", "vault_ids", "metadata", "session_id", "instructions"] + }, "CreateAgentRequest": { "sources": ["#/components/schemas/CreateAgentParams"], "fields": { diff --git a/contracts/agents-api/v1/official.gen.go b/contracts/agents-api/v1/official.gen.go index f95766d7c..cfc6d7467 100644 --- a/contracts/agents-api/v1/official.gen.go +++ b/contracts/agents-api/v1/official.gen.go @@ -54,6 +54,309 @@ type AgentMessageItem struct { Content []AgentContent `json:"content" binding:"required"` } +// Automation projects AutomationResource. +type Automation struct { + Type string `json:"type" binding:"required" enums:"periodic,once"` + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.automation"` + CreatedAt int64 `json:"created_at" binding:"required"` + Name string `json:"name" binding:"required"` + Target json.RawMessage `json:"target" binding:"required" swaggertype:"object"` + Metadata map[string]string `json:"metadata" binding:"required"` + CreatedBy AutomationCreatedBy `json:"created_by" binding:"required"` + Expression *string `json:"expression,omitempty"` + Timezone *string `json:"timezone,omitempty"` + RunAt *int64 `json:"run_at,omitempty"` + Status string `json:"status" binding:"required" enums:"active,paused,completed"` +} + +func (value Automation) MarshalJSON() ([]byte, error) { + switch value.Type { + case "periodic": + return json.Marshal(struct { + Type string `json:"type"` + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int64 `json:"created_at"` + Name string `json:"name"` + Target json.RawMessage `json:"target"` + Metadata map[string]string `json:"metadata"` + CreatedBy AutomationCreatedBy `json:"created_by"` + Expression *string `json:"expression"` + Timezone *string `json:"timezone"` + Status string `json:"status"` + }{ + Type: value.Type, + ID: value.ID, + Object: value.Object, + CreatedAt: value.CreatedAt, + Name: value.Name, + Target: value.Target, + Metadata: value.Metadata, + CreatedBy: value.CreatedBy, + Expression: value.Expression, + Timezone: value.Timezone, + Status: value.Status, + }) + case "once": + return json.Marshal(struct { + Type string `json:"type"` + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int64 `json:"created_at"` + Name string `json:"name"` + Target json.RawMessage `json:"target"` + Metadata map[string]string `json:"metadata"` + CreatedBy AutomationCreatedBy `json:"created_by"` + RunAt *int64 `json:"run_at"` + Status string `json:"status"` + }{ + Type: value.Type, + ID: value.ID, + Object: value.Object, + CreatedAt: value.CreatedAt, + Name: value.Name, + Target: value.Target, + Metadata: value.Metadata, + CreatedBy: value.CreatedBy, + RunAt: value.RunAt, + Status: value.Status, + }) + default: + return nil, fmt.Errorf("invalid Automation type %q", value.Type) + } +} + +// AutomationAgentConfig projects AutomationAgentConfigResource. +type AutomationAgentConfig struct { + Model string `json:"model" binding:"required"` + Reasoning Reasoning `json:"reasoning" binding:"required"` + Text TextConfig `json:"text" binding:"required"` + ServiceTier string `json:"service_tier" binding:"required" enums:"auto,default,flex,priority,fast,ultrafast"` + Instructions *string `json:"instructions" binding:"required" extensions:"x-nullable"` + Tools []json.RawMessage `json:"tools" binding:"required" swaggertype:"array,object"` + MultiAgent MultiAgentConfig `json:"multi_agent" binding:"required"` + Compaction json.RawMessage `json:"compaction,omitempty" swaggertype:"object"` + Experimental json.RawMessage `json:"experimental,omitempty" swaggertype:"object"` +} + +// AutomationCreatedBy projects AutomationCreatedByResource. +type AutomationCreatedBy struct { + Type string `json:"type" binding:"required" enums:"developer,agent"` + SessionID *string `json:"session_id,omitempty"` + ToolCallID *string `json:"tool_call_id,omitempty"` +} + +func (value AutomationCreatedBy) MarshalJSON() ([]byte, error) { + switch value.Type { + case "developer": + return json.Marshal(struct { + Type string `json:"type"` + }{ + Type: value.Type, + }) + case "agent": + return json.Marshal(struct { + Type string `json:"type"` + SessionID *string `json:"session_id"` + ToolCallID *string `json:"tool_call_id"` + }{ + Type: value.Type, + SessionID: value.SessionID, + ToolCallID: value.ToolCallID, + }) + default: + return nil, fmt.Errorf("invalid AutomationCreatedBy type %q", value.Type) + } +} + +// AutomationDeleted projects DeletedAutomationResource. +type AutomationDeleted struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.automation.deleted"` + Deleted bool `json:"deleted" binding:"required"` +} + +// AutomationEnvironment projects AutomationEnvironmentResource. +type AutomationEnvironment struct { + Type string `json:"type" binding:"required" enums:"none,openai_hosted,self_hosted"` + EnvironmentID *string `json:"environment_id,omitempty" extensions:"x-nullable"` + EnvironmentTemplateID *string `json:"environment_template_id,omitempty" extensions:"x-nullable"` + ContainerSize json.RawMessage `json:"container_size,omitempty" extensions:"x-nullable" swaggertype:"object"` + Packages *EnvironmentPackagesResponse `json:"packages,omitempty" extensions:"x-nullable"` + Network *EnvironmentNetwork `json:"network,omitempty" extensions:"x-nullable"` + Desktop json.RawMessage `json:"desktop,omitempty" extensions:"x-nullable" swaggertype:"object"` + CapabilityDirectories []string `json:"capability_directories,omitempty" extensions:"x-nullable"` + Skills []json.RawMessage `json:"skills,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + Plugins []json.RawMessage `json:"plugins,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + Files []json.RawMessage `json:"files,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + WorkspaceDirectory *string `json:"workspace_directory,omitempty"` +} + +func (value AutomationEnvironment) MarshalJSON() ([]byte, error) { + switch value.Type { + case "none": + return json.Marshal(struct { + Type string `json:"type"` + }{ + Type: value.Type, + }) + case "openai_hosted": + return json.Marshal(struct { + Type string `json:"type"` + EnvironmentID *string `json:"environment_id"` + EnvironmentTemplateID *string `json:"environment_template_id"` + ContainerSize json.RawMessage `json:"container_size"` + Packages *EnvironmentPackagesResponse `json:"packages"` + Network *EnvironmentNetwork `json:"network"` + Desktop json.RawMessage `json:"desktop"` + CapabilityDirectories []string `json:"capability_directories"` + Skills []json.RawMessage `json:"skills"` + Plugins []json.RawMessage `json:"plugins"` + Files []json.RawMessage `json:"files"` + }{ + Type: value.Type, + EnvironmentID: value.EnvironmentID, + EnvironmentTemplateID: value.EnvironmentTemplateID, + ContainerSize: value.ContainerSize, + Packages: value.Packages, + Network: value.Network, + Desktop: value.Desktop, + CapabilityDirectories: value.CapabilityDirectories, + Skills: value.Skills, + Plugins: value.Plugins, + Files: value.Files, + }) + case "self_hosted": + return json.Marshal(struct { + Type string `json:"type"` + EnvironmentID *string `json:"environment_id"` + WorkspaceDirectory *string `json:"workspace_directory"` + CapabilityDirectories []string `json:"capability_directories"` + }{ + Type: value.Type, + EnvironmentID: value.EnvironmentID, + WorkspaceDirectory: value.WorkspaceDirectory, + CapabilityDirectories: value.CapabilityDirectories, + }) + default: + return nil, fmt.Errorf("invalid AutomationEnvironment type %q", value.Type) + } +} + +// AutomationInvocation projects AutomationInvocationResource. +type AutomationInvocation struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.automation.invocation"` + AutomationID string `json:"automation_id" binding:"required"` + CreatedAt int64 `json:"created_at" binding:"required"` + Source AutomationInvocationSource `json:"source" binding:"required"` + ScheduledAt *int64 `json:"scheduled_at" binding:"required" extensions:"x-nullable"` + SessionID *string `json:"session_id" binding:"required" extensions:"x-nullable"` + Status string `json:"status" binding:"required" enums:"queued,completed,failed,skipped"` + SkipReason *string `json:"skip_reason" binding:"required" extensions:"x-nullable" enums:"pending_delivery"` + Error *AutomationInvocationError `json:"error" binding:"required" extensions:"x-nullable"` +} + +// AutomationInvocationError projects AutomationInvocationErrorResource. +type AutomationInvocationError struct { + Code string `json:"code" binding:"required"` + Message string `json:"message" binding:"required"` +} + +// AutomationInvocationList projects schema. +type AutomationInvocationList struct { + Object string `json:"object" binding:"required" enums:"list"` + FirstID *string `json:"first_id" binding:"required" extensions:"x-nullable"` + LastID *string `json:"last_id" binding:"required" extensions:"x-nullable"` + Data []AutomationInvocation `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` +} + +// AutomationInvocationSource projects AutomationInvocationSourceResource. +type AutomationInvocationSource struct { + Type string `json:"type" binding:"required" enums:"manual,automation"` + Automation json.RawMessage `json:"automation,omitempty" swaggertype:"object"` +} + +func (value AutomationInvocationSource) MarshalJSON() ([]byte, error) { + switch value.Type { + case "manual": + return json.Marshal(struct { + Type string `json:"type"` + }{ + Type: value.Type, + }) + case "automation": + return json.Marshal(struct { + Type string `json:"type"` + Automation json.RawMessage `json:"automation"` + }{ + Type: value.Type, + Automation: value.Automation, + }) + default: + return nil, fmt.Errorf("invalid AutomationInvocationSource type %q", value.Type) + } +} + +// AutomationList projects schema. +type AutomationList struct { + Object string `json:"object" binding:"required" enums:"list"` + FirstID *string `json:"first_id" binding:"required" extensions:"x-nullable"` + LastID *string `json:"last_id" binding:"required" extensions:"x-nullable"` + Data []Automation `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` +} + +// AutomationTarget projects AutomationTargetResource. +type AutomationTarget struct { + Type string `json:"type" binding:"required" enums:"new_session,existing_session"` + AgentID *string `json:"agent_id,omitempty" extensions:"x-nullable"` + Agent *AutomationAgentConfig `json:"agent,omitempty" extensions:"x-nullable"` + Environment *AutomationEnvironment `json:"environment,omitempty"` + Input []InputMessage `json:"input,omitempty"` + VaultIDs []string `json:"vault_ids,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + SessionID *string `json:"session_id,omitempty"` + Instructions *string `json:"instructions,omitempty"` +} + +func (value AutomationTarget) MarshalJSON() ([]byte, error) { + switch value.Type { + case "new_session": + return json.Marshal(struct { + Type string `json:"type"` + AgentID *string `json:"agent_id"` + Agent *AutomationAgentConfig `json:"agent"` + Environment *AutomationEnvironment `json:"environment"` + Input []InputMessage `json:"input"` + VaultIDs []string `json:"vault_ids"` + Metadata map[string]string `json:"metadata"` + }{ + Type: value.Type, + AgentID: value.AgentID, + Agent: value.Agent, + Environment: value.Environment, + Input: value.Input, + VaultIDs: value.VaultIDs, + Metadata: value.Metadata, + }) + case "existing_session": + return json.Marshal(struct { + Type string `json:"type"` + SessionID *string `json:"session_id"` + Instructions *string `json:"instructions"` + }{ + Type: value.Type, + SessionID: value.SessionID, + Instructions: value.Instructions, + }) + default: + return nil, fmt.Errorf("invalid AutomationTarget type %q", value.Type) + } +} + // CreateAgentRequest projects CreateAgentParams. type CreateAgentRequest struct { XAgentsCore *SavedAgentCoreInput `json:"x_agents_core,omitempty"` diff --git a/scripts/generate-public-api.py b/scripts/generate-public-api.py index 73b737652..62f6a1bc7 100644 --- a/scripts/generate-public-api.py +++ b/scripts/generate-public-api.py @@ -25,6 +25,8 @@ 'CreateEnvironmentTemplateParams': ('network',), 'UpdateEnvironmentTemplateParams': ('network',), 'CreateSessionEventsParams': (), + 'CreateAutomationParamsPeriodic': ('target',), 'CreateAutomationParamsOnce': ('target',), 'UpdateAutomationParams': ('target',), + 'AutomationTargetParamNewSession': ('agent', 'environment'), 'AutomationTargetParamExistingSession': (), } HTTP_METHODS = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options'} @@ -231,7 +233,7 @@ def go_shape(document, schema, required=False): elif kind == 'array': fields = ['kind: arrayValue', 'items: &' + go_shape(document, value['items'])] elif kind == 'integer': - fields = ['kind: integerValue', f'minimum: {value["minimum"]}'] + fields = ['kind: integerValue', f'minimum: {value.get("minimum", 0)}'] else: fields = [{'string': 'kind: stringValue', 'boolean': 'kind: booleanValue'}[kind]] if required: diff --git a/services/core/cmd/server/http_routes_test.go b/services/core/cmd/server/http_routes_test.go index 7ac68adbe..96192f9a9 100644 --- a/services/core/cmd/server/http_routes_test.go +++ b/services/core/cmd/server/http_routes_test.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha256" "fmt" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "io" "net" "net/http" @@ -95,6 +96,7 @@ func daemonComposition(t testing.TB) http.Handler { Files: struct{ api.Files }{}, FilesReader: struct{ api.FilesReader }{}, Skills: struct{ api.Skills }{}, SkillsReader: struct{ api.SkillsReader }{}, Agents: struct{ api.Agents }{}, AgentsReader: struct{ api.AgentsReader }{}, + Automations: struct{ api.Automations }{}, AutomationsReader: struct{ automations.Reader }{}, EnvironmentTemplates: struct{ api.EnvironmentTemplates }{}, EnvironmentTemplatesReader: struct{ api.EnvironmentTemplatesReader }{}, Sessions: struct{ api.Sessions }{}, SessionsReader: struct{ api.SessionsReader }{}, diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index 799a2a834..5321034da 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -31,10 +31,12 @@ import ( "os/signal" "syscall" "time" + _ "time/tzdata" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" @@ -46,6 +48,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/agentpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/automationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/coremetricspg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/deploymentpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/filepg" @@ -317,8 +320,16 @@ func run(config processconfig.Config) error { } return metricPtr(int64(result.Observed)), int64(result.Failed), err }} + automationStore := automationpg.New(units, credentialKey) + automationService, err := automations.NewService(automationStore) + if err != nil { + return err + } + // The pass needs the handler, which needs the metrics that run it. + deliverAutomations := func(context.Context) (*int64, int64, error) { return nil, 0, nil } + automationDelivery := coremetrics.Periodic{ID: "automation_delivery", Every: time.Second, Run: func(ctx context.Context) (*int64, int64, error) { return deliverAutomations(ctx) }} metricsSource := &coreMetricsSource{store: coremetricspg.New(units), pool: pool, worker: worker, registry: registry} - metrics, err := coremetrics.New(processStartedAt, buildRevision, metricsSource, sampling, + metrics, err := coremetrics.New(processStartedAt, buildRevision, metricsSource, sampling, automationDelivery, prune("history_cleanup", 2*time.Second, history.Prune), prune("audit_cleanup", 5*time.Second, func(ctx context.Context) (int64, error) { return auditStore.DeleteExpiredWriteOperations(ctx, time.Now().Add(-config.WriteAuditRetention), 1000) @@ -326,10 +337,6 @@ func run(config processconfig.Config) error { if err != nil { return err } - metricsCtx, cancelMetrics := context.WithCancel(ctx) - metricsDone := make(chan struct{}) - go func() { defer close(metricsDone); metrics.Run(metricsCtx) }() - defer func() { cancelMetrics(); <-metricsDone }() fileStore := filepg.New(units) fileService, err := files.NewService(fileStore) if err != nil { @@ -345,6 +352,7 @@ func run(config processconfig.Config) error { EnvironmentTemplates: environmentTemplates, EnvironmentTemplatesReader: templateStore, Files: fileService, FilesReader: fileStore, Agents: agentService, AgentsReader: agentStore, + Automations: automationService, AutomationsReader: automationStore, Sessions: sessionService, SessionsReader: sessionStore, SessionCreation: sessionService, @@ -379,6 +387,13 @@ func run(config processconfig.Config) error { if err != nil { return err } + if deliverAutomations, err = api.NewAutomationDelivery(deps); err != nil { + return err + } + metricsCtx, cancelMetrics := context.WithCancel(ctx) + metricsDone := make(chan struct{}) + go func() { defer close(metricsDone); metrics.Run(metricsCtx) }() + defer func() { cancelMetrics(); <-metricsDone }() handler := serverHandler(apiHandler, &daemonRoutes{gateway: daemonHandler, enrollment: runtimeenrollment.EnrollmentHandler(sessionService), connection: runtimeenrollment.ConnectionHandler(sessionStore, registry), diff --git a/services/core/internal/api/automation_delivery.go b/services/core/internal/api/automation_delivery.go new file mode 100644 index 000000000..1ea056753 --- /dev/null +++ b/services/core/internal/api/automation_delivery.go @@ -0,0 +1,151 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +const ( + // automationBatch bounds the deliveries one pass runs at once. + automationBatch = 16 + // automationDeliveryTimeout bounds one delivery. + automationDeliveryTimeout = 5 * time.Second +) + +// deliverAutomations is one pass: it records the due occurrences and delivers +// the queued invocations. A delivery that cannot finish yet stays queued for +// the next pass. It reports what it recorded and settled, and counts only +// storage failures as failed; an invocation that failed for good is a settled +// record, not a job failure. +func (h *Handler) deliverAutomations(ctx context.Context) (*int64, int64, error) { + recorded, err := h.Automations.Record(ctx) + if err != nil { + return nil, 1, err + } + deliveries, err := h.Automations.Deliverable(ctx, automationBatch) + if err != nil { + return nil, 1, err + } + var settled, failed atomic.Int64 + var wg sync.WaitGroup + for _, d := range deliveries { + wg.Add(1) + go func() { + defer wg.Done() + outcome, done := h.deliverAutomation(ctx, d) + if !done { + return + } + if err := h.Automations.Settle(ctx, outcome); err != nil { + log.Ctx(ctx).Error("Automation invocation could not be settled", "error", err) + failed.Add(1) + return + } + settled.Add(1) + }() + } + wg.Wait() + processed := int64(recorded) + settled.Load() + if processed == 0 && failed.Load() == 0 { + return nil, 0, nil + } + return &processed, failed.Load(), nil +} + +// deliverAutomation runs one delivery through the code the public routes run. +// It returns the outcome to settle and false when the delivery stays queued. +func (h *Handler) deliverAutomation(ctx context.Context, d automations.Delivery) (automations.Outcome, bool) { + ctx, cancel := context.WithTimeout(context.WithValue(ctx, principalContextKey{}, d.Scope), automationDeliveryTimeout) + defer cancel() + response := &capturedResponse{header: http.Header{}} + outcome := automations.Outcome{InvocationID: d.InvocationID, Status: "completed"} + request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/v1/agents/sessions", bytes.NewReader(d.Request)) + if err != nil { + return outcome, false + } + if d.TargetType == "new_session" { + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Idempotency-Key", d.InvocationID) + h.createSession(response, request) + if response.status == http.StatusCreated { + var session struct { + ID string `json:"id"` + } + if json.Unmarshal(response.body.Bytes(), &session) != nil || session.ID == "" { + return outcome, false + } + outcome.SessionID = session.ID + return outcome, true + } + } else { + var target struct { + SessionID string `json:"session_id"` + Instructions string `json:"instructions"` + } + _ = json.Unmarshal(d.Request, &target) + inputs, err := executionInputs([]json.RawMessage{instructionsEvent(target.Instructions)}) + if err == nil { + _, err = h.Execution.InputAdmission.SubmitInputs(ctx, d.Scope.TenantID, target.SessionID, d.InvocationID, inputs) + } + if err == nil { + outcome.SessionID = target.SessionID + return outcome, true + } + if errors.Is(err, sessions.ErrInputPending) || errors.Is(err, sessions.ErrTurnConflict) || errors.Is(err, execution.ErrExecutionUnavailable) || ctx.Err() != nil { + return outcome, false + } + writeInputError(response, request, err) + } + var envelope struct { + Error struct { + Type string `json:"type"` + Code *string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + _ = json.Unmarshal(response.body.Bytes(), &envelope) + code := envelope.Error.Type + if envelope.Error.Code != nil { + code = *envelope.Error.Code + } + if response.status == http.StatusServiceUnavailable && (code == "execution_unavailable" || code == "sandbox_reset_in_progress") || ctx.Err() != nil { + return outcome, false + } + outcome.Status, outcome.Code, outcome.Message = "failed", code, envelope.Error.Message + if outcome.Code == "" { + outcome.Code, outcome.Message = "internal_error", "The delivery could not be completed." + } + return outcome, true +} + +// capturedResponse records a response written by a handler the delivery calls +// in process. +type capturedResponse struct { + header http.Header + status int + body bytes.Buffer +} + +func (c *capturedResponse) Header() http.Header { return c.header } + +func (c *capturedResponse) WriteHeader(status int) { + if c.status == 0 { + c.status = status + } +} + +func (c *capturedResponse) Write(p []byte) (int, error) { + c.WriteHeader(http.StatusOK) + return c.body.Write(p) +} diff --git a/services/core/internal/api/automations.go b/services/core/internal/api/automations.go new file mode 100644 index 000000000..a5f94bf61 --- /dev/null +++ b/services/core/internal/api/automations.go @@ -0,0 +1,428 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + "unicode/utf8" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" + "github.com/go-chi/chi/v5" +) + +// Automations runs the Automation use cases and the delivery pass. +type Automations interface { + Create(context.Context, automations.Automation) (automations.Automation, error) + Update(ctx context.Context, tenantID, id string, patch automations.Patch) (automations.Automation, error) + Pause(ctx context.Context, tenantID, id string) (automations.Automation, error) + Resume(ctx context.Context, tenantID, id string) (automations.Automation, error) + Delete(ctx context.Context, tenantID, id string) (string, error) + Invoke(ctx context.Context, tenantID, id string) (automations.Invocation, error) + Record(context.Context) (int, error) + Deliverable(ctx context.Context, limit int) ([]automations.Delivery, error) + Settle(context.Context, automations.Outcome) error +} + +const maxInstructions = 1 << 20 + +func (h *Handler) registerAutomationRoutes(r chi.Router) { + const base = "/agents/automations" + r.Post(base, h.createAutomation) + r.Get(base, h.listAutomations) + r.Get(base+"/{automation_id}", h.getAutomation) + r.Post(base+"/{automation_id}", h.updateAutomation) + r.Delete(base+"/{automation_id}", h.deleteAutomation) + r.Post(base+"/{automation_id}/pause", h.pauseAutomation) + r.Post(base+"/{automation_id}/resume", h.resumeAutomation) + r.Post(base+"/{automation_id}/invocations", h.invokeAutomation) + r.Get(base+"/{automation_id}/invocations", h.listAutomationInvocations) + r.Get(base+"/{automation_id}/invocations/{invocation_id}", h.getAutomationInvocation) +} + +// automationTarget is a validated target: its type, the safe projection the +// resource shows and the request the delivery replays. +type automationTarget struct { + kind string + projection, contents json.RawMessage +} + +func (h *Handler) createAutomation(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONObjectLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") + if !ok { + return + } + var kind struct { + Type string `json:"type"` + } + _ = json.Unmarshal(raw, &kind) + pinned := createAutomationParamsPeriodic + if kind.Type == automations.Once { + pinned = createAutomationParamsOnce + } + if writeFieldError(w, validateBody(raw, pinned)) { + return + } + var body struct { + Type string `json:"type"` + Name string `json:"name"` + Metadata map[string]string `json:"metadata"` + Expression string `json:"expression"` + Timezone string `json:"timezone"` + RunAt int64 `json:"run_at"` + Target json.RawMessage `json:"target"` + } + _ = json.Unmarshal(raw, &body) + if err := automationFields(&body.Name, body.Metadata); err != nil { + writeFieldError(w, err) + return + } + if body.Type == automations.Periodic { + if err := boundedText("expression", body.Expression, 256); err != nil { + writeFieldError(w, err) + return + } + if err := boundedText("timezone", body.Timezone, 128); err != nil { + writeFieldError(w, err) + return + } + } + target, err := h.automationTarget(r, body.Target) + if err != nil { + h.writeTargetError(w, r, err) + return + } + a := automations.Automation{TenantID: tenantID(r), Type: body.Type, Name: body.Name, Metadata: metadataOrEmpty(body.Metadata), Expression: body.Expression, Timezone: body.Timezone, TargetType: target.kind, Target: target.projection, Contents: target.contents} + if body.Type == automations.Once { + runAt := time.Unix(body.RunAt, 0) + a.RunAt = &runAt + } + created, err := h.Automations.Create(r.Context(), a) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, created.Resource()) +} + +func (h *Handler) updateAutomation(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONObjectLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") + if !ok || writeFieldError(w, validateBody(raw, updateAutomationParams)) { + return + } + var body struct { + Name *string `json:"name"` + Metadata map[string]string `json:"metadata"` + Expression *string `json:"expression"` + Timezone *string `json:"timezone"` + RunAt *int64 `json:"run_at"` + Target json.RawMessage `json:"target"` + } + _ = json.Unmarshal(raw, &body) + patch := automations.Patch{Name: body.Name, Metadata: body.Metadata, Expression: body.Expression, Timezone: body.Timezone} + if body.Name != nil { + if err := automationFields(body.Name, body.Metadata); err != nil { + writeFieldError(w, err) + return + } + } else if err := automationFields(nil, body.Metadata); err != nil { + writeFieldError(w, err) + return + } + for param, value := range map[string]*string{"expression": body.Expression, "timezone": body.Timezone} { + if value == nil { + continue + } + limit := 256 + if param == "timezone" { + limit = 128 + } + if err := boundedText(param, *value, limit); err != nil { + writeFieldError(w, err) + return + } + } + if body.RunAt != nil { + runAt := time.Unix(*body.RunAt, 0) + patch.RunAt = &runAt + } + if body.Target != nil { + target, err := h.automationTarget(r, body.Target) + if err != nil { + h.writeTargetError(w, r, err) + return + } + patch.TargetType, patch.Target, patch.Contents = target.kind, target.projection, target.contents + } + updated, err := h.Automations.Update(r.Context(), tenantID(r), chi.URLParam(r, "automation_id"), patch) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusOK, updated.Resource()) +} + +func (h *Handler) getAutomation(w http.ResponseWriter, r *http.Request) { + a, err := h.AutomationsReader.Get(r.Context(), tenantID(r), chi.URLParam(r, "automation_id")) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusOK, a.Resource()) +} + +func (h *Handler) listAutomations(w http.ResponseWriter, r *http.Request) { + options, ok := readClampedPage(w, r, "session_id") + if !ok { + return + } + page, err := h.AutomationsReader.List(r.Context(), tenantID(r), automations.ListQuery{After: options.after, Limit: options.limit, Ascending: options.ascending, SessionID: r.URL.Query().Get("session_id")}) + if err != nil { + writeAutomationsError(w, r, err) + return + } + response := v1.AutomationList{Object: "list", Data: make([]v1.Automation, 0, len(page.Automations)), HasMore: page.HasMore} + for _, a := range page.Automations { + response.Data = append(response.Data, a.Resource()) + } + if len(response.Data) > 0 { + response.FirstID, response.LastID = &response.Data[0].ID, &response.Data[len(response.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) deleteAutomation(w http.ResponseWriter, r *http.Request) { + id, err := h.Automations.Delete(r.Context(), tenantID(r), chi.URLParam(r, "automation_id")) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.AutomationDeleted{ID: id, Object: "agent.automation.deleted", Deleted: true}) +} + +func (h *Handler) pauseAutomation(w http.ResponseWriter, r *http.Request) { + h.changeAutomation(w, r, h.Automations.Pause) +} + +func (h *Handler) resumeAutomation(w http.ResponseWriter, r *http.Request) { + h.changeAutomation(w, r, h.Automations.Resume) +} + +func (h *Handler) changeAutomation(w http.ResponseWriter, r *http.Request, change func(context.Context, string, string) (automations.Automation, error)) { + a, err := change(r.Context(), tenantID(r), chi.URLParam(r, "automation_id")) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusOK, a.Resource()) +} + +func (h *Handler) invokeAutomation(w http.ResponseWriter, r *http.Request) { + invocation, err := h.Automations.Invoke(r.Context(), tenantID(r), chi.URLParam(r, "automation_id")) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusOK, invocation.Resource()) +} + +func (h *Handler) getAutomationInvocation(w http.ResponseWriter, r *http.Request) { + invocation, err := h.AutomationsReader.GetInvocation(r.Context(), tenantID(r), chi.URLParam(r, "automation_id"), chi.URLParam(r, "invocation_id")) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusOK, invocation.Resource()) +} + +func (h *Handler) listAutomationInvocations(w http.ResponseWriter, r *http.Request) { + options, ok := readClampedPage(w, r) + if !ok { + return + } + page, err := h.AutomationsReader.ListInvocations(r.Context(), tenantID(r), chi.URLParam(r, "automation_id"), automations.ListQuery{After: options.after, Limit: options.limit, Ascending: options.ascending}) + if err != nil { + writeAutomationsError(w, r, err) + return + } + response := v1.AutomationInvocationList{Object: "list", Data: make([]v1.AutomationInvocation, 0, len(page.Invocations)), HasMore: page.HasMore} + for _, invocation := range page.Invocations { + response.Data = append(response.Data, invocation.Resource()) + } + if len(response.Data) > 0 { + response.FirstID, response.LastID = &response.Data[0].ID, &response.Data[len(response.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} + +// writeTargetError reports a target that failed validation: a field error, or +// an error of the Session create or lookup rules the target reuses. +func (h *Handler) writeTargetError(w http.ResponseWriter, r *http.Request, err error) { + if !writeFieldError(w, err) { + writeSessionsError(w, r, err) + } +} + +// automationFields checks the name and metadata of a request. A nil name is +// not checked. +func automationFields(name *string, values map[string]string) error { + if name != nil { + if err := boundedText("name", *name, 256); err != nil { + return err + } + } + return metadataFieldError(metadata.Validate(values)) +} + +func boundedText(param, value string, limit int) error { + if n := utf8.RuneCountInString(value); n < 1 || n > limit { + return &fieldError{param: param, message: fmt.Sprintf("%s must contain 1 to %d characters.", param, limit)} + } + return nil +} + +// automationTarget validates a target and builds what is saved. A new_session +// target is validated as the Session create body it replays; an +// existing_session target must name a Session and hold deliverable text. +func (h *Handler) automationTarget(r *http.Request, raw json.RawMessage) (automationTarget, error) { + var fields map[string]json.RawMessage + _ = json.Unmarshal(raw, &fields) + var kind string + _ = json.Unmarshal(fields["type"], &kind) + if kind == "existing_session" { + return h.existingSessionTarget(r, raw, fields) + } + if kind != "new_session" && fields["type"] != nil { + if err := checkValue("target.type", fields["type"], shape{kind: enumValue, values: []string{"new_session", "existing_session"}}); err != nil { + return automationTarget{}, err + } + } + return h.newSessionTarget(raw, fields) +} + +func (h *Handler) existingSessionTarget(r *http.Request, raw json.RawMessage, fields map[string]json.RawMessage) (automationTarget, error) { + if err := checkValue("target", raw, automationTargetParamExistingSession); err != nil { + return automationTarget{}, err + } + var target struct { + SessionID string `json:"session_id"` + Instructions string `json:"instructions"` + } + _ = json.Unmarshal(raw, &target) + if len(target.Instructions) > maxInstructions { + return automationTarget{}, &fieldError{param: "target.instructions", message: "instructions must not exceed 1 MiB."} + } + if _, err := executionInputs([]json.RawMessage{instructionsEvent(target.Instructions)}); err != nil { + return automationTarget{}, &fieldError{param: "target.instructions", message: "instructions must be non-empty text this service can store."} + } + if _, err := h.SessionsReader.GetSession(r.Context(), tenantID(r), target.SessionID); err != nil { + return automationTarget{}, err + } + delete(fields, "type") + contents, _ := json.Marshal(fields) + projection, _ := json.Marshal(v1.AutomationTarget{Type: "existing_session", SessionID: &target.SessionID, Instructions: &target.Instructions}) + return automationTarget{kind: "existing_session", projection: projection, contents: contents}, nil +} + +// instructionsEvent is the input an existing_session delivery sends: instructions as +// one user message. +func instructionsEvent(instructions string) json.RawMessage { + event, _ := json.Marshal(map[string]any{"type": "agent.session.input.message", "input": []v1.InputMessage{{Type: "message", Role: "user", Content: []v1.InputContent{{Type: "input_text", Text: &instructions}}}}}) + return event +} + +func (h *Handler) newSessionTarget(raw json.RawMessage, fields map[string]json.RawMessage) (automationTarget, error) { + if err := checkValue("target", raw, automationTargetParamNewSession); err != nil { + return automationTarget{}, err + } + _, inline := fields["agent"] + _, saved := fields["agent_id"] + if inline == saved { + return automationTarget{}, &fieldError{param: "target", message: "Provide exactly one of agent and agent_id."} + } + if inline { + var agent map[string]json.RawMessage + _ = json.Unmarshal(fields["agent"], &agent) + if agent["x_agents_core"] != nil { + return automationTarget{}, &fieldError{param: "target.agent.x_agents_core", message: "x_agents_core is not supported in an automation target."} + } + if agent["model"] == nil { + return automationTarget{}, &fieldError{param: "target.agent.model", message: "agent.model is required in an automation target."} + } + } + delete(fields, "type") + contents, _ := json.Marshal(fields) + input, _, err := parseSessionRequest(contents) + if err != nil { + var field *fieldError + if errors.As(err, &field) { + param := "target" + if field.param != "" { + param += "." + field.param + } + err = &fieldError{param: param, message: field.message, code: field.code} + } + return automationTarget{}, err + } + target := v1.AutomationTarget{Type: "new_session", AgentID: input.AgentID, Environment: automationEnvironment(input), VaultIDs: input.VaultIDs, Metadata: input.Metadata} + _ = json.Unmarshal(fields["input"], &target.Input) + for i := range target.Input { + target.Input[i].Type = "message" + } + if inline { + agent, err := resolveSessionAgent(input, nil) + if err != nil { + return automationTarget{}, err + } + config := v1.AutomationAgentConfig{Model: agent.Model, Reasoning: agent.Reasoning, Text: agent.Text, ServiceTier: agent.ServiceTier, Instructions: agent.Instructions, Tools: make([]json.RawMessage, 0, len(agent.Tools)), MultiAgent: agent.MultiAgent, Compaction: agent.Compaction, Experimental: agent.Experimental} + for _, tool := range agent.Tools { + config.Tools = append(config.Tools, withoutMCPHeaders(tool)) + } + target.Agent = &config + } + projection, err := json.Marshal(target) + return automationTarget{kind: "new_session", projection: projection, contents: contents}, err +} + +// automationEnvironment projects the environment the request supplied. A member +// the request left out stays nil, which the resource shows as null: inherit. +func automationEnvironment(input sessionRequest) *v1.AutomationEnvironment { + env := input.Environment + result := &v1.AutomationEnvironment{Type: env.Type} + var supplied map[string]json.RawMessage + _ = json.Unmarshal(input.originalEnvironment, &supplied) + has := func(name string) bool { value, ok := supplied[name]; return ok && string(value) != "null" } + if has("environment_template_id") { + result.EnvironmentTemplateID = &input.templateID + } + if has("packages") && env.Packages != nil { + packages := packageMetadata(env.Packages) + result.Packages = &packages + } + if has("network") && env.Network != nil { + result.Network = &v1.EnvironmentNetwork{Access: env.Network.Access, AllowedDomains: append([]string{}, env.Network.AllowedDomains...)} + } + if has("desktop") { + result.Desktop = json.RawMessage(`{"enabled":false}`) + } + if has("capability_directories") { + result.CapabilityDirectories = append([]string{}, env.CapabilityDirectories...) + } + if has("skills") { + result.Skills = append([]json.RawMessage{}, env.Skills...) + } + if has("plugins") { + result.Plugins = append([]json.RawMessage{}, env.Plugins...) + } + if has("files") { + result.Files = append([]json.RawMessage{}, env.Files...) + } + if env.WorkspaceDirectory != "" { + result.WorkspaceDirectory = &env.WorkspaceDirectory + } + return result +} diff --git a/services/core/internal/api/dependencies.go b/services/core/internal/api/dependencies.go index 8feec2ab9..ff4d65fc6 100644 --- a/services/core/internal/api/dependencies.go +++ b/services/core/internal/api/dependencies.go @@ -1,10 +1,12 @@ package api import ( + "context" "errors" "fmt" "net/http" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -43,6 +45,8 @@ type Dependencies struct { EnvironmentTemplates EnvironmentTemplates Agents Agents AgentsReader AgentsReader + Automations Automations + AutomationsReader automations.Reader Sessions Sessions SessionsReader SessionsReader SessionCreation SessionCreation @@ -102,6 +106,25 @@ type Handler struct { // NewHandler builds the HTTP handler from complete dependencies. func NewHandler(deps Dependencies) (http.Handler, error) { + h, err := newHandler(deps) + if err != nil { + return nil, err + } + return CanonicalPaths(h.routes()), nil +} + +// NewAutomationDelivery returns the pass that records due Automation +// occurrences and delivers queued invocations through the handler's Session +// create and input paths. +func NewAutomationDelivery(deps Dependencies) (func(context.Context) (*int64, int64, error), error) { + h, err := newHandler(deps) + if err != nil { + return nil, err + } + return h.deliverAutomations, nil +} + +func newHandler(deps Dependencies) (*Handler, error) { if err := deps.validate(); err != nil { return nil, err } @@ -110,7 +133,7 @@ func NewHandler(deps Dependencies) (http.Handler, error) { for _, kind := range deps.Harnesses { h.harnesses[kind] = true } - return CanonicalPaths(h.routes()), nil + return h, nil } func (d Dependencies) validate() error { @@ -130,6 +153,7 @@ func (d Dependencies) validate() error { field{"EnvironmentTemplates", d.EnvironmentTemplates}, field{"EnvironmentTemplatesReader", d.EnvironmentTemplatesReader}, field{"Skills", d.Skills}, field{"SkillsReader", d.SkillsReader}, field{"Agents", d.Agents}, field{"AgentsReader", d.AgentsReader}, + field{"Automations", d.Automations}, field{"AutomationsReader", d.AutomationsReader}, field{"Sessions", d.Sessions}, field{"SessionsReader", d.SessionsReader}, field{"SessionCreation", d.SessionCreation}, field{"SessionEvents", d.SessionEvents}, diff --git a/services/core/internal/api/dependencies_test.go b/services/core/internal/api/dependencies_test.go index 48262d85b..6f09dc800 100644 --- a/services/core/internal/api/dependencies_test.go +++ b/services/core/internal/api/dependencies_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "net/http" "strings" "testing" @@ -106,6 +107,7 @@ func testDependencies(t testing.TB) (Dependencies, *testFakes) { EnvironmentTemplates: f.environmentTemplates, EnvironmentTemplatesReader: f.environmentTemplatesReader, Skills: f.skills, SkillsReader: f.skillsReader, Agents: f.agents, AgentsReader: f.agentsReader, + Automations: struct{ Automations }{}, AutomationsReader: struct{ automations.Reader }{}, Sessions: f.sessions, SessionsReader: f.sessionsReader, SessionCreation: f.sessionCreation, diff --git a/services/core/internal/api/errors_automations.go b/services/core/internal/api/errors_automations.go new file mode 100644 index 000000000..791b82456 --- /dev/null +++ b/services/core/internal/api/errors_automations.go @@ -0,0 +1,25 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" +) + +// writeAutomationsError maps the errors of Automation operations. +func writeAutomationsError(w http.ResponseWriter, r *http.Request, err error) { + var invalid *automations.InvalidError + var conflict *automations.ConflictError + switch { + case errors.Is(err, automations.ErrNotFound): + writeError(w, http.StatusNotFound, "not_found_error", "Resource not found.") + case errors.As(err, &invalid): + writeError(w, http.StatusBadRequest, "invalid_request_error", invalid.Message, invalid.Param) + case errors.As(err, &conflict): + writeError(w, http.StatusConflict, "conflict_error", conflict.Message) + case writeStoredDataError(w, r, err), writeTextValueError(w, r, err), writeAuditSourceError(w, r, err): + default: + writeInternalError(w, r) + } +} diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index 188221aff..bda44d301 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -118,14 +118,9 @@ func (h *Handler) routes() *chi.Mux { r.Get("/agents/sessions/{session_id}/artifacts/{artifact_id}/content", h.sessionArtifactContent) r.Head("/agents/sessions/{session_id}/artifacts/{artifact_id}/content", methodNotAllowed) r.Delete("/agents/sessions/{session_id}/artifacts/{artifact_id}", h.deleteSessionArtifact) + h.registerAutomationRoutes(r) // Official operations Core does not run answer like an unknown route. - const automations = "/agents/automations" for _, route := range []struct{ method, path string }{ - {http.MethodPost, automations}, {http.MethodGet, automations}, - {http.MethodGet, automations + "/{automation_id}"}, {http.MethodPost, automations + "/{automation_id}"}, {http.MethodDelete, automations + "/{automation_id}"}, - {http.MethodPost, automations + "/{automation_id}/pause"}, {http.MethodPost, automations + "/{automation_id}/resume"}, - {http.MethodPost, automations + "/{automation_id}/invocations"}, {http.MethodGet, automations + "/{automation_id}/invocations"}, - {http.MethodGet, automations + "/{automation_id}/invocations/{invocation_id}"}, {http.MethodPost, "/agents/environments"}, {http.MethodGet, "/agents/environments"}, {http.MethodGet, "/agents/sessions/{session_id}/traces"}, } { @@ -141,14 +136,12 @@ func unsupportedOperation(w http.ResponseWriter, _ *http.Request) { writeError(w, http.StatusNotFound, "unsupported_operation", "This API operation is not supported.") } -// createSession atomically reserves or admits initial text with the Session. -func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { - raw, ok := readJSONObjectLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") - if !ok { - return - } - if writeFieldError(w, validateBody(raw, createAgentSessionParams)) { - return +// parseSessionRequest validates a Session create body and decodes it with its +// initial inputs. Every failure that is not a field error is a fieldError +// without a param, so callers report one shape. +func parseSessionRequest(raw []byte) (sessionRequest, []sessions.Input, error) { + if err := validateBody(raw, createAgentSessionParams); err != nil { + return sessionRequest{}, nil, err } var request decodedSessionRequest decoder := json.NewDecoder(bytes.NewReader(raw)) @@ -156,33 +149,46 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { // An unknown member, including a case variant such as Metadata, is rejected // before decoding; see inexactMember. if inexactMember(raw, reflect.TypeOf(request)) || decoder.Decode(&request) != nil { - writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") - return + return sessionRequest{}, nil, &fieldError{code: "invalid_request", message: "Request must be a JSON object containing supported fields."} } input, err := request.validated() if err != nil { - if !writeFieldError(w, err) { - writeError(w, http.StatusBadRequest, "invalid_request", "Request fields have invalid types or null values.") + var field *fieldError + if !errors.As(err, &field) { + err = &fieldError{code: "invalid_request", message: "Request fields have invalid types or null values."} } - return - } - key := r.Header.Get("Idempotency-Key") - if key == "" { - key = uuid.NewString() + return input, nil, err } initialInputs, err := initialSessionInputs(input.Input) if err != nil { - writeSessionsError(w, r, err) - return + return input, nil, err } if input.Environment.Type == "none" && len(initialInputs) == 0 { - writeError(w, http.StatusBadRequest, "invalid_request_error", "conversation-only sessions currently require initial input") - return + return input, nil, &fieldError{code: "invalid_request_error", message: "conversation-only sessions currently require initial input"} } if input.Stream && input.Environment.Type != "self_hosted" && len(initialInputs) == 0 { - writeError(w, http.StatusBadRequest, "invalid_request_error", "streaming session creation requires initial input") + return input, nil, &fieldError{code: "invalid_request_error", message: "streaming session creation requires initial input"} + } + return input, initialInputs, nil +} + +// createSession atomically reserves or admits initial text with the Session. +func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONObjectLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") + if !ok { return } + input, initialInputs, err := parseSessionRequest(raw) + if err != nil { + if !writeFieldError(w, err) { + writeSessionsError(w, r, err) + } + return + } + key := r.Header.Get("Idempotency-Key") + if key == "" { + key = uuid.NewString() + } creationRequest, err := sessionCreationRequest(input, initialInputs) if err != nil { writeSessionsError(w, r, err) diff --git a/services/core/internal/api/official_shapes.gen.go b/services/core/internal/api/official_shapes.gen.go index 729e78e9c..1d8cb5985 100644 --- a/services/core/internal/api/official_shapes.gen.go +++ b/services/core/internal/api/official_shapes.gen.go @@ -556,4 +556,52 @@ var ( }, }}, required: true}}, }} + createAutomationParamsPeriodic = shape{kind: objectValue, members: []member{ + {"type", shape{kind: enumValue, values: []string{"periodic"}, required: true}}, + {"name", shape{kind: stringValue, required: true}}, + {"target", shape{kind: openObject, required: true}}, + {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}}}, + {"expression", shape{kind: stringValue, required: true}}, + {"timezone", shape{kind: stringValue, required: true}}, + }} + createAutomationParamsOnce = shape{kind: objectValue, members: []member{ + {"type", shape{kind: enumValue, values: []string{"once"}, required: true}}, + {"name", shape{kind: stringValue, required: true}}, + {"target", shape{kind: openObject, required: true}}, + {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}}}, + {"run_at", shape{kind: integerValue, minimum: 0, required: true}}, + }} + updateAutomationParams = shape{kind: objectValue, members: []member{ + {"name", shape{kind: stringValue}}, + {"target", shape{kind: openObject}}, + {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}}}, + {"expression", shape{kind: stringValue}}, + {"timezone", shape{kind: stringValue}}, + {"run_at", shape{kind: integerValue, minimum: 0}}, + }} + automationTargetParamNewSession = shape{kind: objectValue, members: []member{ + {"type", shape{kind: enumValue, values: []string{"new_session"}, required: true}}, + {"agent", shape{kind: openObject}}, + {"agent_id", shape{kind: stringValue}}, + {"environment", shape{kind: openObject, required: true}}, + {"input", shape{kind: arrayValue, items: &shape{kind: objectValue, members: []member{ + {"type", shape{kind: enumValue, values: []string{"message"}}}, + {"role", shape{kind: enumValue, values: []string{"user"}, required: true}}, + {"content", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"input_text", "input_image"}, variants: map[string][]member{ + "input_text": { + {"text", shape{kind: stringValue, required: true}}, + }, + "input_image": { + {"image_url", shape{kind: stringValue, required: true}}, + }, + }}, required: true}}, + }}, required: true}}, + {"vault_ids", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}, nullable: true}}, + }} + automationTargetParamExistingSession = shape{kind: objectValue, members: []member{ + {"type", shape{kind: enumValue, values: []string{"existing_session"}, required: true}}, + {"session_id", shape{kind: stringValue, required: true}}, + {"instructions", shape{kind: stringValue, required: true}}, + }} ) diff --git a/services/core/internal/api/routing_test.go b/services/core/internal/api/routing_test.go index 00fbcf5d9..f3a75cc87 100644 --- a/services/core/internal/api/routing_test.go +++ b/services/core/internal/api/routing_test.go @@ -349,7 +349,7 @@ func TestEveryRouteAuthenticatesItsCanonicalPath(t *testing.T) { t.Errorf("/v1 routes differ from the pinned set:\nserved %v\npinned %v", served, pinned.Routes) } // Official operations Core does not run answer like an unknown route. - for _, route := range []string{"POST /v1/agents/automations", "GET /v1/agents/automations/a/invocations/i", "GET /v1/agents/environments", "GET /v1/agents/sessions/s/traces"} { + for _, route := range []string{"POST /v1/agents/environments", "GET /v1/agents/environments", "GET /v1/agents/sessions/s/traces"} { method, target, _ := strings.Cut(route, " ") if got := serve(handler, method, target, "", withHeaders(project, beta)); got.Code != http.StatusNotFound || !strings.Contains(got.Body.String(), `"code":"unsupported_operation"`) { t.Errorf("%s = %d %s", route, got.Code, got.Body) diff --git a/services/core/internal/automations/automation.go b/services/core/internal/automations/automation.go new file mode 100644 index 000000000..928880716 --- /dev/null +++ b/services/core/internal/automations/automation.go @@ -0,0 +1,242 @@ +// Package automations owns Automations: saved schedules that deliver a target +// Session create or instructions, and the invocation records of each delivery. +// This package holds the rules; the PostgreSQL adapter stores them and the API +// package runs the deliveries. +package automations + +import ( + "encoding/json" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" +) + +// Automation statuses and types. +const ( + Active = "active" + Paused = "paused" + Completed = "completed" + + Periodic = "periodic" + Once = "once" +) + +// Automation is a saved schedule. Target is the safe projection of its target; +// Contents is the target request, set only on the way in, and the storage seals +// it. +type Automation struct { + ID, TenantID string + Type string + Name string + Metadata map[string]string + Status string + RunAt *time.Time // once + Expression string // periodic + Timezone string // periodic + // NextRunAt is the next occurrence to record while the Automation is active. + NextRunAt *time.Time + TargetType string + Target json.RawMessage + Contents json.RawMessage + CreatedAt time.Time + // Pending and Queued report a queued scheduled invocation and a queued + // invocation of any source. Only locked reads fill them. + Pending, Queued bool +} + +// Invocation is the delivery record of one occurrence or manual invocation. +type Invocation struct { + ID, AutomationID string + CreatedAt time.Time + ScheduledAt *time.Time + // Snapshot is the Automation resource at the occurrence; nil for a manual + // invocation. + Snapshot json.RawMessage + Status, SkipReason, SessionID, Code, Message string +} + +// Delivery is a queued invocation ready to deliver, with the Project that owns +// the Automation and the opened target request. +type Delivery struct { + InvocationID, AutomationID, TargetType string + Scope identity.Principal + Request json.RawMessage +} + +// Outcome settles a queued invocation. Status is completed or failed. +type Outcome struct { + InvocationID, Status, SessionID, Code, Message string +} + +// Patch is an update. Nil members keep the stored value. A target replaces the +// whole target and keeps its type. +type Patch struct { + Name *string + Metadata map[string]string + TargetType string + Target, Contents json.RawMessage + Expression, Timezone *string + RunAt *time.Time +} + +// Occurrence is what a due Automation records. +type Occurrence struct { + ScheduledAt time.Time + // Skipped reports an occurrence recorded as skipped because an earlier + // invocation is still queued. + Skipped bool + // Next is the Automation's next occurrence; nil for none. + Next *time.Time + // Completed reports a one-time Automation whose schedule has finished. + Completed bool +} + +// Occurrence decides the invocation of an active Automation whose NextRunAt is +// due. After downtime a periodic Automation records only its latest due +// occurrence. +func (a Automation) Occurrence(now time.Time) Occurrence { + o := Occurrence{ScheduledAt: *a.NextRunAt, Skipped: a.Queued} + if a.Type == Once { + o.Completed = o.Skipped + return o + } + schedule, err := ParseSchedule(a.Expression, a.Timezone) + for err == nil { + next, ok := schedule.Next(o.ScheduledAt) + if !ok { + break + } + if next.After(now) { + o.Next = &next + break + } + o.ScheduledAt = next + } + return o +} + +// nextRun is the first occurrence of a periodic Automation after now. +func (a Automation) nextRun(now time.Time) (*time.Time, error) { + schedule, err := ParseSchedule(a.Expression, a.Timezone) + if err != nil { + return nil, err + } + next, _ := schedule.Next(now) + return &next, nil +} + +// Pause stops scheduled delivery. A paused Automation stays paused. +func (a Automation) Pause() (Automation, error) { + if a.Status == Completed { + return a, &ConflictError{"A completed automation cannot be paused."} + } + a.Status = Paused + return a, nil +} + +// Resume restarts scheduled delivery. A one-time Automation without a pending +// scheduled invocation needs a future run_at. An active Automation is unchanged. +func (a Automation) Resume(now time.Time) (Automation, error) { + var err error + switch { + case a.Status == Completed: + return a, &ConflictError{"A completed automation cannot be resumed."} + case a.Status == Active: + return a, nil + case a.Type == Periodic: + a.NextRunAt, err = a.nextRun(now) + case a.Pending: + a.NextRunAt = nil + case a.RunAt.After(now): + a.NextRunAt = a.RunAt + default: + return a, &ConflictError{"A one-time automation without a pending invocation needs a future run_at to resume."} + } + a.Status = Active + return a, err +} + +// Apply applies an update. Timing fields must match the stored type, run_at +// must be in the future and cannot change once the scheduled invocation exists, +// and an active Automation recomputes its next occurrence. +func (a Automation) Apply(p Patch, now time.Time) (Automation, error) { + if p.Name != nil { + a.Name = *p.Name + } + if p.Metadata != nil { + a.Metadata = p.Metadata + } + if p.Target != nil { + if p.TargetType != a.TargetType { + return a, &InvalidError{"target.type", "target.type must remain " + a.TargetType + "."} + } + a.Target, a.Contents = p.Target, p.Contents + } + var err error + switch { + case a.Type == Once && (p.Expression != nil || p.Timezone != nil): + param := "expression" + if p.Expression == nil { + param = "timezone" + } + return a, &InvalidError{param, param + " applies only to periodic automations."} + case a.Type == Periodic && p.RunAt != nil: + return a, &InvalidError{"run_at", "run_at applies only to one-time automations."} + case p.RunAt != nil: + if !p.RunAt.After(now) { + return a, &InvalidError{"run_at", "run_at must be a future Unix timestamp."} + } + if a.Status == Completed || a.Pending { + return a, &ConflictError{"run_at cannot change once the scheduled invocation exists."} + } + a.RunAt = p.RunAt + if a.Status == Active { + a.NextRunAt = p.RunAt + } + case p.Expression != nil || p.Timezone != nil: + if p.Expression != nil { + a.Expression = *p.Expression + } + if p.Timezone != nil { + a.Timezone = *p.Timezone + } + var next *time.Time + if next, err = a.nextRun(now); err == nil && a.Status == Active { + a.NextRunAt = next + } + } + return a, err +} + +// Resource projects the Automation onto its public resource. +func (a Automation) Resource() v1.Automation { + resource := v1.Automation{Type: a.Type, ID: a.ID, Object: "agent.automation", CreatedAt: a.CreatedAt.Unix(), Name: a.Name, Target: a.Target, Metadata: a.Metadata, CreatedBy: v1.AutomationCreatedBy{Type: "developer"}, Status: a.Status} + if a.Type == Periodic { + resource.Expression, resource.Timezone = &a.Expression, &a.Timezone + } else { + runAt := a.RunAt.Unix() + resource.RunAt = &runAt + } + return resource +} + +// Resource projects the invocation onto its public resource. +func (i Invocation) Resource() v1.AutomationInvocation { + resource := v1.AutomationInvocation{ID: i.ID, Object: "agent.automation.invocation", AutomationID: i.AutomationID, CreatedAt: i.CreatedAt.Unix(), Source: v1.AutomationInvocationSource{Type: "manual"}, Status: i.Status} + if i.ScheduledAt != nil { + scheduled := i.ScheduledAt.Unix() + resource.ScheduledAt = &scheduled + resource.Source = v1.AutomationInvocationSource{Type: "automation", Automation: i.Snapshot} + } + if i.SessionID != "" { + resource.SessionID = &i.SessionID + } + if i.SkipReason != "" { + resource.SkipReason = &i.SkipReason + } + if i.Status == "failed" { + resource.Error = &v1.AutomationInvocationError{Code: i.Code, Message: i.Message} + } + return resource +} diff --git a/services/core/internal/automations/automation_test.go b/services/core/internal/automations/automation_test.go new file mode 100644 index 000000000..4c03626e4 --- /dev/null +++ b/services/core/internal/automations/automation_test.go @@ -0,0 +1,88 @@ +package automations + +import ( + "errors" + "testing" + "time" +) + +func at(s string) *time.Time { + t, _ := time.Parse(time.RFC3339, s) + return &t +} + +func TestOccurrence(t *testing.T) { + periodic := Automation{Type: Periodic, Status: Active, Expression: "0 * * * *", Timezone: "UTC", NextRunAt: at("2026-03-01T01:00:00Z")} + now := *at("2026-03-01T05:10:00Z") + if got := periodic.Occurrence(now); !got.ScheduledAt.Equal(*at("2026-03-01T05:00:00Z")) || got.Skipped || got.Next == nil || !got.Next.Equal(*at("2026-03-01T06:00:00Z")) { + t.Errorf("after downtime only the latest occurrence is recorded: %+v", got) + } + periodic.Queued = true + if got := periodic.Occurrence(now); !got.Skipped || got.Completed { + t.Errorf("a queued invocation skips the occurrence: %+v", got) + } + once := Automation{Type: Once, Status: Active, NextRunAt: at("2026-03-01T01:00:00Z")} + if got := once.Occurrence(now); got.Next != nil || got.Completed { + t.Errorf("a one-time occurrence has no next: %+v", got) + } + once.Queued = true + if got := once.Occurrence(now); !got.Skipped || !got.Completed { + t.Errorf("a skipped one-time occurrence completes it: %+v", got) + } +} + +func TestPauseResume(t *testing.T) { + now := *at("2026-03-01T00:00:00Z") + var conflict *ConflictError + if _, err := (Automation{Status: Completed}).Pause(); !errors.As(err, &conflict) { + t.Errorf("pausing a completed automation: %v", err) + } + if paused, err := (Automation{Status: Paused}).Pause(); err != nil || paused.Status != Paused { + t.Errorf("pausing a paused automation: %v", err) + } + if resumed, err := (Automation{Status: Active}).Resume(now); err != nil || resumed.Status != Active { + t.Errorf("resuming an active automation: %v", err) + } + once := Automation{Type: Once, Status: Paused, RunAt: at("2026-02-01T00:00:00Z")} + if _, err := once.Resume(now); !errors.As(err, &conflict) { + t.Errorf("resuming a past run_at without a pending invocation: %v", err) + } + once.Pending = true + if resumed, err := once.Resume(now); err != nil || resumed.Status != Active || resumed.NextRunAt != nil { + t.Errorf("resuming with a pending invocation: %+v %v", resumed, err) + } + once.Pending, once.RunAt = false, at("2026-04-01T00:00:00Z") + if resumed, err := once.Resume(now); err != nil || resumed.NextRunAt == nil { + t.Errorf("resuming with a future run_at: %+v %v", resumed, err) + } +} + +func TestApply(t *testing.T) { + now := *at("2026-03-01T00:00:00Z") + var invalid *InvalidError + var conflict *ConflictError + periodic := Automation{Type: Periodic, Status: Active, Expression: "0 * * * *", Timezone: "UTC", TargetType: "existing_session"} + if _, err := periodic.Apply(Patch{RunAt: at("2026-04-01T00:00:00Z")}, now); !errors.As(err, &invalid) || invalid.Param != "run_at" { + t.Errorf("run_at on a periodic automation: %v", err) + } + if _, err := periodic.Apply(Patch{TargetType: "new_session", Target: []byte(`{}`)}, now); !errors.As(err, &invalid) || invalid.Param != "target.type" { + t.Errorf("target of another type: %v", err) + } + updated, err := periodic.Apply(Patch{Expression: ptr("30 * * * *")}, now) + if err != nil || updated.NextRunAt == nil || !updated.NextRunAt.Equal(*at("2026-03-01T00:30:00Z")) { + t.Errorf("a new expression recomputes the next occurrence: %+v %v", updated, err) + } + once := Automation{Type: Once, Status: Active, RunAt: at("2026-05-01T00:00:00Z")} + if _, err := once.Apply(Patch{Expression: ptr("0 * * * *")}, now); !errors.As(err, &invalid) || invalid.Param != "expression" { + t.Errorf("expression on a one-time automation: %v", err) + } + if _, err := once.Apply(Patch{RunAt: at("2026-02-01T00:00:00Z")}, now); !errors.As(err, &invalid) { + t.Errorf("past run_at: %v", err) + } + once.Pending = true + if _, err := once.Apply(Patch{RunAt: at("2026-06-01T00:00:00Z")}, now); !errors.As(err, &conflict) { + t.Errorf("run_at after the scheduled invocation exists: %v", err) + } +} + +func ptr[T any](v T) *T { return &v } diff --git a/services/core/internal/automations/cron.go b/services/core/internal/automations/cron.go new file mode 100644 index 000000000..944fe0a44 --- /dev/null +++ b/services/core/internal/automations/cron.go @@ -0,0 +1,145 @@ +package automations + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// Schedule is a parsed five-field crontab(5) expression in a time zone. +type Schedule struct { + minutes, hours, days, months, weekdays uint64 + // anyDay and anyWeekday report a day field that starts with "*". When + // neither does, a day matches either field. + anyDay, anyWeekday bool + location *time.Location +} + +var ( + monthNames = []string{"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"} + weekdayNames = []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"} +) + +// ParseSchedule parses expression, which is minute, hour, day of month, month +// and day of week. Each field is "*" or a comma list of n, a-b, */s or a-b/s; +// months and weekdays also take three-letter names, and weekday 7 is Sunday. +// It rejects descriptors, seconds and an expression with no occurrence in the +// next five years. timezone is an IANA name. +func ParseSchedule(expression, timezone string) (*Schedule, error) { + fields := strings.Fields(expression) + if len(fields) != 5 { + return nil, &InvalidError{"expression", "expression must have five fields: minute, hour, day of month, month and day of week."} + } + if timezone == "" || timezone == "Local" { + return nil, &InvalidError{"timezone", "timezone must be an IANA time zone name."} + } + location, err := time.LoadLocation(timezone) + if err != nil { + return nil, &InvalidError{"timezone", "timezone must be an IANA time zone name."} + } + s := &Schedule{location: location, anyDay: strings.HasPrefix(fields[2], "*"), anyWeekday: strings.HasPrefix(fields[4], "*")} + for i, target := range []struct { + mask *uint64 + min, max int + names []string + first int + }{{&s.minutes, 0, 59, nil, 0}, {&s.hours, 0, 23, nil, 0}, {&s.days, 1, 31, nil, 0}, {&s.months, 1, 12, monthNames, 1}, {&s.weekdays, 0, 7, weekdayNames, 0}} { + if *target.mask, err = parseField(fields[i], target.min, target.max, target.names, target.first); err != nil { + return nil, &InvalidError{"expression", fmt.Sprintf("expression field %d %q is invalid.", i+1, fields[i])} + } + } + if s.weekdays&(1<<7) != 0 { + s.weekdays |= 1 + } + if _, ok := s.Next(time.Now()); !ok { + return nil, &InvalidError{"expression", "expression has no occurrence in the next five years."} + } + return s, nil +} + +func parseField(field string, min, max int, names []string, first int) (uint64, error) { + var mask uint64 + for _, part := range strings.Split(field, ",") { + bounds, step, stepped := strings.Cut(part, "/") + low, high := min, max + if bounds != "*" { + from, to, ranged := strings.Cut(bounds, "-") + var err error + if low, err = parseValue(from, names, first); err != nil { + return 0, err + } + if high = low; ranged { + if high, err = parseValue(to, names, first); err != nil { + return 0, err + } + } else if stepped { + return 0, fmt.Errorf("a step needs a range") + } + } + increment := 1 + if stepped { + var err error + if increment, err = strconv.Atoi(step); err != nil || increment < 1 { + return 0, fmt.Errorf("invalid step") + } + } + if low < min || high > max || low > high { + return 0, fmt.Errorf("out of range") + } + for v := low; v <= high; v += increment { + mask |= 1 << v + } + } + return mask, nil +} + +func parseValue(text string, names []string, first int) (int, error) { + for i, name := range names { + if strings.EqualFold(text, name) { + return first + i, nil + } + } + if text == "" || strings.TrimLeft(text, "0123456789") != "" { + return 0, fmt.Errorf("invalid value") + } + return strconv.Atoi(text) +} + +// Next returns the first occurrence after the given time, or false when there +// is none in the next five years. It walks wall-clock minutes in the Schedule's +// zone. A wall-clock minute that does not exist is skipped, and one that occurs +// twice fires once, at its first instant. +func (s *Schedule) Next(after time.Time) (time.Time, bool) { + a := after.In(s.location) + civil := time.Date(a.Year(), a.Month(), a.Day(), a.Hour(), a.Minute()+1, 0, 0, time.UTC) + limit := civil.AddDate(5, 0, 0) + for civil.Before(limit) { + year, month, day := civil.Date() + switch { + case s.months&(1<>'type' = 'existing_session' AND target->>'session_id' = sqlc.narg(session_id)::text)) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (created_at, id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (created_at, id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: InsertManualInvocation :one +INSERT INTO automation_invocations (id, automation_id, status, delivery) +SELECT sqlc.arg(id), a.id, 'queued', a.target_contents FROM automations a WHERE a.tenant_id = sqlc.arg(tenant_id) AND a.id = sqlc.arg(automation_id) +RETURNING created_at; + +-- name: GetInvocation :one +SELECT i.id, i.automation_id, i.created_at, i.scheduled_at, i.automation_snapshot, i.status, i.skip_reason, i.session_id, i.error_code, i.error_message +FROM automation_invocations i JOIN automations a ON a.id = i.automation_id +WHERE a.tenant_id = $1 AND i.automation_id = $2 AND i.id = $3; + +-- name: ListInvocations :many +SELECT i.id, i.automation_id, i.created_at, i.scheduled_at, i.automation_snapshot, i.status, i.skip_reason, i.session_id, i.error_code, i.error_message +FROM automation_invocations i +WHERE i.automation_id = sqlc.arg(automation_id) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (i.created_at, i.id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (i.created_at, i.id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN i.created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN i.id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN i.created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN i.id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: DueAutomationIDs :many +SELECT a.tenant_id, a.id FROM automations a +WHERE a.status = 'active' AND a.next_run_at <= sqlc.arg(now)::timestamptz + AND EXISTS (SELECT 1 FROM projects p WHERE p.tenant_id = a.tenant_id AND p.archived_at IS NULL) +ORDER BY a.next_run_at LIMIT sqlc.arg(page_limit) +FOR UPDATE OF a SKIP LOCKED; + +-- name: InsertScheduledInvocation :exec +INSERT INTO automation_invocations (id, automation_id, scheduled_at, automation_snapshot, status, skip_reason, delivery) +SELECT sqlc.arg(id), a.id, sqlc.arg(scheduled_at), sqlc.arg(automation_snapshot), sqlc.arg(status)::text, sqlc.narg(skip_reason)::text, CASE WHEN sqlc.arg(status)::text = 'queued' THEN a.target_contents END +FROM automations a WHERE a.id = sqlc.arg(automation_id) +ON CONFLICT (automation_id, scheduled_at) DO NOTHING; + +-- name: AdvanceAutomation :exec +UPDATE automations SET next_run_at = sqlc.narg(next_run_at), status = CASE WHEN sqlc.arg(completed)::boolean THEN 'completed' ELSE status END WHERE id = sqlc.arg(id); + +-- name: DeliverableInvocations :many +SELECT DISTINCT ON (i.automation_id) i.id, i.automation_id, a.tenant_id, (a.target->>'type')::text AS target_type, i.delivery, + p.subject_kind, p.subject_id, s.organization_id, s.project_id +FROM automation_invocations i +JOIN automations a ON a.id = i.automation_id +JOIN projects p ON p.tenant_id = a.tenant_id AND p.archived_at IS NULL +JOIN execution_project_scopes s ON s.tenant_id = a.tenant_id +WHERE i.status = 'queued' AND (i.scheduled_at IS NULL OR a.status = 'active') +ORDER BY i.automation_id, i.created_at, i.id +LIMIT sqlc.arg(page_limit); + +-- name: SettleInvocation :one +UPDATE automation_invocations SET status = $2, session_id = sqlc.narg(session_id), error_code = sqlc.narg(error_code), error_message = sqlc.narg(error_message), delivery = NULL +WHERE id = $1 AND status = 'queued' RETURNING automation_id, scheduled_at; + +-- name: CompleteOnceAutomation :exec +UPDATE automations SET status = 'completed', next_run_at = NULL WHERE id = $1 AND type = 'once'; diff --git a/services/core/internal/db/sqlc/automations.sql.go b/services/core/internal/db/sqlc/automations.sql.go new file mode 100644 index 000000000..32ba45fcc --- /dev/null +++ b/services/core/internal/db/sqlc/automations.sql.go @@ -0,0 +1,604 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: automations.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const advanceAutomation = `-- name: AdvanceAutomation :exec +UPDATE automations SET next_run_at = $1, status = CASE WHEN $2::boolean THEN 'completed' ELSE status END WHERE id = $3 +` + +type AdvanceAutomationParams struct { + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Completed bool `json:"completed"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) AdvanceAutomation(ctx context.Context, arg AdvanceAutomationParams) error { + _, err := q.db.Exec(ctx, advanceAutomation, arg.NextRunAt, arg.Completed, arg.ID) + return err +} + +const automationFlags = `-- name: AutomationFlags :one +SELECT EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = $1 AND i.status = 'queued' AND i.scheduled_at IS NOT NULL) AS pending, + EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = $1 AND i.status = 'queued') AS queued +` + +type AutomationFlagsRow struct { + Pending bool `json:"pending"` + Queued bool `json:"queued"` +} + +func (q *Queries) AutomationFlags(ctx context.Context, id pgtype.UUID) (AutomationFlagsRow, error) { + row := q.db.QueryRow(ctx, automationFlags, id) + var i AutomationFlagsRow + err := row.Scan(&i.Pending, &i.Queued) + return i, err +} + +const completeOnceAutomation = `-- name: CompleteOnceAutomation :exec +UPDATE automations SET status = 'completed', next_run_at = NULL WHERE id = $1 AND type = 'once' +` + +func (q *Queries) CompleteOnceAutomation(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, completeOnceAutomation, id) + return err +} + +const deleteAutomation = `-- name: DeleteAutomation :one +DELETE FROM automations WHERE tenant_id = $1 AND id = $2 RETURNING id +` + +type DeleteAutomationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) DeleteAutomation(ctx context.Context, arg DeleteAutomationParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, deleteAutomation, arg.TenantID, arg.ID) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} + +const deliverableInvocations = `-- name: DeliverableInvocations :many +SELECT DISTINCT ON (i.automation_id) i.id, i.automation_id, a.tenant_id, (a.target->>'type')::text AS target_type, i.delivery, + p.subject_kind, p.subject_id, s.organization_id, s.project_id +FROM automation_invocations i +JOIN automations a ON a.id = i.automation_id +JOIN projects p ON p.tenant_id = a.tenant_id AND p.archived_at IS NULL +JOIN execution_project_scopes s ON s.tenant_id = a.tenant_id +WHERE i.status = 'queued' AND (i.scheduled_at IS NULL OR a.status = 'active') +ORDER BY i.automation_id, i.created_at, i.id +LIMIT $1 +` + +type DeliverableInvocationsRow struct { + ID pgtype.UUID `json:"id"` + AutomationID pgtype.UUID `json:"automation_id"` + TenantID pgtype.UUID `json:"tenant_id"` + TargetType string `json:"target_type"` + Delivery []byte `json:"delivery"` + SubjectKind string `json:"subject_kind"` + SubjectID string `json:"subject_id"` + OrganizationID string `json:"organization_id"` + ProjectID string `json:"project_id"` +} + +func (q *Queries) DeliverableInvocations(ctx context.Context, pageLimit int32) ([]DeliverableInvocationsRow, error) { + rows, err := q.db.Query(ctx, deliverableInvocations, pageLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DeliverableInvocationsRow{} + for rows.Next() { + var i DeliverableInvocationsRow + if err := rows.Scan( + &i.ID, + &i.AutomationID, + &i.TenantID, + &i.TargetType, + &i.Delivery, + &i.SubjectKind, + &i.SubjectID, + &i.OrganizationID, + &i.ProjectID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const dueAutomationIDs = `-- name: DueAutomationIDs :many +SELECT a.tenant_id, a.id FROM automations a +WHERE a.status = 'active' AND a.next_run_at <= $1::timestamptz + AND EXISTS (SELECT 1 FROM projects p WHERE p.tenant_id = a.tenant_id AND p.archived_at IS NULL) +ORDER BY a.next_run_at LIMIT $2 +FOR UPDATE OF a SKIP LOCKED +` + +type DueAutomationIDsParams struct { + Now pgtype.Timestamptz `json:"now"` + PageLimit int32 `json:"page_limit"` +} + +type DueAutomationIDsRow struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) DueAutomationIDs(ctx context.Context, arg DueAutomationIDsParams) ([]DueAutomationIDsRow, error) { + rows, err := q.db.Query(ctx, dueAutomationIDs, arg.Now, arg.PageLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DueAutomationIDsRow{} + for rows.Next() { + var i DueAutomationIDsRow + if err := rows.Scan(&i.TenantID, &i.ID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getAutomation = `-- name: GetAutomation :one +SELECT id, tenant_id, type, name, metadata, status, run_at, expression, timezone, next_run_at, target, created_at FROM automations +WHERE tenant_id = $1 AND id = $2 +` + +type GetAutomationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type GetAutomationRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Type string `json:"type"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Status string `json:"status"` + RunAt pgtype.Timestamptz `json:"run_at"` + Expression pgtype.Text `json:"expression"` + Timezone pgtype.Text `json:"timezone"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Target []byte `json:"target"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) GetAutomation(ctx context.Context, arg GetAutomationParams) (GetAutomationRow, error) { + row := q.db.QueryRow(ctx, getAutomation, arg.TenantID, arg.ID) + var i GetAutomationRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Type, + &i.Name, + &i.Metadata, + &i.Status, + &i.RunAt, + &i.Expression, + &i.Timezone, + &i.NextRunAt, + &i.Target, + &i.CreatedAt, + ) + return i, err +} + +const getInvocation = `-- name: GetInvocation :one +SELECT i.id, i.automation_id, i.created_at, i.scheduled_at, i.automation_snapshot, i.status, i.skip_reason, i.session_id, i.error_code, i.error_message +FROM automation_invocations i JOIN automations a ON a.id = i.automation_id +WHERE a.tenant_id = $1 AND i.automation_id = $2 AND i.id = $3 +` + +type GetInvocationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + AutomationID pgtype.UUID `json:"automation_id"` + ID pgtype.UUID `json:"id"` +} + +type GetInvocationRow struct { + ID pgtype.UUID `json:"id"` + AutomationID pgtype.UUID `json:"automation_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + AutomationSnapshot []byte `json:"automation_snapshot"` + Status string `json:"status"` + SkipReason pgtype.Text `json:"skip_reason"` + SessionID pgtype.UUID `json:"session_id"` + ErrorCode pgtype.Text `json:"error_code"` + ErrorMessage pgtype.Text `json:"error_message"` +} + +func (q *Queries) GetInvocation(ctx context.Context, arg GetInvocationParams) (GetInvocationRow, error) { + row := q.db.QueryRow(ctx, getInvocation, arg.TenantID, arg.AutomationID, arg.ID) + var i GetInvocationRow + err := row.Scan( + &i.ID, + &i.AutomationID, + &i.CreatedAt, + &i.ScheduledAt, + &i.AutomationSnapshot, + &i.Status, + &i.SkipReason, + &i.SessionID, + &i.ErrorCode, + &i.ErrorMessage, + ) + return i, err +} + +const insertAutomation = `-- name: InsertAutomation :one +INSERT INTO automations (id, tenant_id, type, name, metadata, status, run_at, expression, timezone, next_run_at, target, target_contents) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING created_at +` + +type InsertAutomationParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Type string `json:"type"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Status string `json:"status"` + RunAt pgtype.Timestamptz `json:"run_at"` + Expression pgtype.Text `json:"expression"` + Timezone pgtype.Text `json:"timezone"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Target []byte `json:"target"` + TargetContents []byte `json:"target_contents"` +} + +func (q *Queries) InsertAutomation(ctx context.Context, arg InsertAutomationParams) (pgtype.Timestamptz, error) { + row := q.db.QueryRow(ctx, insertAutomation, + arg.ID, + arg.TenantID, + arg.Type, + arg.Name, + arg.Metadata, + arg.Status, + arg.RunAt, + arg.Expression, + arg.Timezone, + arg.NextRunAt, + arg.Target, + arg.TargetContents, + ) + var created_at pgtype.Timestamptz + err := row.Scan(&created_at) + return created_at, err +} + +const insertManualInvocation = `-- name: InsertManualInvocation :one +INSERT INTO automation_invocations (id, automation_id, status, delivery) +SELECT $1, a.id, 'queued', a.target_contents FROM automations a WHERE a.tenant_id = $2 AND a.id = $3 +RETURNING created_at +` + +type InsertManualInvocationParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + AutomationID pgtype.UUID `json:"automation_id"` +} + +func (q *Queries) InsertManualInvocation(ctx context.Context, arg InsertManualInvocationParams) (pgtype.Timestamptz, error) { + row := q.db.QueryRow(ctx, insertManualInvocation, arg.ID, arg.TenantID, arg.AutomationID) + var created_at pgtype.Timestamptz + err := row.Scan(&created_at) + return created_at, err +} + +const insertScheduledInvocation = `-- name: InsertScheduledInvocation :exec +INSERT INTO automation_invocations (id, automation_id, scheduled_at, automation_snapshot, status, skip_reason, delivery) +SELECT $1, a.id, $2, $3, $4::text, $5::text, CASE WHEN $4::text = 'queued' THEN a.target_contents END +FROM automations a WHERE a.id = $6 +ON CONFLICT (automation_id, scheduled_at) DO NOTHING +` + +type InsertScheduledInvocationParams struct { + ID pgtype.UUID `json:"id"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + AutomationSnapshot []byte `json:"automation_snapshot"` + Status string `json:"status"` + SkipReason pgtype.Text `json:"skip_reason"` + AutomationID pgtype.UUID `json:"automation_id"` +} + +func (q *Queries) InsertScheduledInvocation(ctx context.Context, arg InsertScheduledInvocationParams) error { + _, err := q.db.Exec(ctx, insertScheduledInvocation, + arg.ID, + arg.ScheduledAt, + arg.AutomationSnapshot, + arg.Status, + arg.SkipReason, + arg.AutomationID, + ) + return err +} + +const listAutomations = `-- name: ListAutomations :many +SELECT id, tenant_id, type, name, metadata, status, run_at, expression, timezone, next_run_at, target, created_at FROM automations +WHERE tenant_id = $1 + AND ($2::text IS NULL OR (target->>'type' = 'existing_session' AND target->>'session_id' = $2::text)) + AND ($3::timestamptz IS NULL + OR (NOT $4::boolean AND (created_at, id) < ($3::timestamptz, $5::uuid)) + OR ($4::boolean AND (created_at, id) > ($3::timestamptz, $5::uuid))) +ORDER BY + CASE WHEN $4::boolean THEN created_at END ASC, + CASE WHEN $4::boolean THEN id END ASC, + CASE WHEN NOT $4::boolean THEN created_at END DESC, + CASE WHEN NOT $4::boolean THEN id END DESC +LIMIT $6 +` + +type ListAutomationsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.Text `json:"session_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +type ListAutomationsRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Type string `json:"type"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Status string `json:"status"` + RunAt pgtype.Timestamptz `json:"run_at"` + Expression pgtype.Text `json:"expression"` + Timezone pgtype.Text `json:"timezone"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Target []byte `json:"target"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) ListAutomations(ctx context.Context, arg ListAutomationsParams) ([]ListAutomationsRow, error) { + rows, err := q.db.Query(ctx, listAutomations, + arg.TenantID, + arg.SessionID, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListAutomationsRow{} + for rows.Next() { + var i ListAutomationsRow + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.Type, + &i.Name, + &i.Metadata, + &i.Status, + &i.RunAt, + &i.Expression, + &i.Timezone, + &i.NextRunAt, + &i.Target, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listInvocations = `-- name: ListInvocations :many +SELECT i.id, i.automation_id, i.created_at, i.scheduled_at, i.automation_snapshot, i.status, i.skip_reason, i.session_id, i.error_code, i.error_message +FROM automation_invocations i +WHERE i.automation_id = $1 + AND ($2::timestamptz IS NULL + OR (NOT $3::boolean AND (i.created_at, i.id) < ($2::timestamptz, $4::uuid)) + OR ($3::boolean AND (i.created_at, i.id) > ($2::timestamptz, $4::uuid))) +ORDER BY + CASE WHEN $3::boolean THEN i.created_at END ASC, + CASE WHEN $3::boolean THEN i.id END ASC, + CASE WHEN NOT $3::boolean THEN i.created_at END DESC, + CASE WHEN NOT $3::boolean THEN i.id END DESC +LIMIT $5 +` + +type ListInvocationsParams struct { + AutomationID pgtype.UUID `json:"automation_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +type ListInvocationsRow struct { + ID pgtype.UUID `json:"id"` + AutomationID pgtype.UUID `json:"automation_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + AutomationSnapshot []byte `json:"automation_snapshot"` + Status string `json:"status"` + SkipReason pgtype.Text `json:"skip_reason"` + SessionID pgtype.UUID `json:"session_id"` + ErrorCode pgtype.Text `json:"error_code"` + ErrorMessage pgtype.Text `json:"error_message"` +} + +func (q *Queries) ListInvocations(ctx context.Context, arg ListInvocationsParams) ([]ListInvocationsRow, error) { + rows, err := q.db.Query(ctx, listInvocations, + arg.AutomationID, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListInvocationsRow{} + for rows.Next() { + var i ListInvocationsRow + if err := rows.Scan( + &i.ID, + &i.AutomationID, + &i.CreatedAt, + &i.ScheduledAt, + &i.AutomationSnapshot, + &i.Status, + &i.SkipReason, + &i.SessionID, + &i.ErrorCode, + &i.ErrorMessage, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const lockAutomation = `-- name: LockAutomation :one +SELECT id, tenant_id, type, name, metadata, status, run_at, expression, timezone, next_run_at, target, created_at FROM automations +WHERE tenant_id = $1 AND id = $2 FOR UPDATE +` + +type LockAutomationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type LockAutomationRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Type string `json:"type"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Status string `json:"status"` + RunAt pgtype.Timestamptz `json:"run_at"` + Expression pgtype.Text `json:"expression"` + Timezone pgtype.Text `json:"timezone"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Target []byte `json:"target"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) LockAutomation(ctx context.Context, arg LockAutomationParams) (LockAutomationRow, error) { + row := q.db.QueryRow(ctx, lockAutomation, arg.TenantID, arg.ID) + var i LockAutomationRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Type, + &i.Name, + &i.Metadata, + &i.Status, + &i.RunAt, + &i.Expression, + &i.Timezone, + &i.NextRunAt, + &i.Target, + &i.CreatedAt, + ) + return i, err +} + +const settleInvocation = `-- name: SettleInvocation :one +UPDATE automation_invocations SET status = $2, session_id = $3, error_code = $4, error_message = $5, delivery = NULL +WHERE id = $1 AND status = 'queued' RETURNING automation_id, scheduled_at +` + +type SettleInvocationParams struct { + ID pgtype.UUID `json:"id"` + Status string `json:"status"` + SessionID pgtype.UUID `json:"session_id"` + ErrorCode pgtype.Text `json:"error_code"` + ErrorMessage pgtype.Text `json:"error_message"` +} + +type SettleInvocationRow struct { + AutomationID pgtype.UUID `json:"automation_id"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` +} + +func (q *Queries) SettleInvocation(ctx context.Context, arg SettleInvocationParams) (SettleInvocationRow, error) { + row := q.db.QueryRow(ctx, settleInvocation, + arg.ID, + arg.Status, + arg.SessionID, + arg.ErrorCode, + arg.ErrorMessage, + ) + var i SettleInvocationRow + err := row.Scan(&i.AutomationID, &i.ScheduledAt) + return i, err +} + +const updateAutomation = `-- name: UpdateAutomation :exec +UPDATE automations SET name = $3, metadata = $4, status = $5, run_at = $6, expression = $7, timezone = $8, next_run_at = $9, + target = CASE WHEN $10::boolean THEN $11::jsonb ELSE target END, + target_contents = CASE WHEN $10::boolean THEN $12::bytea ELSE target_contents END +WHERE tenant_id = $1 AND id = $2 +` + +type UpdateAutomationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Status string `json:"status"` + RunAt pgtype.Timestamptz `json:"run_at"` + Expression pgtype.Text `json:"expression"` + Timezone pgtype.Text `json:"timezone"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + SetTarget bool `json:"set_target"` + Target []byte `json:"target"` + TargetContents []byte `json:"target_contents"` +} + +func (q *Queries) UpdateAutomation(ctx context.Context, arg UpdateAutomationParams) error { + _, err := q.db.Exec(ctx, updateAutomation, + arg.TenantID, + arg.ID, + arg.Name, + arg.Metadata, + arg.Status, + arg.RunAt, + arg.Expression, + arg.Timezone, + arg.NextRunAt, + arg.SetTarget, + arg.Target, + arg.TargetContents, + ) + return err +} diff --git a/services/core/internal/db/sqlc/models.go b/services/core/internal/db/sqlc/models.go index c046b4d95..520c9e309 100644 --- a/services/core/internal/db/sqlc/models.go +++ b/services/core/internal/db/sqlc/models.go @@ -36,6 +36,36 @@ type AgentModelExecution struct { EncryptedConfig []byte `json:"encrypted_config"` } +type Automation struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Type string `json:"type"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Status string `json:"status"` + RunAt pgtype.Timestamptz `json:"run_at"` + Expression pgtype.Text `json:"expression"` + Timezone pgtype.Text `json:"timezone"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Target []byte `json:"target"` + TargetContents []byte `json:"target_contents"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AutomationInvocation struct { + ID pgtype.UUID `json:"id"` + AutomationID pgtype.UUID `json:"automation_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + AutomationSnapshot []byte `json:"automation_snapshot"` + Status string `json:"status"` + SkipReason pgtype.Text `json:"skip_reason"` + SessionID pgtype.UUID `json:"session_id"` + ErrorCode pgtype.Text `json:"error_code"` + ErrorMessage pgtype.Text `json:"error_message"` + Delivery []byte `json:"delivery"` +} + type DeploymentModelProvider struct { Harness string `json:"harness"` Protocol string `json:"protocol"` diff --git a/services/core/internal/persistence/postgres/automationpg/store.go b/services/core/internal/persistence/postgres/automationpg/store.go new file mode 100644 index 000000000..10220231f --- /dev/null +++ b/services/core/internal/persistence/postgres/automationpg/store.go @@ -0,0 +1,386 @@ +// Package automationpg stores Automations and their invocations in PostgreSQL. +// The target request, which may hold confidential Session configuration, is +// sealed with the credential key and bound to its tenant and Automation. A +// queued invocation carries its own sealed copy until it settles. +package automationpg + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// maxDue bounds the occurrences one Record call records. +const maxDue = 100 + +// Store implements automations.Storage and automations.Reader. +type Store struct { + pool *pgunit.Pool + cipher *credentialcrypto.Cipher +} + +var ( + _ automations.Storage = (*Store)(nil) + _ automations.Reader = (*Store)(nil) +) + +func New(pool *pgunit.Pool, cipher *credentialcrypto.Cipher) *Store { + return &Store{pool: pool, cipher: cipher} +} + +func (s *Store) Create(ctx context.Context, a automations.Automation) (automations.Automation, error) { + tenant, err := pgunit.ParseID(a.TenantID) + if err != nil { + return a, automations.ErrNotFound + } + id := uuid.New() + a.ID = id.String() + contents, err := s.cipher.SealAutomationTarget(a.Contents, a.TenantID, a.ID) + if err != nil { + return a, err + } + metadata, err := json.Marshal(a.Metadata) + if err != nil { + return a, err + } + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + created, err := q.InsertAutomation(ctx, sqlc.InsertAutomationParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Type: a.Type, Name: a.Name, Metadata: metadata, Status: a.Status, + RunAt: timestamp(a.RunAt), Expression: text(a.Expression), Timezone: text(a.Timezone), NextRunAt: timestamp(a.NextRunAt), Target: a.Target, TargetContents: contents}) + if err != nil { + return err + } + a.CreatedAt = created.Time + return auditpg.RecordWriteAudit(ctx, q, a.TenantID, writeaudit.ActionCreate, writeaudit.ResourceAutomation, a.ID, "", writeaudit.Resource{Type: writeaudit.ResourceAutomation, ID: a.ID}) + }) + a.Contents = nil + return a, storageError(err) +} + +func (s *Store) Update(ctx context.Context, tenantID, id string, change func(automations.Automation) (automations.Automation, error)) (automations.Automation, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return automations.Automation{}, automations.ErrNotFound + } + var result automations.Automation + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + row, err := q.LockAutomation(ctx, sqlc.LockAutomationParams{TenantID: tenant, ID: pgunit.PathID(id)}) + if err != nil { + return err + } + current, err := automation(sqlc.GetAutomationRow(row)) + if err != nil { + return err + } + flags, err := q.AutomationFlags(ctx, row.ID) + if err != nil { + return err + } + current.Pending, current.Queued = flags.Pending, flags.Queued + if result, err = change(current); err != nil { + return err + } + metadata, err := json.Marshal(result.Metadata) + if err != nil { + return err + } + var contents []byte + if result.Contents != nil { + if contents, err = s.cipher.SealAutomationTarget(result.Contents, tenantID, result.ID); err != nil { + return err + } + } + if err := q.UpdateAutomation(ctx, sqlc.UpdateAutomationParams{TenantID: tenant, ID: row.ID, Name: result.Name, Metadata: metadata, Status: result.Status, RunAt: timestamp(result.RunAt), + Expression: text(result.Expression), Timezone: text(result.Timezone), NextRunAt: timestamp(result.NextRunAt), SetTarget: result.Contents != nil, Target: result.Target, TargetContents: contents}); err != nil { + return err + } + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionUpdate, writeaudit.ResourceAutomation, result.ID, "") + }) + result.Contents = nil + return result, storageError(err) +} + +func (s *Store) Delete(ctx context.Context, tenantID, id string) (string, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return "", automations.ErrNotFound + } + var deleted string + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + row, err := q.DeleteAutomation(ctx, sqlc.DeleteAutomationParams{TenantID: tenant, ID: pgunit.PathID(id)}) + if err != nil { + return err + } + deleted = uuidString(row) + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionDelete, writeaudit.ResourceAutomation, deleted, "") + }) + return deleted, storageError(err) +} + +func (s *Store) Invoke(ctx context.Context, tenantID, id string) (automations.Invocation, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return automations.Invocation{}, automations.ErrNotFound + } + automationID, invocationID := pgunit.PathID(id), uuid.New() + result := automations.Invocation{ID: invocationID.String(), AutomationID: uuidString(automationID), Status: "queued"} + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + created, err := q.InsertManualInvocation(ctx, sqlc.InsertManualInvocationParams{ID: pgtype.UUID{Bytes: invocationID, Valid: true}, TenantID: tenant, AutomationID: automationID}) + if err != nil { + return err + } + result.CreatedAt = created.Time + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionCreate, writeaudit.ResourceAutomationInvocation, result.ID, result.AutomationID, writeaudit.Resource{Type: writeaudit.ResourceAutomationInvocation, ID: result.ID, ParentID: result.AutomationID}) + }) + return result, storageError(err) +} + +// Record records the occurrence of each due active Automation of a live +// Project and advances it, in one transaction. A concurrent pass skips rows +// another holds, and the unique scheduled time keeps an occurrence single. +func (s *Store) Record(ctx context.Context, now time.Time) (int, error) { + recorded := 0 + err := s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + due, err := q.DueAutomationIDs(ctx, sqlc.DueAutomationIDsParams{Now: timestamp(&now), PageLimit: maxDue}) + if err != nil { + return err + } + for _, d := range due { + row, err := q.GetAutomation(ctx, sqlc.GetAutomationParams{TenantID: d.TenantID, ID: d.ID}) + if err != nil { + return err + } + a, err := automation(row) + if err != nil { + return err + } + flags, err := q.AutomationFlags(ctx, d.ID) + if err != nil { + return err + } + a.Queued = flags.Queued + snapshot, err := json.Marshal(a.Resource()) + if err != nil { + return err + } + occurrence, status, reason := a.Occurrence(now), "queued", pgtype.Text{} + if occurrence.Skipped { + status, reason = "skipped", text("pending_delivery") + } + if err := q.InsertScheduledInvocation(ctx, sqlc.InsertScheduledInvocationParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, AutomationID: d.ID, ScheduledAt: timestamp(&occurrence.ScheduledAt), AutomationSnapshot: snapshot, Status: status, SkipReason: reason}); err != nil { + return err + } + if err := q.AdvanceAutomation(ctx, sqlc.AdvanceAutomationParams{ID: d.ID, NextRunAt: timestamp(occurrence.Next), Completed: occurrence.Completed}); err != nil { + return err + } + recorded++ + } + return nil + }) + return recorded, err +} + +func (s *Store) Deliverable(ctx context.Context, limit int) ([]automations.Delivery, error) { + rows, err := s.pool.Queries().DeliverableInvocations(ctx, int32(limit)) + if err != nil { + return nil, err + } + deliveries := make([]automations.Delivery, 0, len(rows)) + for _, row := range rows { + tenant, id := uuidString(row.TenantID), uuidString(row.AutomationID) + request, err := s.cipher.OpenAutomationTarget(row.Delivery, tenant, id) + if err != nil { + return nil, err + } + deliveries = append(deliveries, automations.Delivery{InvocationID: uuidString(row.ID), AutomationID: id, TargetType: row.TargetType, Request: request, + Scope: identity.Principal{ProjectScope: identity.ProjectScope{TenantID: tenant, OrganizationID: row.OrganizationID, ProjectID: row.ProjectID}, SubjectKind: row.SubjectKind, SubjectID: row.SubjectID}}) + } + return deliveries, nil +} + +func (s *Store) Settle(ctx context.Context, outcome automations.Outcome) error { + id, err := pgunit.ParseID(outcome.InvocationID) + if err != nil { + return nil + } + params := sqlc.SettleInvocationParams{ID: id, Status: outcome.Status, ErrorCode: text(outcome.Code), ErrorMessage: text(outcome.Message)} + if session, err := pgunit.ParseID(outcome.SessionID); err == nil { + params.SessionID = session + } + return s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + settled, err := q.SettleInvocation(ctx, params) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil || !settled.ScheduledAt.Valid { + return err + } + return q.CompleteOnceAutomation(ctx, settled.AutomationID) + }) +} + +func (s *Store) Get(ctx context.Context, tenantID, id string) (automations.Automation, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return automations.Automation{}, automations.ErrNotFound + } + row, err := s.pool.Queries().GetAutomation(ctx, sqlc.GetAutomationParams{TenantID: tenant, ID: pgunit.PathID(id)}) + if err != nil { + return automations.Automation{}, storageError(err) + } + return automation(row) +} + +// List reads the cursor and the page in one snapshot. +func (s *Store) List(ctx context.Context, tenantID string, query automations.ListQuery) (automations.Page, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return automations.Page{}, automations.ErrNotFound + } + params := sqlc.ListAutomationsParams{TenantID: tenant, SessionID: pgtype.Text{String: query.SessionID, Valid: query.SessionID != ""}, PageLimit: int32(query.Limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: query.Ascending} + var rows []sqlc.ListAutomationsRow + err = s.pool.Snapshot(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + if query.After != "" { + after, err := q.GetAutomation(ctx, sqlc.GetAutomationParams{TenantID: tenant, ID: pgunit.PathID(pgunit.LookupCursor(query.After))}) + if err != nil { + return err + } + params.AfterCreated, params.AfterID = after.CreatedAt, after.ID + } + var err error + rows, err = q.ListAutomations(ctx, params) + return err + }) + if err != nil { + return automations.Page{}, storageError(err) + } + page := automations.Page{Automations: make([]automations.Automation, 0, min(query.Limit, len(rows))), HasMore: len(rows) > query.Limit} + for _, row := range rows[:min(query.Limit, len(rows))] { + a, err := automation(sqlc.GetAutomationRow(row)) + if err != nil { + return automations.Page{}, err + } + page.Automations = append(page.Automations, a) + } + return page, nil +} + +func (s *Store) GetInvocation(ctx context.Context, tenantID, id, invocationID string) (automations.Invocation, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return automations.Invocation{}, automations.ErrNotFound + } + row, err := s.pool.Queries().GetInvocation(ctx, sqlc.GetInvocationParams{TenantID: tenant, AutomationID: pgunit.PathID(id), ID: pgunit.PathID(invocationID)}) + if err != nil { + return automations.Invocation{}, storageError(err) + } + return invocation(row), nil +} + +// ListInvocations reads the Automation, the cursor and the page in one +// snapshot, so a missing Automation answers before an unresolved cursor. +func (s *Store) ListInvocations(ctx context.Context, tenantID, id string, query automations.ListQuery) (automations.InvocationPage, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return automations.InvocationPage{}, automations.ErrNotFound + } + automationID := pgunit.PathID(id) + params := sqlc.ListInvocationsParams{AutomationID: automationID, PageLimit: int32(query.Limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: query.Ascending} + var rows []sqlc.ListInvocationsRow + err = s.pool.Snapshot(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + if _, err := q.GetAutomation(ctx, sqlc.GetAutomationParams{TenantID: tenant, ID: automationID}); err != nil { + return err + } + if query.After != "" { + after, err := q.GetInvocation(ctx, sqlc.GetInvocationParams{TenantID: tenant, AutomationID: automationID, ID: pgunit.PathID(pgunit.LookupCursor(query.After))}) + if err != nil { + return err + } + params.AfterCreated, params.AfterID = after.CreatedAt, after.ID + } + var err error + rows, err = q.ListInvocations(ctx, params) + return err + }) + if err != nil { + return automations.InvocationPage{}, storageError(err) + } + page := automations.InvocationPage{Invocations: make([]automations.Invocation, 0, min(query.Limit, len(rows))), HasMore: len(rows) > query.Limit} + for _, row := range rows[:min(query.Limit, len(rows))] { + page.Invocations = append(page.Invocations, invocation(sqlc.GetInvocationRow(row))) + } + return page, nil +} + +func automation(row sqlc.GetAutomationRow) (automations.Automation, error) { + a := automations.Automation{ID: uuidString(row.ID), TenantID: uuidString(row.TenantID), Type: row.Type, Name: row.Name, Status: row.Status, RunAt: timePtr(row.RunAt), + Expression: row.Expression.String, Timezone: row.Timezone.String, NextRunAt: timePtr(row.NextRunAt), Target: row.Target, CreatedAt: row.CreatedAt.Time} + var target struct { + Type string `json:"type"` + } + if json.Unmarshal(row.Metadata, &a.Metadata) != nil || json.Unmarshal(row.Target, &target) != nil { + return a, errors.New("invalid stored automation") + } + a.TargetType = target.Type + return a, nil +} + +func invocation(row sqlc.GetInvocationRow) automations.Invocation { + return automations.Invocation{ID: uuidString(row.ID), AutomationID: uuidString(row.AutomationID), CreatedAt: row.CreatedAt.Time, ScheduledAt: timePtr(row.ScheduledAt), Snapshot: row.AutomationSnapshot, + Status: row.Status, SkipReason: row.SkipReason.String, SessionID: uuidString(row.SessionID), Code: row.ErrorCode.String, Message: row.ErrorMessage.String} +} + +func uuidString(id pgtype.UUID) string { + if !id.Valid { + return "" + } + return uuid.UUID(id.Bytes).String() +} + +func timestamp(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{} + } + return pgtype.Timestamptz{Time: *t, Valid: true} +} + +func timePtr(t pgtype.Timestamptz) *time.Time { + if !t.Valid { + return nil + } + return &t.Time +} + +func text(value string) pgtype.Text { return pgtype.Text{String: value, Valid: value != ""} } + +// storageError translates the database outcomes callers act on: no row is +// ErrNotFound and text PostgreSQL cannot store is textvalue.ErrUnstorable. +func storageError(err error) error { + switch { + case errors.Is(err, pgx.ErrNoRows): + return automations.ErrNotFound + case pgunit.IsUnstorableText(err): + return textvalue.ErrUnstorable + } + return err +} diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index b5ca45f72..d9f5df356 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "net/http/httptest" "strings" "testing" @@ -141,6 +142,7 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { EnvironmentTemplates: struct{ api.EnvironmentTemplates }{}, EnvironmentTemplatesReader: struct{ api.EnvironmentTemplatesReader }{}, Skills: struct{ api.Skills }{}, SkillsReader: struct{ api.SkillsReader }{}, Agents: struct{ api.Agents }{}, AgentsReader: struct{ api.AgentsReader }{}, + Automations: struct{ api.Automations }{}, AutomationsReader: struct{ automations.Reader }{}, Sessions: struct{ api.Sessions }{}, SessionsReader: struct{ api.SessionsReader }{}, SessionCreation: struct{ api.SessionCreation }{}, diff --git a/services/core/migrations/000012_automations.sql b/services/core/migrations/000012_automations.sql new file mode 100644 index 000000000..da1bd5a3b --- /dev/null +++ b/services/core/migrations/000012_automations.sql @@ -0,0 +1,43 @@ +-- +goose Up +CREATE TABLE automations ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + type text NOT NULL CHECK (type IN ('periodic', 'once')), + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 256), + metadata jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(metadata) = 'object'), + status text NOT NULL CHECK (status IN ('active', 'paused', 'completed')), + run_at timestamptz, + expression text, + timezone text, + next_run_at timestamptz, + target jsonb NOT NULL, + target_contents bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT statement_timestamp(), + CHECK ((type = 'once') = (run_at IS NOT NULL)), + CHECK ((type = 'periodic') = (expression IS NOT NULL AND timezone IS NOT NULL)), + CHECK (status <> 'completed' OR type = 'once') +); +CREATE INDEX automations_page ON automations (tenant_id, created_at, id); +CREATE INDEX automations_target_session ON automations (tenant_id, (target->>'session_id')) WHERE target->>'type' = 'existing_session'; +CREATE INDEX automations_due ON automations (next_run_at) WHERE status = 'active'; + +CREATE TABLE automation_invocations ( + id uuid PRIMARY KEY, + automation_id uuid NOT NULL REFERENCES automations (id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT statement_timestamp(), + scheduled_at timestamptz, + automation_snapshot jsonb, + status text NOT NULL CHECK (status IN ('queued', 'completed', 'failed', 'skipped')), + skip_reason text CHECK (skip_reason = 'pending_delivery'), + session_id uuid, + error_code text, + error_message text, + delivery bytea, + UNIQUE (automation_id, scheduled_at), + CHECK ((scheduled_at IS NULL) = (automation_snapshot IS NULL)), + CHECK ((status = 'skipped') = (skip_reason IS NOT NULL)), + CHECK ((status = 'failed') = (error_code IS NOT NULL AND error_message IS NOT NULL)), + CHECK ((status = 'queued') = (delivery IS NOT NULL)) +); +CREATE INDEX automation_invocations_page ON automation_invocations (automation_id, created_at, id); +CREATE INDEX automation_invocations_queued ON automation_invocations (automation_id, created_at) WHERE status = 'queued'; diff --git a/services/core/tests/integration/automations_public_test.go b/services/core/tests/integration/automations_public_test.go new file mode 100644 index 000000000..fd52b137c --- /dev/null +++ b/services/core/tests/integration/automations_public_test.go @@ -0,0 +1,60 @@ +package integration + +import ( + "context" + "net/http/httptest" + "os" + "os/exec" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/google/uuid" +) + +func TestAutomationsOfficialClient(t *testing.T) { + python := os.Getenv("OAC_TEST_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, _ := testStore(t) + token, foreign, tenant, project := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + auth := newTestAuthenticator(t, []testAPIKey{ + {OrganizationID: "test-org", ProjectID: project, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + // Delivery resolves the owning Project's principal from its rows. + if _, err := s.pool.Exec(t.Context(), "INSERT INTO execution_project_scopes(tenant_id,organization_id,project_id) VALUES($1,'test-org',$2)", tenant, project); err != nil { + t.Fatal(err) + } + if _, err := s.pool.Exec(t.Context(), "INSERT INTO projects(id,name,tenant_id,subject_kind,subject_id) VALUES($1,'Automation fixture',$1,'service_account','test-runner')", tenant); err != nil { + t.Fatal(err) + } + var deps api.Dependencies + h, err := publicHandler(t, s, auth, "codex", func(d *api.Dependencies) { deps = *d }) + if err != nil { + t.Fatal(err) + } + deliver, err := api.NewAutomationDelivery(deps) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + defer server.Close() + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + go func() { + for ctx.Err() == nil { + if _, failed, err := deliver(ctx); err != nil && ctx.Err() == nil { + t.Errorf("delivery failed %d: %v", failed, err) + } + time.Sleep(100 * time.Millisecond) + } + }() + out, err := exec.CommandContext(ctx, python, "../../tests/official_automations.py", server.URL, token, foreign).CombinedOutput() + if err != nil { + t.Fatalf("official Automations: %v %s", err, out) + } + t.Log(string(out)) +} diff --git a/services/core/tests/integration/automations_store_test.go b/services/core/tests/integration/automations_store_test.go new file mode 100644 index 000000000..ced93248d --- /dev/null +++ b/services/core/tests/integration/automations_store_test.go @@ -0,0 +1,70 @@ +package integration + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" + "github.com/google/uuid" +) + +// Concurrent recording makes one invocation per occurrence, and settling is +// idempotent: the first outcome stays and completes the one-time Automation. +func TestAutomationRecordingAndSettlement(t *testing.T) { + s, _ := testStore(t) + store, _ := fixtureAutomations(t, s) + tenant, project := uuid.NewString(), uuid.NewString() + if _, err := s.pool.Exec(t.Context(), "INSERT INTO execution_project_scopes(tenant_id,organization_id,project_id) VALUES($1,'test-org',$2)", tenant, project); err != nil { + t.Fatal(err) + } + if _, err := s.pool.Exec(t.Context(), "INSERT INTO projects(id,name,tenant_id,subject_kind,subject_id) VALUES($1,'Automation store',$1,'service_account','test-runner')", tenant); err != nil { + t.Fatal(err) + } + due := time.Now().Add(-time.Minute) + created, err := store.Create(t.Context(), automations.Automation{TenantID: tenant, Type: automations.Once, Name: "due", Metadata: map[string]string{}, Status: automations.Active, RunAt: &due, NextRunAt: &due, + TargetType: "existing_session", Target: []byte(`{"type":"existing_session"}`), Contents: []byte(`{"session_id":"s","instructions":"i"}`)}) + if err != nil { + t.Fatal(err) + } + var recorded atomic.Int64 + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + n, err := store.Record(t.Context(), time.Now()) + if err != nil { + t.Error(err) + } + recorded.Add(int64(n)) + }() + } + wg.Wait() + page, err := store.ListInvocations(t.Context(), tenant, created.ID, automations.ListQuery{Limit: 10}) + if err != nil || recorded.Load() != 1 || len(page.Invocations) != 1 || page.Invocations[0].Status != "queued" { + t.Fatalf("recorded %d: %+v %v", recorded.Load(), page, err) + } + deliveries, err := store.Deliverable(t.Context(), 10) + if err != nil || len(deliveries) != 1 || string(deliveries[0].Request) != `{"session_id":"s","instructions":"i"}` || deliveries[0].Scope.ProjectID != project { + t.Fatalf("deliverable %+v %v", deliveries, err) + } + id := deliveries[0].InvocationID + if err := store.Settle(t.Context(), automations.Outcome{InvocationID: id, Status: "completed", SessionID: uuid.NewString()}); err != nil { + t.Fatal(err) + } + if err := store.Settle(t.Context(), automations.Outcome{InvocationID: id, Status: "failed", Code: "late", Message: "late"}); err != nil { + t.Fatal(err) + } + invocation, err := store.GetInvocation(t.Context(), tenant, created.ID, id) + if err != nil || invocation.Status != "completed" || invocation.Code != "" { + t.Fatalf("settled twice: %+v %v", invocation, err) + } + if a, err := store.Get(t.Context(), tenant, created.ID); err != nil || a.Status != automations.Completed { + t.Fatalf("one-time automation after its invocation settled: %+v %v", a, err) + } + if left, err := store.Deliverable(t.Context(), 10); err != nil || len(left) != 0 { + t.Fatalf("settled invocation stays deliverable: %+v %v", left, err) + } +} diff --git a/services/core/tests/integration/public_handler_fixture_test.go b/services/core/tests/integration/public_handler_fixture_test.go index 8d26a015a..2f12e1ba8 100644 --- a/services/core/tests/integration/public_handler_fixture_test.go +++ b/services/core/tests/integration/public_handler_fixture_test.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" @@ -16,6 +17,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/agentpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/automationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/filepg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" @@ -77,6 +79,7 @@ func publicHandler(t testing.TB, s *Store, keys fixtureKeyResolver, engine strin return nil, err } deployments := deploymentService(t, s) + automationStore, automationService := fixtureAutomations(t, s) deps := api.Dependencies{ Engine: engine, CoreKeys: admin, InstallationBindings: deployments, Projects: projectService, ProjectsReader: fixtureProjectsReader{Store: projectStore, keys: keys}, @@ -86,6 +89,7 @@ func publicHandler(t testing.TB, s *Store, keys fixtureKeyResolver, engine strin EnvironmentTemplates: environmentTemplates, EnvironmentTemplatesReader: templates, Skills: skillService, SkillsReader: skillStore, Agents: agentService, AgentsReader: agentStore, + Automations: automationService, AutomationsReader: automationStore, Sessions: service, SessionsReader: sessionStore, SessionCreation: service, @@ -117,6 +121,17 @@ func fixtureAgents(t testing.TB, s *Store) (*agentpg.Store, *agents.Service) { return agentStore, agentService } +// fixtureAutomations builds the Automation adapter and service on s. +func fixtureAutomations(t testing.TB, s *Store) (*automationpg.Store, *automations.Service) { + t.Helper() + store := automationpg.New(pgunit.NewPool(s.pool), s.credentialCipher) + service, err := automations.NewService(store) + if err != nil { + t.Fatal(err) + } + return store, service +} + // fixtureFiles builds the File adapter and service on s. func fixtureFiles(t testing.TB, s *Store) (*filepg.Store, *files.Service) { t.Helper() diff --git a/services/core/tests/official_automations.py b/services/core/tests/official_automations.py new file mode 100644 index 000000000..8da87505e --- /dev/null +++ b/services/core/tests/official_automations.py @@ -0,0 +1,122 @@ +"""Public Automations through the fixed SDK, real delivery and PostgreSQL.""" + +import sys +import time + +import httpx2 +from openai import BadRequestError, ConflictError, NotFoundError, OpenAI + +MESSAGE = [{"role": "user", "content": [{"type": "input_text", "text": "scheduled"}]}] + + +def fails(error, action, param=None): + try: + action() + except error as e: + if param is not None: + assert e.response.json()["error"]["param"] == param, e.response.text + return + raise AssertionError("expected " + error.__name__) + + +def settled(read): + deadline = time.time() + 20 + while time.time() < deadline: + value = read() + if value.status != "queued": + return value + time.sleep(0.2) + raise AssertionError("delivery did not settle") + + +def main(): + base, token, foreign = sys.argv[1:] + with httpx2.Client(trust_env=False, timeout=10) as http: + make = lambda key: OpenAI(api_key=key, base_url=base + "/v1", http_client=http, max_retries=0, _strict_response_validation=True) + client, other = make(token), make(foreign) + agents = client.beta.agents + automations, invocations = agents.automations, agents.automations.invocations + saved = agents.create(model="saved-model", instructions="Saved instructions.") + session = agents.sessions.create(input="hello", agent_id=saved.id, environment={"type": "none"}) + new_session = {"type": "new_session", "agent_id": saved.id, "environment": {"type": "none"}, "input": MESSAGE, "metadata": {"k": "v"}} + existing = {"type": "existing_session", "session_id": session.id, "instructions": "wake up"} + + periodic = automations.create(type="periodic", name="yearly", expression="0 0 1 1 *", timezone="UTC", target=new_session, metadata={"a": "b"}) + assert periodic.status == "active" and periodic.created_by.type == "developer" and periodic.metadata == {"a": "b"} + target = periodic.target + assert target.agent_id == saved.id and target.agent is None and target.metadata == {"k": "v"} and target.vault_ids == [] and target.environment.type == "none" + inline = automations.create(type="periodic", name="inline", expression="*/5 * * * *", timezone="Asia/Shanghai", + target={"type": "new_session", "agent": {"model": "saved-model", "instructions": "Inline."}, "environment": {"type": "none"}, "input": MESSAGE}) + assert inline.target.agent.model == "saved-model" and inline.target.agent_id is None + once = automations.create(type="once", name="later", run_at=int(time.time()) + 3600, target=existing) + assert once.target.session_id == session.id and once.target.instructions == "wake up" and once.status == "active" + for created in (periodic, inline, once): + assert automations.retrieve(created.id) == created + assert [a.id for a in automations.list().data] == [once.id, inline.id, periodic.id] + assert [a.id for a in automations.list(order="asc", limit=2).data] == [periodic.id, inline.id] + assert [a.id for a in automations.list(after=inline.id).data] == [periodic.id] + assert [a.id for a in automations.list(session_id=session.id).data] == [once.id] + fails(NotFoundError, lambda: automations.list(after="missing")) + fails(NotFoundError, lambda: other.beta.agents.automations.retrieve(periodic.id)) + + # Update replaces whole fields and keeps the target type. + updated = automations.update(periodic.id, name="renamed", metadata={}, expression="30 0 1 1 *") + assert updated.name == "renamed" and updated.metadata == {} and updated.expression == "30 0 1 1 *" and automations.retrieve(periodic.id) == updated + assert automations.update(once.id, target={**existing, "instructions": "changed"}).target.instructions == "changed" + fails(BadRequestError, lambda: automations.update(once.id, target=new_session), "target.type") + fails(BadRequestError, lambda: automations.update(periodic.id, run_at=int(time.time()) + 60), "run_at") + fails(BadRequestError, lambda: automations.update(once.id, expression="* * * * *"), "expression") + fails(BadRequestError, lambda: automations.update(once.id, run_at=int(time.time()) - 60), "run_at") + + # Pause holds scheduled delivery; both transitions are idempotent. + assert automations.pause(periodic.id).status == "paused" and automations.pause(periodic.id).status == "paused" + assert automations.resume(periodic.id).status == "active" and automations.resume(periodic.id).status == "active" + + # Manual invocations deliver for both target types. + queued = invocations.create(once.id) + assert queued.status == "queued" and queued.source.type == "manual" and queued.scheduled_at is None + done = settled(lambda: invocations.retrieve(queued.id, automation_id=once.id)) + assert done.status == "completed" and done.session_id == session.id and done.error is None + manual = invocations.create(periodic.id) + created = settled(lambda: invocations.retrieve(manual.id, automation_id=periodic.id)) + assert created.status == "completed" and agents.sessions.retrieve(created.session_id).id == created.session_id + listed = invocations.list(once.id) + assert [i.id for i in listed.data] == [queued.id] and listed.data[0] == done + + # A one-time Automation runs at run_at and then completes. + soon = automations.create(type="once", name="soon", run_at=int(time.time()) + 2, target=existing) + deadline = time.time() + 20 + while automations.retrieve(soon.id).status != "completed": + assert time.time() < deadline, "one-time automation did not complete" + time.sleep(0.2) + record = invocations.list(soon.id).data[0] + assert record.status == "completed" and record.scheduled_at == soon.run_at and record.source.type == "automation" and record.source.automation.id == soon.id + assert record.session_id == session.id + fails(ConflictError, lambda: automations.pause(soon.id)) + fails(ConflictError, lambda: automations.resume(soon.id)) + fails(ConflictError, lambda: automations.update(soon.id, run_at=int(time.time()) + 60)) + + # Rejected requests save nothing. + count = len(automations.list(limit=100).data) + periodic_args = {"type": "periodic", "name": "bad", "expression": "0 0 1 1 *", "timezone": "UTC", "target": new_session} + fails(BadRequestError, lambda: automations.create(**periodic_args | {"expression": "bad"}), "expression") + fails(BadRequestError, lambda: automations.create(**periodic_args | {"timezone": "Mars/Base"}), "timezone") + fails(BadRequestError, lambda: automations.create(type="once", name="bad", run_at=int(time.time()) - 5, target=existing), "run_at") + fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {**new_session, "environment": {"type": "openai_hosted", "environment_id": "env"}}})) + fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {**new_session, "agent": {"model": "m"}}}), "target") + fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {k: v for k, v in new_session.items() if k != "agent_id"} | {"agent": {"instructions": "no model"}}}), "target.agent.model") + fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {k: v for k, v in new_session.items() if k != "agent_id"} | {"agent": {"model": "m", "x_agents_core": {"harness": "codex"}}}}), "target.agent.x_agents_core") + fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {**existing, "instructions": ""}}), "target.instructions") + fails(NotFoundError, lambda: automations.create(**periodic_args | {"target": {**existing, "session_id": "missing"}})) + assert len(automations.list(limit=100).data) == count + + # Delete removes the Automation and its records, not the Session. + deleted = automations.delete(periodic.id) + assert deleted.id == periodic.id and deleted.deleted and deleted.object == "agent.automation.deleted" + fails(NotFoundError, lambda: automations.retrieve(periodic.id)) + fails(NotFoundError, lambda: invocations.list(periodic.id)) + assert agents.sessions.retrieve(session.id).id == session.id + + +if __name__ == "__main__": + main() From 12273cd7abb8abdcfba78a663b2822291396b95f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 04:23:54 +0000 Subject: [PATCH 2/7] Deliver Automation invocations oldest first A skipped existing-session invocation carries the target Session, and delivery picks the oldest queued invocation across Automations. --- .../core/internal/db/queries/automations.sql | 25 ++++++----- .../core/internal/db/sqlc/automations.sql.go | 45 +++++++++++-------- .../integration/automations_store_test.go | 18 ++++++++ 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/services/core/internal/db/queries/automations.sql b/services/core/internal/db/queries/automations.sql index b5d8df69b..8b5e9e100 100644 --- a/services/core/internal/db/queries/automations.sql +++ b/services/core/internal/db/queries/automations.sql @@ -69,8 +69,10 @@ ORDER BY a.next_run_at LIMIT sqlc.arg(page_limit) FOR UPDATE OF a SKIP LOCKED; -- name: InsertScheduledInvocation :exec -INSERT INTO automation_invocations (id, automation_id, scheduled_at, automation_snapshot, status, skip_reason, delivery) -SELECT sqlc.arg(id), a.id, sqlc.arg(scheduled_at), sqlc.arg(automation_snapshot), sqlc.arg(status)::text, sqlc.narg(skip_reason)::text, CASE WHEN sqlc.arg(status)::text = 'queued' THEN a.target_contents END +INSERT INTO automation_invocations (id, automation_id, scheduled_at, automation_snapshot, status, skip_reason, session_id, delivery) +SELECT sqlc.arg(id), a.id, sqlc.arg(scheduled_at), sqlc.arg(automation_snapshot), sqlc.arg(status)::text, sqlc.narg(skip_reason)::text, + CASE WHEN sqlc.arg(status)::text = 'skipped' AND a.target->>'type' = 'existing_session' THEN (a.target->>'session_id')::uuid END, + CASE WHEN sqlc.arg(status)::text = 'queued' THEN a.target_contents END FROM automations a WHERE a.id = sqlc.arg(automation_id) ON CONFLICT (automation_id, scheduled_at) DO NOTHING; @@ -78,14 +80,17 @@ ON CONFLICT (automation_id, scheduled_at) DO NOTHING; UPDATE automations SET next_run_at = sqlc.narg(next_run_at), status = CASE WHEN sqlc.arg(completed)::boolean THEN 'completed' ELSE status END WHERE id = sqlc.arg(id); -- name: DeliverableInvocations :many -SELECT DISTINCT ON (i.automation_id) i.id, i.automation_id, a.tenant_id, (a.target->>'type')::text AS target_type, i.delivery, - p.subject_kind, p.subject_id, s.organization_id, s.project_id -FROM automation_invocations i -JOIN automations a ON a.id = i.automation_id -JOIN projects p ON p.tenant_id = a.tenant_id AND p.archived_at IS NULL -JOIN execution_project_scopes s ON s.tenant_id = a.tenant_id -WHERE i.status = 'queued' AND (i.scheduled_at IS NULL OR a.status = 'active') -ORDER BY i.automation_id, i.created_at, i.id +SELECT * FROM ( + SELECT DISTINCT ON (i.automation_id) i.id, i.automation_id, a.tenant_id, (a.target->>'type')::text AS target_type, i.delivery, i.created_at, + p.subject_kind, p.subject_id, s.organization_id, s.project_id + FROM automation_invocations i + JOIN automations a ON a.id = i.automation_id + JOIN projects p ON p.tenant_id = a.tenant_id AND p.archived_at IS NULL + JOIN execution_project_scopes s ON s.tenant_id = a.tenant_id + WHERE i.status = 'queued' AND (i.scheduled_at IS NULL OR a.status = 'active') + ORDER BY i.automation_id, i.created_at, i.id +) oldest +ORDER BY created_at, id LIMIT sqlc.arg(page_limit); -- name: SettleInvocation :one diff --git a/services/core/internal/db/sqlc/automations.sql.go b/services/core/internal/db/sqlc/automations.sql.go index 32ba45fcc..a9c807a7c 100644 --- a/services/core/internal/db/sqlc/automations.sql.go +++ b/services/core/internal/db/sqlc/automations.sql.go @@ -69,27 +69,31 @@ func (q *Queries) DeleteAutomation(ctx context.Context, arg DeleteAutomationPara } const deliverableInvocations = `-- name: DeliverableInvocations :many -SELECT DISTINCT ON (i.automation_id) i.id, i.automation_id, a.tenant_id, (a.target->>'type')::text AS target_type, i.delivery, - p.subject_kind, p.subject_id, s.organization_id, s.project_id -FROM automation_invocations i -JOIN automations a ON a.id = i.automation_id -JOIN projects p ON p.tenant_id = a.tenant_id AND p.archived_at IS NULL -JOIN execution_project_scopes s ON s.tenant_id = a.tenant_id -WHERE i.status = 'queued' AND (i.scheduled_at IS NULL OR a.status = 'active') -ORDER BY i.automation_id, i.created_at, i.id +SELECT id, automation_id, tenant_id, target_type, delivery, created_at, subject_kind, subject_id, organization_id, project_id FROM ( + SELECT DISTINCT ON (i.automation_id) i.id, i.automation_id, a.tenant_id, (a.target->>'type')::text AS target_type, i.delivery, i.created_at, + p.subject_kind, p.subject_id, s.organization_id, s.project_id + FROM automation_invocations i + JOIN automations a ON a.id = i.automation_id + JOIN projects p ON p.tenant_id = a.tenant_id AND p.archived_at IS NULL + JOIN execution_project_scopes s ON s.tenant_id = a.tenant_id + WHERE i.status = 'queued' AND (i.scheduled_at IS NULL OR a.status = 'active') + ORDER BY i.automation_id, i.created_at, i.id +) oldest +ORDER BY created_at, id LIMIT $1 ` type DeliverableInvocationsRow struct { - ID pgtype.UUID `json:"id"` - AutomationID pgtype.UUID `json:"automation_id"` - TenantID pgtype.UUID `json:"tenant_id"` - TargetType string `json:"target_type"` - Delivery []byte `json:"delivery"` - SubjectKind string `json:"subject_kind"` - SubjectID string `json:"subject_id"` - OrganizationID string `json:"organization_id"` - ProjectID string `json:"project_id"` + ID pgtype.UUID `json:"id"` + AutomationID pgtype.UUID `json:"automation_id"` + TenantID pgtype.UUID `json:"tenant_id"` + TargetType string `json:"target_type"` + Delivery []byte `json:"delivery"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + SubjectKind string `json:"subject_kind"` + SubjectID string `json:"subject_id"` + OrganizationID string `json:"organization_id"` + ProjectID string `json:"project_id"` } func (q *Queries) DeliverableInvocations(ctx context.Context, pageLimit int32) ([]DeliverableInvocationsRow, error) { @@ -107,6 +111,7 @@ func (q *Queries) DeliverableInvocations(ctx context.Context, pageLimit int32) ( &i.TenantID, &i.TargetType, &i.Delivery, + &i.CreatedAt, &i.SubjectKind, &i.SubjectID, &i.OrganizationID, @@ -308,8 +313,10 @@ func (q *Queries) InsertManualInvocation(ctx context.Context, arg InsertManualIn } const insertScheduledInvocation = `-- name: InsertScheduledInvocation :exec -INSERT INTO automation_invocations (id, automation_id, scheduled_at, automation_snapshot, status, skip_reason, delivery) -SELECT $1, a.id, $2, $3, $4::text, $5::text, CASE WHEN $4::text = 'queued' THEN a.target_contents END +INSERT INTO automation_invocations (id, automation_id, scheduled_at, automation_snapshot, status, skip_reason, session_id, delivery) +SELECT $1, a.id, $2, $3, $4::text, $5::text, + CASE WHEN $4::text = 'skipped' AND a.target->>'type' = 'existing_session' THEN (a.target->>'session_id')::uuid END, + CASE WHEN $4::text = 'queued' THEN a.target_contents END FROM automations a WHERE a.id = $6 ON CONFLICT (automation_id, scheduled_at) DO NOTHING ` diff --git a/services/core/tests/integration/automations_store_test.go b/services/core/tests/integration/automations_store_test.go index ced93248d..7c2d6a2ff 100644 --- a/services/core/tests/integration/automations_store_test.go +++ b/services/core/tests/integration/automations_store_test.go @@ -67,4 +67,22 @@ func TestAutomationRecordingAndSettlement(t *testing.T) { if left, err := store.Deliverable(t.Context(), 10); err != nil || len(left) != 0 { t.Fatalf("settled invocation stays deliverable: %+v %v", left, err) } + + // An occurrence that finds a queued invocation is recorded as skipped, with the target Session. + session := uuid.NewString() + periodic, err := store.Create(t.Context(), automations.Automation{TenantID: tenant, Type: automations.Periodic, Name: "busy", Metadata: map[string]string{}, Status: automations.Active, Expression: "* * * * *", Timezone: "UTC", NextRunAt: &due, + TargetType: "existing_session", Target: []byte(`{"type":"existing_session","session_id":"` + session + `"}`), Contents: []byte(`{}`)}) + if err != nil { + t.Fatal(err) + } + if _, err := store.Invoke(t.Context(), tenant, periodic.ID); err != nil { + t.Fatal(err) + } + if n, err := store.Record(t.Context(), time.Now()); err != nil || n != 1 { + t.Fatalf("recorded %d: %v", n, err) + } + page, err = store.ListInvocations(t.Context(), tenant, periodic.ID, automations.ListQuery{Limit: 10, Ascending: true}) + if err != nil || len(page.Invocations) != 2 || page.Invocations[0].Status != "queued" || page.Invocations[1].Status != "skipped" || page.Invocations[1].SkipReason != "pending_delivery" || page.Invocations[1].SessionID != session { + t.Fatalf("skipped occurrence: %+v %v", page, err) + } } From 386923a87a799f41cb72e435c20efeefa4a59b1b Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 04:23:54 +0000 Subject: [PATCH 3/7] Document Automations Adds the Automations owner page, the compatibility ledger entries, the guide section, the metrics job and the delivery notes in IMPLEMENTATION.md, with Chinese mirrors. --- apps/web/src/i18n/locales/en/metrics.ts | 2 +- apps/web/src/i18n/locales/zh-CN/metrics.ts | 2 +- contracts/agents-api/automations.md | 78 +++++++++++++++++++++ contracts/agents-api/core-metrics.md | 5 +- contracts/agents-api/index.md | 19 ++++- contracts/agents-api/wire-semantics.md | 6 +- contracts/agents-api/zh/automations.md | 80 ++++++++++++++++++++++ contracts/agents-api/zh/core-metrics.md | 7 +- contracts/agents-api/zh/index.md | 19 ++++- contracts/agents-api/zh/wire-semantics.md | 6 +- docs.json | 1 + docs/api/public-agent-api.md | 24 +++++++ docs/zh/api/public-agent-api.md | 26 ++++++- services/core/IMPLEMENTATION.md | 1 + 14 files changed, 260 insertions(+), 16 deletions(-) create mode 100644 contracts/agents-api/automations.md create mode 100644 contracts/agents-api/zh/automations.md diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index 66e191a70..748febb2a 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -172,7 +172,7 @@ export const metrics = { processed: "Processed", failed: "Failed", empty: "Core reports no background jobs.", - names: { scheduler: "Execution scheduling", runtime_sampler: "Runtime sampling", history_cleanup: "History cleanup", audit_cleanup: "Audit cleanup" }, + names: { scheduler: "Execution scheduling", runtime_sampler: "Runtime sampling", automation_delivery: "Automation delivery", history_cleanup: "History cleanup", audit_cleanup: "Audit cleanup" }, health: { ok: "Healthy", failing: "Failing", stopped: "Stopped", unknown: "Unknown" }, }, }, diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index b17eccf8e..eb8cb8975 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -172,7 +172,7 @@ export const metrics = { processed: "处理", failed: "失败", empty: "Core 没有报告后台任务。", - names: { scheduler: "执行调度", runtime_sampler: "Runtime 采样", history_cleanup: "历史清理", audit_cleanup: "审计清理" }, + names: { scheduler: "执行调度", runtime_sampler: "Runtime 采样", automation_delivery: "Automation 投递", history_cleanup: "历史清理", audit_cleanup: "审计清理" }, health: { ok: "正常", failing: "失败", stopped: "已停止", unknown: "未知" }, }, }, diff --git a/contracts/agents-api/automations.md b/contracts/agents-api/automations.md new file mode 100644 index 000000000..4e61cd85b --- /dev/null +++ b/contracts/agents-api/automations.md @@ -0,0 +1,78 @@ +--- +title: "Automations" +--- + +An Automation saves a schedule and a target. When the schedule fires, Core delivers the target: it creates a new Session, or sends instructions to an existing one. All ten operations of the pinned SDK are implemented under `/v1/agents/automations` with the same Project authentication and `OpenAI-Beta: agents=v1` as other Beta routes. An Automation belongs to the Project of its key; a missing ID and another Project's ID both return 404. + +| Operation | Result | +| --- | --- | +| `POST /agents/automations` | 201, an active Automation; nothing is delivered | +| `GET /agents/automations` | A page; `session_id` keeps Automations whose target is that Session | +| `GET /agents/automations/{automation_id}` | The Automation | +| `POST /agents/automations/{automation_id}` | Replaces the fields in the body; `target` and `metadata` replace the whole field | +| `DELETE /agents/automations/{automation_id}` | `agent.automation.deleted`; the target Session and the Sessions it created stay | +| `POST …/pause`, `POST …/resume` | The Automation | +| `POST …/invocations` | 200, a queued manual invocation | +| `GET …/invocations`, `GET …/invocations/{invocation_id}` | A page, or one invocation | + +Lists take `after`, `limit` (default 20; 0 becomes 1 and above 100 becomes 100) and `order` (default `desc`). A cursor that names no record returns 404 `not_found_error`. + +## Resource + +`type` is `periodic` or `once`. + +- `periodic` has `expression` and `timezone`. Status is `active` or `paused`. +- `once` has `run_at`, a Unix timestamp in seconds. Status is `active`, `paused` or `completed`; completed means the schedule has finished, not that the agent's work has. +- `name` holds 1–256 characters, `expression` 1–256, `timezone` 1–128. `metadata` follows the [validation errors](./wire-semantics.md#validation-errors). +- `created_by` is always `{"type": "developer"}`: the `automations` agent tool is not supported, so an agent cannot create one ([Session admission](./wire-semantics.md#session-admission)). + +### Schedules + +`expression` is a five-field crontab: minute, hour, day of month, month and day of week. A field is `*` or a comma list of `n`, `a-b`, `*/s` and `a-b/s`; months and weekdays also take three-letter names, and weekday 7 is Sunday. When both day fields are restricted, a day matches either. Descriptors such as `@daily`, a seconds field, `Local` and an empty zone are rejected, and so is an expression with no occurrence in the next five years. + +`timezone` is an IANA name, resolved from the tzdata Core embeds. A wall-clock minute that does not exist on a daylight saving change is skipped; a minute that occurs twice fires once, at its first instant. + +If Core was down when occurrences were due, it records only the latest one. + +## Targets + +`target.type` is `new_session` or `existing_session`. An update keeps the type; another type returns 400 with param `target.type`. + +`new_session` takes the body of Session create without `type`: `environment` and `input` (an array of user messages) are required; `agent` or `agent_id` is optional, and exactly one of them must be given; `metadata` and `vault_ids` are optional. The target is validated by the code that validates Session create, so its rejections are the same, with their params prefixed `target.`: `environment.environment_id`, `container_size`, `desktop.enabled`, `packages.system` and `blocked_domains` are rejected. Beyond that: + +- An inline `agent` needs `model` (param `target.agent.model`) and rejects `x_agents_core`. +- `x_agents_core` is rejected anywhere in the target. +- The saved resource shows the inline agent without `id` and `name`, MCP headers removed. `environment` shows the members the request supplied and `null` for the rest, which means inherit. `env` and `setup_commands` are never shown. + +`existing_session` takes `session_id` and `instructions` (1 byte to 1 MiB). The Session must exist when the Automation is created or updated; a Session deleted later fails each delivery with the input path's 404. Core sends `instructions` as one user message with one `input_text` part, exactly as `agent.session.input.message` would. + +Create and update save the target; references inside it, such as a Skill, a File or an Agent, resolve at each invocation. + +The target request may hold values that reads never return, so Core stores it sealed with the credential key, bound to the Project and the Automation. + +## Invocations + +A scheduled occurrence and a manual invocation each produce one invocation record. `source` is `manual`, or `automation` with the Automation as it was at the occurrence. + +| Status | Meaning | +| --- | --- | +| `queued` | Recorded, not yet delivered | +| `completed` | Delivered; `session_id` is the created Session, or the target | +| `failed` | Delivery was rejected for good; `error` holds the `code` and `message` the same request would return | +| `skipped` | `skip_reason` is `pending_delivery`: the Automation already had a queued invocation | + +Core records due occurrences and delivers queued invocations once a second, and runs at most 16 deliveries at the same time, each for up to 5 s. A delivery uses the code of `POST /v1/agents/sessions` or `POST /v1/agents/sessions/{id}/events`, with the invocation ID as its `Idempotency-Key`, so a retry after a crash finds the Session or the input it already made. Core retries a delivery while the cause is temporary: execution unavailable, a sandbox reset, a pending earlier input, or a Turn that cannot take input. Any other rejection fails the invocation. One Automation delivers one invocation at a time, oldest first, and invocations are kept until the Automation is deleted. + +A `once` Automation completes when its scheduled invocation reaches `completed`, `failed` or `skipped`. A manual invocation never changes the schedule. + +## State changes + +| Request | Rule | +| --- | --- | +| Pause | Stops scheduled delivery, holds a queued scheduled invocation until resume, and stops no Session. Manual invocations still deliver. Pausing a paused Automation returns it unchanged | +| Resume | A periodic Automation takes its next occurrence from now. A `once` Automation resumes if its scheduled invocation is pending or `run_at` is in the future; otherwise 409 `conflict_error`. Resuming an active Automation returns it unchanged | +| Pause or resume a completed Automation | 409 `conflict_error` | +| Update | `expression` and `timezone` apply only to periodic Automations and `run_at` only to `once`; another type's field returns 400 naming it. A past `run_at` returns 400. Changing `run_at` after the scheduled invocation exists returns 409 | +| Manual invoke | Allowed in every status | + +Core's choices where the official sources are silent are listed in the [ledger](./index.md#unverified-against-the-official-service). Invocation creation writes the audit entry `create` on `automation_invocation`; scheduled recording writes none ([write provenance](./admin-api.md#write-provenance)). diff --git a/contracts/agents-api/core-metrics.md b/contracts/agents-api/core-metrics.md index 088ae46e9..a3a9c5f35 100644 --- a/contracts/agents-api/core-metrics.md +++ b/contracts/agents-api/core-metrics.md @@ -57,9 +57,10 @@ Core resolves its own cgroup, including nested and subtree mounts, and reports t ## Background jobs -`jobs` lists `scheduler`, `runtime_sampler`, `history_cleanup` and `audit_cleanup`. Each has `status` (`unknown` before its first run, `ok`, `failing`, or `stopped` when its loop is disabled or ended), `last_run_at` (when the last pass finished), `processed` and `failed`, both describing the last pass. +`jobs` lists `scheduler`, `runtime_sampler`, `automation_delivery`, `history_cleanup` and `audit_cleanup`. Each has `status` (`unknown` before its first run, `ok`, `failing`, or `stopped` when its loop is disabled or ended), `last_run_at` (when the last pass finished), `processed` and `failed`, both describing the last pass. - The scheduler's `processed` counts the Turn and Environment work it selected in its last poll. - The Runtime sampler's `processed` and `failed` count the targets its last sweep observed and failed to observe. +- The Automation delivery job's `processed` counts the occurrences it recorded and the invocations it settled in its last pass; `failed` counts storage failures. An invocation that failed for good is a settled record and does not count. - A cleanup job's `processed` counts the rows it removed. -- For the scheduler and the cleanup jobs, a failed pass sets `processed` to null and `failed` to 1; `failed` never estimates lost rows or failed Turns. +- For the scheduler, the Automation delivery job and the cleanup jobs, a failed pass sets `processed` to null and `failed` to 1; `failed` never estimates lost rows or failed Turns. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index e38f51edf..19c4e29f9 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -40,7 +40,7 @@ Evidence for a status comes from the pinned official SDK and raw HTTP against th | Vault Credentials | create, retrieve, update, list, delete | Implemented for `static_bearer` and `mcp_oauth`, with `metadata` | [Vaults and Credentials](./vaults.md) | | Files | create, retrieve, list, delete, content | Implemented for `purpose=user_data`; content download is rejected | [Files and Skills](./source-files.md) | | Skills and Skill versions | create, retrieve, update, list, delete, content | Implemented | [Files and Skills](./source-files.md) | -| Automations | all 10 operations | Not supported: 404 `unsupported_operation` | [Unsupported operations](./wire-semantics.md#unsupported-operations) | +| Automations and invocations | create, retrieve, update, list, delete, pause, resume; invocation create, retrieve, list | Implemented; `created_by` is always `developer` | [Automations](./automations.md) | | Environments collection | create, list (`/agents/environments`) | Not supported: 404 `unsupported_operation`; Core's Environments are Session-scoped | [Unsupported operations](./wire-semantics.md#unsupported-operations) | | Session traces | list | Not supported: 404 `unsupported_operation` | [Unsupported operations](./wire-semantics.md#unsupported-operations) | @@ -127,6 +127,10 @@ Each item is Core's deliberate or native behavior where the official service beh - File names hold 1–1,024 bytes; the official service accepts empty and longer names. - A `SKILL.md` front-matter key other than `name`, `description`, `license`, `compatibility` and `metadata` is rejected; the official service accepts unknown keys ([upload a bundle](./source-files.md#upload-a-bundle)). +**Automations** ([Automations](./automations.md)) + +- An empty `instructions` in an `existing_session` target returns 400 at create and update; the pinned schema allows 0 bytes, and Core cannot send an empty message. + **Vaults and Credentials** ([Vaults and Credentials](./vaults.md)) - MiniMax Code runs public function calls one at a time, so the application sees one `function_call` at a time. @@ -195,6 +199,19 @@ Each item is Core's deliberate or native behavior where the official service beh - When a Session update binds to a queued Turn: Core binds each root Turn's settings when it accepts the input that creates the Turn, so a Turn queued before an update keeps the earlier values. - The meaning of "automatic review is not enabled" in a Session response: Core omits `review` unless it is `auto_review`. +- Automations, where the pinned sources are silent ([Automations](./automations.md)): + - A target `existing_session` delivers its `instructions` as one `agent.session.input.message` with one `input_text` part, since the schema defines no notification Item or event. + - The cron grammar is Vixie crontab beyond "standard five-field". A nonexistent daylight saving minute is skipped, and a repeated one fires once at its first instant. + - After downtime, only the latest due occurrence is recorded. + - `pending_delivery` skips an occurrence while any invocation of the Automation is queued. + - A `once` Automation completes when its scheduled invocation is terminal. + - Pause holds a queued scheduled invocation until resume; manual invocations still deliver, in every status. + - Pausing a paused Automation and resuming an active one return it unchanged. Pausing or resuming a completed one, resuming a `once` Automation whose past `run_at` has no pending invocation, and changing `run_at` after its scheduled invocation exists return 409 `conflict_error`. + - A past `run_at`, a timing field of the other type, a target of another type, an invalid cron expression or time zone, and an inline `agent` without `model` return 400 `invalid_request_error` with Core's own params and messages. + - A skipped `existing_session` invocation carries the target's `session_id`; a `new_session` invocation has null until its Session exists. + - A `limit` outside 1–100 is clamped, and an unresolved `after` returns 404. + - Create validates shapes, Core's Session rules and that the target Session exists; saved references resolve at each invocation. + - Invocations are kept until the Automation is deleted. A deleted target Session fails each delivery. - The order of errors when one request has several faults, and error, default and payload-limit parity in general. - A Subagent's function call: Core makes it a required action of the root Turn that runs the child, with `turn_id` set to that Turn, and publishes the child's `function_call` Item once as `agent.session.turn.item.added` with the child Turn ID and a null `output_index`; a result may name either Turn. The Turn is `waiting` and the Session `requires_action` while root output may continue, and the `call_id` equals the `call_id` of the child's `function_call` Item. Claude Code publishes the child's Items when the root Turn settles, after the required action. - Whether the official service emits Environment `pending` and when: Core emits it when a hosted Environment's initialization starts, and the one official sample showed only `ready`, about 29 s after creation. diff --git a/contracts/agents-api/wire-semantics.md b/contracts/agents-api/wire-semantics.md index b3795b5d7..caf2d4c56 100644 --- a/contracts/agents-api/wire-semantics.md +++ b/contracts/agents-api/wire-semantics.md @@ -23,7 +23,7 @@ The pinned OpenAI Python SDK ([upstream.json](https://github.com/MiniMax-AI/Open ### Unsupported operations -The Automations routes (all 10), `POST` and `GET /agents/environments` and `GET /agents/sessions/{session_id}/traces` are registered, so they pass the Beta and authentication checks like every pinned route, then answer 404 with code `unsupported_operation` and the message "This API operation is not supported." Environments exist only inside a Session ([ownership](./environments.md#resources-and-states)), and Core has no Automations or trace store. +`POST` and `GET /agents/environments` and `GET /agents/sessions/{session_id}/traces` are registered, so they pass the Beta and authentication checks like every pinned route, then answer 404 with code `unsupported_operation` and the message "This API operation is not supported." Environments exist only inside a Session ([ownership](./environments.md#resources-and-states)), and Core has no trace store. Creating an Agent, Vault, Credential, Environment Template, Environment file or Session returns 201, also for a streamed Session creation. An empty update body on an Agent or Environment Template advances `updated_at` and changes nothing else; timestamps have one-second precision. @@ -59,7 +59,7 @@ Every `/v1` JSON route passes one body gate before route decoding, validation or | Order | Case | Response | | --- | --- | --- | | 1 | `Content-Type` missing, not JSON or malformed, including a bodyless `POST`. `application/json` and `application/*+json` are accepted case-insensitively, with parameters | "expected request with Content-Type: application/json" | -| 2 | Body over the route limit: 16 MiB for Session create and Environment Templates, the [Environment file](./environment-files.md) bound for file create, 1 MiB elsewhere | 413, code `request_too_large` | +| 2 | Body over the route limit: 16 MiB for Session create, Environment Templates and Automation create and update, the [Environment file](./environment-files.md) bound for file create, 1 MiB elsewhere | 413, code `request_too_large` | | 3 | Invalid UTF-8 | "Invalid body: encountered a unicode decode error when parsing this JSON value. Please check the value to ensure it is valid unicode." | | 4 | Malformed JSON, trailing data, two values, a byte order mark, a whitespace-only body, or an escape that forms a lone UTF-16 surrogate | "Invalid body: failed to parse JSON value. Please check the value to ensure it is valid JSON. (Common errors include trailing commas, missing closing brackets, missing quotation marks, etc.)" | | 5 | A key repeated in one object, at any depth | `"Invalid body: duplicate JSON key '' at ''. Duplicate JSON keys are not supported."` The path joins object keys with `.` and omits array indices, such as `metadata.k` or `tools.type`. Keys compare after unescaping and case-sensitively. The first repeat in document order is reported. | @@ -123,7 +123,7 @@ Vault and Credential lists accept `status` as a scalar, as `status[]` entries, o | Lists | Default | Accepted | Other values | | --- | --- | --- | --- | -| Agents, Sessions, Items, Turn Items, Environment Templates, Vaults, Credentials, Subagent Items, Subagent Turn Items | 20 | 1–100 | 0 becomes 1; above 100 becomes 100 | +| Agents, Sessions, Items, Turn Items, Environment Templates, Automations, Automation invocations, Vaults, Credentials, Subagent Items, Subagent Turn Items | 20 | 1–100 | 0 becomes 1; above 100 becomes 100 | | Turns, Subagents, Subagent Turns, Artifacts | 20 | 1–100 | 400 `invalid_request_error`, "limit must be between 1 and 100" | | Skills, Skill versions | 20 | 0–100 | 0 returns an empty page whose `has_more` reports whether a resource follows the cursor. Negative: 400 `integer_below_min_value`, param `limit`. Above 100: 400 `integer_above_max_value`, param `limit` | | Files | 10000 | 1–10000 | 400 with a null code, "limit must be between 1 and 10000." | diff --git a/contracts/agents-api/zh/automations.md b/contracts/agents-api/zh/automations.md new file mode 100644 index 000000000..2b80c82c1 --- /dev/null +++ b/contracts/agents-api/zh/automations.md @@ -0,0 +1,80 @@ +--- +title: "Automations" +source: contracts/agents-api/automations.md +source_hash: 0320feda2a052e74cb809d682793b5fad5b71250ae08ed80a35a14d368238450 +--- + +Automation 保存一个计划和一个目标。计划触发时,Core 投递目标:创建新 Session,或向已有 Session 发送指令。锁定版本 SDK 的全部十个操作都在 `/v1/agents/automations` 下实现,并与其他 Beta 路由一样需要项目身份验证和 `OpenAI-Beta: agents=v1`。Automation 属于其密钥所在的项目;不存在的 ID 与其他项目的 ID 都返回 404。 + +| 操作 | 结果 | +| --- | --- | +| `POST /agents/automations` | 201,一个 active 的 Automation;不会投递任何内容 | +| `GET /agents/automations` | 一页结果;`session_id` 只保留目标为该 Session 的 Automation | +| `GET /agents/automations/{automation_id}` | 该 Automation | +| `POST /agents/automations/{automation_id}` | 替换请求体中的字段;`target` 和 `metadata` 整体替换 | +| `DELETE /agents/automations/{automation_id}` | `agent.automation.deleted`;目标 Session 和它创建的 Session 保留 | +| `POST …/pause`、`POST …/resume` | 该 Automation | +| `POST …/invocations` | 200,一个排队中的手动 invocation | +| `GET …/invocations`、`GET …/invocations/{invocation_id}` | 一页结果,或单个 invocation | + +列表接受 `after`、`limit`(默认 20;0 变为 1,大于 100 变为 100)和 `order`(默认 `desc`)。不指向任何记录的游标返回 404 `not_found_error`。 + +## 资源 {#resource} + +`type` 为 `periodic` 或 `once`。 + +- `periodic` 带有 `expression` 和 `timezone`。状态为 `active` 或 `paused`。 +- `once` 带有 `run_at`,即以秒为单位的 Unix 时间戳。状态为 `active`、`paused` 或 `completed`;completed 表示计划已结束,而不是 agent 的工作已结束。 +- `name` 为 1–256 个字符,`expression` 为 1–256,`timezone` 为 1–128。`metadata` 遵循[校验错误](wire-semantics.md#validation-errors)中的规则。 +- `created_by` 始终为 `{"type": "developer"}`:不支持 `automations` agent 工具,因此 agent 无法创建 Automation([Session 准入](wire-semantics.md#session-admission))。 + +### 计划 {#schedules} + +`expression` 是五字段 crontab:分钟、小时、日、月和星期。每个字段为 `*`,或由 `n`、`a-b`、`*/s` 和 `a-b/s` 组成的逗号列表;月和星期还接受三字母名称,星期 7 表示周日。日和星期两个字段都有限制时,满足任一个即匹配该日。`@daily` 这类描述符、秒字段、`Local` 和空时区会被拒绝,未来五年内没有触发时间的表达式也会被拒绝。 + +`timezone` 是 IANA 名称,由 Core 内嵌的 tzdata 解析。夏令时切换中不存在的墙上时钟分钟会被跳过;出现两次的分钟只触发一次,在其第一个时刻。 + +如果 Core 停机期间有多次触发到期,它只记录最近一次。 + +## 目标 {#targets} + +`target.type` 为 `new_session` 或 `existing_session`。更新保持类型不变;另一种类型返回 400,param 为 `target.type`。 + +`new_session` 采用去掉 `type` 的 Session 创建请求体:`environment` 和 `input`(用户消息数组)必填;`agent` 或 `agent_id` 可选,且必须恰好提供其中一个;`metadata` 和 `vault_ids` 可选。目标由校验 Session 创建的同一段代码校验,因此拒绝规则相同,param 加上前缀 `target.`:`environment.environment_id`、`container_size`、`desktop.enabled`、`packages.system` 和 `blocked_domains` 会被拒绝。此外: + +- 内联 `agent` 需要 `model`(param 为 `target.agent.model`),并拒绝 `x_agents_core`。 +- 目标中任何位置的 `x_agents_core` 都会被拒绝。 +- 保存后的资源展示的内联 agent 不含 `id` 和 `name`,并去掉 MCP headers。`environment` 展示请求提供的成员,其余为 `null`,表示继承。`env` 和 `setup_commands` 从不展示。 + +`existing_session` 采用 `session_id` 和 `instructions`(1 字节到 1 MiB)。创建或更新 Automation 时,该 Session 必须存在;之后被删除会让每次投递以输入路径的 404 失败。Core 把 `instructions` 作为一条带有一个 `input_text` 部分的用户消息发送,与 `agent.session.input.message` 完全一样。 + +创建和更新只保存目标;目标中的引用,如 Skill、File 或 Agent,在每次 invocation 时解析。 + +目标请求可能包含读取时不会返回的值,因此 Core 用凭据密钥将其密封存储,并绑定到项目和 Automation。 + +## Invocations {#invocations} + +每次计划触发和每次手动 invocation 都产生一条 invocation 记录。`source` 为 `manual`,或为 `automation` 并带有触发时的 Automation。 + +| 状态 | 含义 | +| --- | --- | +| `queued` | 已记录,尚未投递 | +| `completed` | 已投递;`session_id` 是创建的 Session,或目标 Session | +| `failed` | 投递被永久拒绝;`error` 包含相同请求会返回的 `code` 和 `message` | +| `skipped` | `skip_reason` 为 `pending_delivery`:该 Automation 已有排队中的 invocation | + +Core 每秒记录到期的触发并投递排队中的 invocation,最多同时运行 16 个投递,每个最长 5 s。投递使用 `POST /v1/agents/sessions` 或 `POST /v1/agents/sessions/{id}/events` 的代码,并以 invocation ID 作为 `Idempotency-Key`,因此崩溃后的重试会找到已经创建的 Session 或输入。原因是暂时的,Core 就会重试:执行不可用、沙箱重置、较早的输入仍在待处理,或 Turn 无法接收输入。其他任何拒绝都会让 invocation 失败。同一个 Automation 一次只投递一个 invocation,先到先投递;invocation 保留到 Automation 被删除。 + +`once` Automation 在其计划 invocation 到达 `completed`、`failed` 或 `skipped` 时完成。手动 invocation 从不改变计划。 + +## 状态变化 {#state-changes} + +| 请求 | 规则 | +| --- | --- | +| 暂停 | 停止计划投递,保留排队中的计划 invocation 直到恢复,不停止任何 Session。手动 invocation 仍会投递。暂停已暂停的 Automation 会原样返回 | +| 恢复 | periodic Automation 从现在起取下一次触发。`once` Automation 在其计划 invocation 待处理或 `run_at` 在未来时恢复;否则返回 409 `conflict_error`。恢复已激活的 Automation 会原样返回 | +| 暂停或恢复已完成的 Automation | 409 `conflict_error` | +| 更新 | `expression` 和 `timezone` 只适用于 periodic Automation,`run_at` 只适用于 `once`;另一类型的字段返回 400 并指明该字段。过去的 `run_at` 返回 400。计划 invocation 产生后修改 `run_at` 返回 409 | +| 手动 invoke | 任何状态下都允许 | + +官方来源未说明、由 Core 自行选择的行为列在[清单](index.md#unverified-against-the-official-service)中。创建 invocation 会写入 `automation_invocation` 上的 `create` 审计条目;计划记录不写审计([写入来源](admin-api.md#write-provenance))。 diff --git a/contracts/agents-api/zh/core-metrics.md b/contracts/agents-api/zh/core-metrics.md index 1921f3526..7ff32847d 100644 --- a/contracts/agents-api/zh/core-metrics.md +++ b/contracts/agents-api/zh/core-metrics.md @@ -1,7 +1,7 @@ --- title: "Core 指标" source: contracts/agents-api/core-metrics.md -source_hash: bb6125b122a9cb46664db9d9536c0e799a3e1b789daed0f62941c0bc2bed6405 +source_hash: 79bdf1be68c3765366370f2da4fbb032bda1474adc8c096410f2a8c0f93361db --- `GET /core/v1/metrics?range=1h|6h|24h|7d` 报告 Core 自身的健康状况:进程、执行队列与槽位、PostgreSQL 和后台任务。它要求 Core 密钥([Core 管理 API](admin-api.md))。 @@ -59,9 +59,10 @@ Core 解析自身的 cgroup,包括嵌套与子树挂载,并报告该 cgroup ## 后台任务 {#background-jobs} -`jobs` 列出 `scheduler`、`runtime_sampler`、`history_cleanup` 和 `audit_cleanup`。每项包含 `status`(首次运行前为 `unknown`,之后为 `ok`、`failing`,循环禁用或结束时为 `stopped`)、`last_run_at`(上次一轮结束时间)、`processed` 和 `failed`,后两者描述上次一轮。 +`jobs` 列出 `scheduler`、`runtime_sampler`、`automation_delivery`、`history_cleanup` 和 `audit_cleanup`。每项包含 `status`(首次运行前为 `unknown`,之后为 `ok`、`failing`,循环禁用或结束时为 `stopped`)、`last_run_at`(上次一轮结束时间)、`processed` 和 `failed`,后两者描述上次一轮。 - scheduler 的 `processed` 统计上次轮询选中的 Turn 和 Environment 工作。 - Runtime sampler 的 `processed` 和 `failed` 统计上次扫描成功观察和未能观察的目标。 +- Automation 投递任务的 `processed` 统计上一轮记录的触发和结算的 invocation 数;`failed` 统计存储失败。永久失败的 invocation 是已结算的记录,不计入。 - 清理任务的 `processed` 统计删除的行数。 -- scheduler 和清理任务失败时,`processed` 为 null,`failed` 为 1;`failed` 不估算丢失行数或失败 Turn 数量。 +- scheduler、Automation 投递任务和清理任务失败时,`processed` 为 null,`failed` 为 1;`failed` 不估算丢失行数或失败 Turn 数量。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index ac529d86a..d89d1013c 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -43,7 +43,7 @@ Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源 | Vault Credentials | create, retrieve, update, list, delete | 已支持 `static_bearer` 和 `mcp_oauth`,含 `metadata` | [Vaults and Credentials](vaults.md) | | Files | create, retrieve, list, delete, content | 已支持 `purpose=user_data`;内容下载会被拒绝 | [Files and Skills](source-files.md) | | Skills and Skill versions | create, retrieve, update, list, delete, content | 已实现 | [Files and Skills](source-files.md) | -| Automations | 全部 10 个操作 | 不支持:404 `unsupported_operation` | [不支持的操作](wire-semantics.md#unsupported-operations) | +| Automations 和 invocations | 创建、检索、更新、列出、删除、暂停、恢复;invocation 的创建、检索、列出 | 已实现;`created_by` 始终为 `developer` | [Automations](automations.md) | | Environment 集合 | create, list(`/agents/environments`) | 不支持:404 `unsupported_operation`;Core 的 Environment 以 Session 为范围 | [不支持的操作](wire-semantics.md#unsupported-operations) | | Session traces | list | 不支持:404 `unsupported_operation` | [不支持的操作](wire-semantics.md#unsupported-operations) | @@ -130,6 +130,10 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 文件名长度为 1–1,024 字节;官方服务接受空文件名和更长的文件名。 - `SKILL.md` front matter 中 `name`、`description`、`license`、`compatibility` 和 `metadata` 以外的键会被拒绝;官方服务接受未知键([上传 bundle](source-files.md#upload-a-bundle))。 +**Automations**([Automations](automations.md)) + +- `existing_session` 目标的 `instructions` 为空时,创建和更新返回 400;固定版本 schema 允许 0 字节,而 Core 无法发送空消息。 + **Vaults 和 Credentials**([Vaults and Credentials](vaults.md)) - MiniMax Code 逐个运行公共函数调用,因此应用每次只看到一个 `function_call`。 @@ -198,6 +202,19 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - Session 更新何时绑定到排队中的 Turn:Core 在接受创建根 Turn 的输入时绑定该 Turn 的设置,因此更新之前已排队的 Turn 保持原有取值。 - Session 响应中“automatic review is not enabled”的含义:除 `auto_review` 外,Core 省略 `review`。 +- Automations 中固定版本来源未说明的部分([Automations](automations.md)): + - `existing_session` 目标把 `instructions` 作为一条 `agent.session.input.message` 投递,其中只有一个 `input_text` 部分,因为 schema 没有定义通知 Item 或事件。 + - cron 语法在“标准五字段”之外采用 Vixie crontab。夏令时切换中不存在的分钟会被跳过,重复出现的分钟只在其第一个时刻触发一次。 + - 停机之后,只记录最近一次到期的触发。 + - Automation 已有排队中的 invocation 时,`pending_delivery` 会跳过新的触发。 + - `once` Automation 在其计划 invocation 到达终态时完成。 + - 暂停会保留排队中的计划 invocation 直到恢复;手动 invocation 在任何状态下仍会投递。 + - 暂停已暂停的 Automation、恢复已激活的 Automation,会原样返回。暂停或恢复已完成的 Automation、恢复过去 `run_at` 且没有待处理 invocation 的 `once` Automation,以及在计划 invocation 产生后修改 `run_at`,均返回 409 `conflict_error`。 + - 过去的 `run_at`、另一类型的时间字段、另一类型的目标、无效的 cron 表达式或时区、缺少 `model` 的内联 `agent`,返回 400 `invalid_request_error`,param 和消息为 Core 自己的内容。 + - 被跳过的 `existing_session` invocation 带有目标的 `session_id`;`new_session` invocation 在其 Session 存在之前为 null。 + - 超出 1–100 的 `limit` 会被截断,无法解析的 `after` 返回 404。 + - 创建时校验 schema、Core 的 Session 规则以及目标 Session 是否存在;目标中保存的引用在每次 invocation 时解析。 + - invocation 保留到 Automation 被删除。目标 Session 被删除后,每次投递都会失败。 - 当一个请求存在多项故障时的错误顺序,以及错误处理、默认值和载荷限制的总体一致性。 - Subagent 的函数调用:Core 将其作为运行该子级的根 Turn 的待执行操作,`turn_id` 为该 Turn,并把子级的 `function_call` Item 以 `agent.session.turn.item.added` 发布一次,`turn_id` 为子级 Turn ID,`output_index` 为 null;结果可以指定其中任一 Turn。该 Turn 为 `waiting`、Session 为 `requires_action`,根输出可继续,`call_id` 等于该子级 `function_call` Item 的 `call_id`。Claude Code 在根 Turn 收敛时才发布子级的 Item,晚于该待执行操作。 - 官方服务是否发出 Environment `pending` 以及何时发出:Core 在 hosted Environment 初始化开始时发出,而仅有的一个官方样本只显示了创建约 29 秒后的 `ready`。 diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index 1a0e27889..82c472b57 100644 --- a/contracts/agents-api/zh/wire-semantics.md +++ b/contracts/agents-api/zh/wire-semantics.md @@ -25,7 +25,7 @@ source_hash: 73dcaa859ac6e54b4b51950922b8cb781daff8e159e718dddde363a8e089168a ### 不支持的操作 {#unsupported-operations} -Automations 路由(共 10 个)、`POST` 和 `GET /agents/environments`,以及 `GET /agents/sessions/{session_id}/traces` 都已注册,因此与其他固定版本路由一样先通过 Beta 和身份验证检查,然后返回 404、代码 `unsupported_operation`、消息 "This API operation is not supported."。Environment 只存在于 Session 内部([归属](environments.md#resources-and-states)),Core 没有 Automations 或 trace 存储。 +`POST` 和 `GET /agents/environments`,以及 `GET /agents/sessions/{session_id}/traces` 都已注册,因此与其他固定版本路由一样先通过 Beta 和身份验证检查,然后返回 404、代码 `unsupported_operation`、消息 "This API operation is not supported."。Environment 只存在于 Session 内部([归属](environments.md#resources-and-states)),Core 没有 trace 存储。 创建 Agent、Vault、Credential、Environment 模板、Environment 文件或 Session 均返回 201,流式 Session 创建也是如此。对 Agent 或 Environment 模板发送空更新正文会推进 `updated_at`,但不会更改其他内容;时间戳精确到秒。 @@ -61,7 +61,7 @@ Beta 路由要求 `OpenAI-Beta` 标头中恰好有一个值,且该值必须等 | 顺序 | 情况 | 响应 | | --- | --- | --- | | 1 | `Content-Type` 缺失、不是 JSON 或格式错误,包括无正文的 `POST`。`application/json` 和 `application/*+json` 不区分大小写,并允许附带参数 | "expected request with Content-Type: application/json" | -| 2 | 正文超过路由限制:Session 创建和 Environment 模板为 16 MiB,文件创建采用 [Environment file](environment-files.md) 的限制,其他路由为 1 MiB | 413,代码 `request_too_large` | +| 2 | 正文超过路由限制:Session 创建、Environment 模板以及 Automation 创建和更新为 16 MiB,文件创建采用 [Environment file](environment-files.md) 的限制,其他路由为 1 MiB | 413,代码 `request_too_large` | | 3 | 无效 UTF-8 | "Invalid body: encountered a unicode decode error when parsing this JSON value. Please check the value to ensure it is valid unicode." | | 4 | JSON 格式错误、尾随数据、两个值、字节顺序标记、仅含空白字符的正文,或形成孤立 UTF-16 代理项的转义 | "Invalid body: failed to parse JSON value. Please check the value to ensure it is valid JSON. (Common errors include trailing commas, missing closing brackets, missing quotation marks, etc.)" | | 5 | 一个对象内任意深度出现重复键 | `"Invalid body: duplicate JSON key '' at ''. Duplicate JSON keys are not supported."` 路径使用 `.` 连接对象键并省略数组索引,例如 `metadata.k` 或 `tools.type`。键在反转义后按大小写敏感方式比较。报告文档顺序中首次出现的重复键。 | @@ -125,7 +125,7 @@ Vault 和 Credential 列表接受标量 `status`、`status[]` 条目或两者, | 列表 | 默认值 | 接受范围 | 其他值 | | --- | --- | --- | --- | -| Agents、Sessions、Items、Turn Items、Environment 模板、Vaults、Credentials、Subagent Items、Subagent Turn Items | 20 | 1–100 | 0 变为 1;大于 100 的值变为 100 | +| Agents、Sessions、Items、Turn Items、Environment 模板、Automations、Automation invocations、Vaults、Credentials、Subagent Items、Subagent Turn Items | 20 | 1–100 | 0 变为 1;大于 100 的值变为 100 | | Turns、Subagents、Subagent Turns、Artifacts | 20 | 1–100 | 400 `invalid_request_error`,"limit must be between 1 and 100" | | Skills、Skill 版本 | 20 | 0–100 | 0 返回空页面,其 `has_more` 表示游标后是否还有资源。负数:400 `integer_below_min_value`,param 为 `limit`。大于 100:400 `integer_above_max_value`,param 为 `limit` | | Files | 10000 | 1–10000 | 400,code 为 null,"limit must be between 1 and 10000." | diff --git a/docs.json b/docs.json index d6809997c..c603f4e10 100644 --- a/docs.json +++ b/docs.json @@ -61,6 +61,7 @@ "contracts/agents-api/subagents", "contracts/agents-api/environments", "contracts/agents-api/environment-files", + "contracts/agents-api/automations", "contracts/agents-api/source-files", "contracts/agents-api/vaults" ] diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index 7ff95356c..9c1c8cb6b 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -51,6 +51,7 @@ On a shared host, `-H @<(printf 'Authorization: Bearer %s\n' "$OPENAI_API_KEY")` | [Files](#files) | `/files`, `/agents/environments/{id}/files`, `/agents/sessions/{id}/artifacts` | Uploads, workspace files and outputs | | [Skills](#skills) | `/skills` | Versioned capability bundles | | [Environment Templates](#environment-templates) | `/agents/environments/templates` | Reusable workspace setup | +| [Automations](#automations) | `/agents/automations` | Scheduled Session creation or instructions | | [Vaults](#vaults) | `/vaults` | Write-only credentials for MCP servers | | Subagents | `/agents/sessions/{id}/subagents` | Read-only child work; see [Subagents](../../contracts/agents-api/subagents.md) | @@ -65,6 +66,7 @@ Core has the routes of the pinned SDK, listed in [upstream-routes.json](https:// | Stop the current Turn, or recover after a lost response | [Cancel](#cancel), [idempotency](#idempotency) | | Call your own code from the agent | [Function tools](#function-tools) | | Give the agent Skills, packages, files and setup commands | [Skills](#skills), [Environment Templates](#environment-templates) | +| Start a Session or send instructions on a schedule | [Automations](#automations) | | Connect an MCP server with credentials | [Vaults](#vaults) and [Execution tools](../../contracts/agents-api/execution-tools.md) | | Use Skill or Plugin directories on your own machine | [Local capability directories](../getting-started/self-hosted.md#local-capability-directories) | | Put files in the workspace, or download what the agent wrote | [Files](#files) | @@ -557,6 +559,28 @@ template = client.beta.agents.environments.templates.create( A Session freezes the template when it starts. Details: [Environment Templates](../../contracts/agents-api/environments.md#templates). +## Automations + +An Automation saves a schedule and a target. A `periodic` Automation fires on a five-field cron `expression` in an IANA `timezone`; a `once` Automation fires at `run_at`. The target either creates a new Session (`new_session`, the body of Session create) or sends `instructions` to an existing one (`existing_session`): + +```python +automation = client.beta.agents.automations.create( + type="periodic", + name="Weekday report", + expression="0 9 * * 1-5", + timezone="Asia/Shanghai", + target={ + "type": "new_session", + "agent_id": agent.id, + "environment": {"type": "none"}, + "input": [{"role": "user", "content": [{"type": "input_text", "text": "Write the daily report."}]}], + }, +) +invocation = client.beta.agents.automations.invocations.create(automation.id) # deliver once now +``` + +Pause, resume and delete stop or end the schedule without touching Sessions. Each delivery is an invocation record whose `status` moves from `queued` to `completed` or `failed`; `session_id` names the Session it reached. Details and limits: [Automations](../../contracts/agents-api/automations.md). + ## Vaults Vaults hold credentials for HTTP MCP servers: a `static_bearer` token or an `mcp_oauth` token with optional refresh. Tokens are write-only. diff --git a/docs/zh/api/public-agent-api.md b/docs/zh/api/public-agent-api.md index 045faa3d1..e88ee4d56 100644 --- a/docs/zh/api/public-agent-api.md +++ b/docs/zh/api/public-agent-api.md @@ -1,7 +1,7 @@ --- title: "Agents API 指南" source: docs/api/public-agent-api.md -source_hash: 8c09bb8ae2cc51057298e4f6524e2145d897fcaaa7155a15c233a5267fe2768b +source_hash: a54b7abdac172ce02ed09ad501f33eff155278236a188a4897117eca35fb4055 --- Core 在 `/v1` 提供 OpenAI Agents API。本指南用官方 OpenAI SDK 和普通 HTTP 两种方式演示每项常见任务,并说明 Core 与 OpenAI 的差异。如果尚未启动过 Session,请先阅读[运行第一个 Session](../getting-started/quickstart.md)。 @@ -53,6 +53,7 @@ agents /agents | [Files](#files) | `/files`、`/agents/environments/{id}/files`、`/agents/sessions/{id}/artifacts` | 上传内容、工作区文件和输出 | | [Skills](#skills) | `/skills` | 带版本的能力捆绑包 | | [Environment 模板](#environment-templates) | `/agents/environments/templates` | 可复用的工作区设置 | +| [Automations](#automations) | `/agents/automations` | 按计划创建 Session 或发送指令 | | [Vaults](#vaults) | `/vaults` | MCP 服务器的只写凭据 | | Subagents | `/agents/sessions/{id}/subagents` | 只读的子任务;请参阅 [子 Agent](../../../contracts/agents-api/zh/subagents.md) | @@ -67,6 +68,7 @@ Core 的路由与固定版本 SDK 中的路由一致,完整列表见 [upstream | 停止当前 Turn,或在响应丢失后恢复 | [取消](#cancel)、[幂等性](#idempotency) | | 从 Agent 调用你自己的代码 | [函数工具](#function-tools) | | 为 Agent 提供 Skills、软件包、文件和设置命令 | [Skills](#skills)、[Environment 模板](#environment-templates) | +| 按计划启动 Session 或发送指令 | [Automations](#automations) | | 连接需要凭据的 MCP 服务器 | [Vaults](#vaults) 和 [执行工具](../../../contracts/agents-api/zh/execution-tools.md) | | 在自己的计算机上使用 Skill 或 Plugin 目录 | [本地能力目录](../getting-started/self-hosted.md#local-capability-directories) | | 将文件放入工作区,或下载 Agent 生成的内容 | [Files](#files) | @@ -559,6 +561,28 @@ template = client.beta.agents.environments.templates.create( Session 启动时会冻结模板。详细信息请参阅 [Environment 模板](../../../contracts/agents-api/zh/environments.md#templates)。 +## Automations {#automations} + +Automation 保存一个计划和一个目标。`periodic` Automation 按 IANA `timezone` 中的五字段 cron `expression` 触发;`once` Automation 在 `run_at` 触发。目标要么创建新 Session(`new_session`,即 Session 创建的请求体),要么向已有 Session 发送 `instructions`(`existing_session`): + +```python +automation = client.beta.agents.automations.create( + type="periodic", + name="Weekday report", + expression="0 9 * * 1-5", + timezone="Asia/Shanghai", + target={ + "type": "new_session", + "agent_id": agent.id, + "environment": {"type": "none"}, + "input": [{"role": "user", "content": [{"type": "input_text", "text": "Write the daily report."}]}], + }, +) +invocation = client.beta.agents.automations.invocations.create(automation.id) # deliver once now +``` + +暂停、恢复和删除只会停止或结束计划,不影响 Session。每次投递都是一条 invocation 记录,其 `status` 从 `queued` 变为 `completed` 或 `failed`;`session_id` 是它到达的 Session。详细信息和限制:[Automations](../../../contracts/agents-api/zh/automations.md)。 + ## Vaults {#vaults} Vaults 保存 HTTP MCP 服务器的凭据:`static_bearer` 令牌,或支持可选刷新的 `mcp_oauth` 令牌。令牌为只写字段。 diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index cb54c39ea..5ba54dff2 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -29,6 +29,7 @@ Adapters' execution repositories require a `*pgunit.Lease` at construction, and Domain owners, each with its PostgreSQL adapter under `internal/persistence/postgres`: - `agents` (`agentpg`): saved Agents, their configuration merge and bounds, and the encrypted model-provider bundle bound to each Agent. +- `automations` (`automationpg`): Automations, their schedules and invocation records. `automations` owns the cron grammar and the pure decisions (an occurrence, pause, resume, update); `automationpg` seals each target request with the credential key, bound to the tenant and the Automation, and copies the ciphertext unopened into each queued invocation until it settles. The `automation_delivery` Periodic job (every second) records at most 100 due occurrences in one transaction (`FOR UPDATE SKIP LOCKED` and a unique scheduled time keep a replica from recording one twice), then delivers at most 16 queued invocations at a time, each under 5 s, through `Handler.createSession` for `new_session` and `executionInputs` with `Execution.InputAdmission.SubmitInputs` for `existing_session`, using the invocation ID as the `Idempotency-Key`. The owning Project's principal comes from its `projects` and `execution_project_scopes` rows. A temporary cause (execution unavailable, a sandbox reset, a pending input or Turn conflict, the delivery timeout) leaves the invocation queued; any other rejection settles it `failed` with the error the public route would return. These constants are not settings. - `files` (`filepg`): source Files. - `projects` (`projectpg`): Projects, Project API keys and key resolution for authentication. `projects` validates Project and key names by rune count, separately from the byte limits on node names, and decides key issuance over the share-locked Project so an archive and an issuance never both commit. - `vaults` (`vaultpg`): Vaults and Credentials, the encryption of Credential secrets, OAuth access-token refresh, and the MCP credential selection that Session creation freezes and the Dispatcher's `Credentials` resolves into a bearer token. From 84d8f2c5e7ecd11abc03bb34099e165f0cde69a7 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 05:03:19 +0000 Subject: [PATCH 4/7] Fix Automation targets, recording and delivery Create runs the checks Session create runs (planSession), saves the canonical Session ID, and projects tools, reasoning and self-hosted directories as the pinned resource requires. Each due Automation records in its own transaction, an unopenable sealed target settles failed, and delivery orders by last attempt. A repeated wall-clock minute east of UTC fires at its first instant. Co-Authored-By: Claude Sonnet 5.5 --- contracts/agents-api/go-bindings.json | 5 +- contracts/agents-api/v1/official.gen.go | 8 +- scripts/generate-public-api.py | 2 +- services/core/cmd/server/http_routes_test.go | 2 +- services/core/cmd/server/main.go | 5 +- .../core/internal/api/automation_delivery.go | 48 ++---- services/core/internal/api/automations.go | 153 +++++++++--------- services/core/internal/api/dependencies.go | 27 +--- .../core/internal/api/dependencies_test.go | 2 +- services/core/internal/api/handler.go | 76 +-------- .../core/internal/api/official_shapes.gen.go | 4 +- services/core/internal/api/session_plan.go | 79 +++++++++ .../core/internal/api/session_response.go | 35 ++-- .../core/internal/automations/automation.go | 6 +- services/core/internal/automations/cron.go | 19 ++- .../core/internal/automations/cron_test.go | 1 + services/core/internal/automations/service.go | 42 ++--- .../core/internal/db/queries/automations.sql | 35 ++-- .../core/internal/db/sqlc/automations.sql.go | 57 +++---- services/core/internal/db/sqlc/models.go | 1 + .../postgres/automationpg/store.go | 106 +++++++----- .../core/migrations/000012_automations.sql | 1 + .../integration/automations_public_test.go | 2 +- .../integration/automations_store_test.go | 57 ++++++- services/core/tests/official_automations.py | 20 ++- 25 files changed, 442 insertions(+), 351 deletions(-) create mode 100644 services/core/internal/api/session_plan.go diff --git a/contracts/agents-api/go-bindings.json b/contracts/agents-api/go-bindings.json index 44a52eef8..605695b91 100644 --- a/contracts/agents-api/go-bindings.json +++ b/contracts/agents-api/go-bindings.json @@ -32,7 +32,7 @@ "AutomationAgentConfig": { "sources": ["#/components/schemas/AutomationAgentConfigResource"], "fields": { - "reasoning": {"type": "Reasoning"} + "reasoning": {"type": "ReasoningResource"} }, "order": ["model", "reasoning", "text", "service_tier", "instructions", "tools", "multi_agent", "compaction", "experimental"] }, @@ -349,6 +349,9 @@ "Reasoning": { "sources": ["#/components/schemas/ReasoningParam"] }, + "ReasoningResource": { + "sources": ["#/components/schemas/ReasoningResource"] + }, "ReasoningItem": { "sources": ["#/components/schemas/ReasoningItemResource"], "order": ["id", "turn_id", "type", "status", "summary"] diff --git a/contracts/agents-api/v1/official.gen.go b/contracts/agents-api/v1/official.gen.go index cfc6d7467..0fb796a9f 100644 --- a/contracts/agents-api/v1/official.gen.go +++ b/contracts/agents-api/v1/official.gen.go @@ -130,7 +130,7 @@ func (value Automation) MarshalJSON() ([]byte, error) { // AutomationAgentConfig projects AutomationAgentConfigResource. type AutomationAgentConfig struct { Model string `json:"model" binding:"required"` - Reasoning Reasoning `json:"reasoning" binding:"required"` + Reasoning ReasoningResource `json:"reasoning" binding:"required"` Text TextConfig `json:"text" binding:"required"` ServiceTier string `json:"service_tier" binding:"required" enums:"auto,default,flex,priority,fast,ultrafast"` Instructions *string `json:"instructions" binding:"required" extensions:"x-nullable"` @@ -817,6 +817,12 @@ type ReasoningItem struct { Summary []SummaryText `json:"summary" binding:"required"` } +// ReasoningResource projects ReasoningResource. +type ReasoningResource struct { + Effort *string `json:"effort" binding:"required" extensions:"x-nullable" enums:"none,minimal,low,medium,high,xhigh,max"` + Summary *string `json:"summary" binding:"required" extensions:"x-nullable" enums:"concise,detailed,auto"` +} + // RequiredAction projects SessionRequiredActionResource. type RequiredAction struct { Type string `json:"type" binding:"required" enums:"computer_use_approval_request,function_call,environment_connection"` diff --git a/scripts/generate-public-api.py b/scripts/generate-public-api.py index 62f6a1bc7..76d70b9c9 100644 --- a/scripts/generate-public-api.py +++ b/scripts/generate-public-api.py @@ -233,7 +233,7 @@ def go_shape(document, schema, required=False): elif kind == 'array': fields = ['kind: arrayValue', 'items: &' + go_shape(document, value['items'])] elif kind == 'integer': - fields = ['kind: integerValue', f'minimum: {value.get("minimum", 0)}'] + fields = ['kind: integerValue'] + ([f'minimum: {value["minimum"]}'] if 'minimum' in value else []) else: fields = [{'string': 'kind: stringValue', 'boolean': 'kind: booleanValue'}[kind]] if required: diff --git a/services/core/cmd/server/http_routes_test.go b/services/core/cmd/server/http_routes_test.go index 96192f9a9..1e08b2e5b 100644 --- a/services/core/cmd/server/http_routes_test.go +++ b/services/core/cmd/server/http_routes_test.go @@ -5,7 +5,6 @@ import ( "context" "crypto/sha256" "fmt" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "io" "net" "net/http" @@ -17,6 +16,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index 5321034da..bf00fa67c 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -383,13 +383,10 @@ func run(config processconfig.Config) error { ConfigurationDiscovery: managedNodes.setup, }, } - apiHandler, err := api.NewHandler(deps) + apiHandler, deliverAutomations, err := api.NewHandlerWithDelivery(deps) if err != nil { return err } - if deliverAutomations, err = api.NewAutomationDelivery(deps); err != nil { - return err - } metricsCtx, cancelMetrics := context.WithCancel(ctx) metricsDone := make(chan struct{}) go func() { defer close(metricsDone); metrics.Run(metricsCtx) }() diff --git a/services/core/internal/api/automation_delivery.go b/services/core/internal/api/automation_delivery.go index 1ea056753..f920556ca 100644 --- a/services/core/internal/api/automation_delivery.go +++ b/services/core/internal/api/automation_delivery.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "net/http" + "net/http/httptest" "sync" "sync/atomic" "time" @@ -29,15 +30,16 @@ const ( // storage failures as failed; an invocation that failed for good is a settled // record, not a job failure. func (h *Handler) deliverAutomations(ctx context.Context) (*int64, int64, error) { - recorded, err := h.Automations.Record(ctx) + var settled, failed atomic.Int64 + recorded, err := h.Automations.Record(ctx, time.Now()) if err != nil { - return nil, 1, err + log.Ctx(ctx).Error("Automation occurrences could not be recorded", "error", err) + failed.Add(1) } deliveries, err := h.Automations.Deliverable(ctx, automationBatch) if err != nil { - return nil, 1, err + return nil, failed.Load() + 1, err } - var settled, failed atomic.Int64 var wg sync.WaitGroup for _, d := range deliveries { wg.Add(1) @@ -68,21 +70,18 @@ func (h *Handler) deliverAutomations(ctx context.Context) (*int64, int64, error) func (h *Handler) deliverAutomation(ctx context.Context, d automations.Delivery) (automations.Outcome, bool) { ctx, cancel := context.WithTimeout(context.WithValue(ctx, principalContextKey{}, d.Scope), automationDeliveryTimeout) defer cancel() - response := &capturedResponse{header: http.Header{}} + response := httptest.NewRecorder() outcome := automations.Outcome{InvocationID: d.InvocationID, Status: "completed"} - request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/v1/agents/sessions", bytes.NewReader(d.Request)) - if err != nil { - return outcome, false - } + request, _ := http.NewRequestWithContext(ctx, http.MethodPost, "/v1/agents/sessions", bytes.NewReader(d.Request)) if d.TargetType == "new_session" { request.Header.Set("Content-Type", "application/json") request.Header.Set("Idempotency-Key", d.InvocationID) h.createSession(response, request) - if response.status == http.StatusCreated { + if response.Code == http.StatusCreated { var session struct { ID string `json:"id"` } - if json.Unmarshal(response.body.Bytes(), &session) != nil || session.ID == "" { + if json.Unmarshal(response.Body.Bytes(), &session) != nil || session.ID == "" { return outcome, false } outcome.SessionID = session.ID @@ -102,7 +101,7 @@ func (h *Handler) deliverAutomation(ctx context.Context, d automations.Delivery) outcome.SessionID = target.SessionID return outcome, true } - if errors.Is(err, sessions.ErrInputPending) || errors.Is(err, sessions.ErrTurnConflict) || errors.Is(err, execution.ErrExecutionUnavailable) || ctx.Err() != nil { + if errors.Is(err, sessions.ErrTurnConflict) || errors.Is(err, execution.ErrExecutionUnavailable) || ctx.Err() != nil { return outcome, false } writeInputError(response, request, err) @@ -114,12 +113,12 @@ func (h *Handler) deliverAutomation(ctx context.Context, d automations.Delivery) Message string `json:"message"` } `json:"error"` } - _ = json.Unmarshal(response.body.Bytes(), &envelope) + _ = json.Unmarshal(response.Body.Bytes(), &envelope) code := envelope.Error.Type if envelope.Error.Code != nil { code = *envelope.Error.Code } - if response.status == http.StatusServiceUnavailable && (code == "execution_unavailable" || code == "sandbox_reset_in_progress") || ctx.Err() != nil { + if response.Code == http.StatusServiceUnavailable && (code == "execution_unavailable" || code == "sandbox_reset_in_progress") || ctx.Err() != nil { return outcome, false } outcome.Status, outcome.Code, outcome.Message = "failed", code, envelope.Error.Message @@ -128,24 +127,3 @@ func (h *Handler) deliverAutomation(ctx context.Context, d automations.Delivery) } return outcome, true } - -// capturedResponse records a response written by a handler the delivery calls -// in process. -type capturedResponse struct { - header http.Header - status int - body bytes.Buffer -} - -func (c *capturedResponse) Header() http.Header { return c.header } - -func (c *capturedResponse) WriteHeader(status int) { - if c.status == 0 { - c.status = status - } -} - -func (c *capturedResponse) Write(p []byte) (int, error) { - c.WriteHeader(http.StatusOK) - return c.body.Write(p) -} diff --git a/services/core/internal/api/automations.go b/services/core/internal/api/automations.go index a5f94bf61..9bbd6534c 100644 --- a/services/core/internal/api/automations.go +++ b/services/core/internal/api/automations.go @@ -23,7 +23,7 @@ type Automations interface { Resume(ctx context.Context, tenantID, id string) (automations.Automation, error) Delete(ctx context.Context, tenantID, id string) (string, error) Invoke(ctx context.Context, tenantID, id string) (automations.Invocation, error) - Record(context.Context) (int, error) + Record(context.Context, time.Time) (int, error) Deliverable(ctx context.Context, limit int) ([]automations.Delivery, error) Settle(context.Context, automations.Outcome) error } @@ -49,6 +49,8 @@ func (h *Handler) registerAutomationRoutes(r chi.Router) { type automationTarget struct { kind string projection, contents json.RawMessage + // report writes the response for the error that came with the target. + report func(http.ResponseWriter, *http.Request, error) } func (h *Handler) createAutomation(w http.ResponseWriter, r *http.Request) { @@ -81,19 +83,9 @@ func (h *Handler) createAutomation(w http.ResponseWriter, r *http.Request) { writeFieldError(w, err) return } - if body.Type == automations.Periodic { - if err := boundedText("expression", body.Expression, 256); err != nil { - writeFieldError(w, err) - return - } - if err := boundedText("timezone", body.Timezone, 128); err != nil { - writeFieldError(w, err) - return - } - } target, err := h.automationTarget(r, body.Target) if err != nil { - h.writeTargetError(w, r, err) + target.report(w, r, err) return } a := automations.Automation{TenantID: tenantID(r), Type: body.Type, Name: body.Name, Metadata: metadataOrEmpty(body.Metadata), Expression: body.Expression, Timezone: body.Timezone, TargetType: target.kind, Target: target.projection, Contents: target.contents} @@ -124,28 +116,10 @@ func (h *Handler) updateAutomation(w http.ResponseWriter, r *http.Request) { } _ = json.Unmarshal(raw, &body) patch := automations.Patch{Name: body.Name, Metadata: body.Metadata, Expression: body.Expression, Timezone: body.Timezone} - if body.Name != nil { - if err := automationFields(body.Name, body.Metadata); err != nil { - writeFieldError(w, err) - return - } - } else if err := automationFields(nil, body.Metadata); err != nil { + if err := automationFields(body.Name, body.Metadata); err != nil { writeFieldError(w, err) return } - for param, value := range map[string]*string{"expression": body.Expression, "timezone": body.Timezone} { - if value == nil { - continue - } - limit := 256 - if param == "timezone" { - limit = 128 - } - if err := boundedText(param, *value, limit); err != nil { - writeFieldError(w, err) - return - } - } if body.RunAt != nil { runAt := time.Unix(*body.RunAt, 0) patch.RunAt = &runAt @@ -153,7 +127,7 @@ func (h *Handler) updateAutomation(w http.ResponseWriter, r *http.Request) { if body.Target != nil { target, err := h.automationTarget(r, body.Target) if err != nil { - h.writeTargetError(w, r, err) + target.report(w, r, err) return } patch.TargetType, patch.Target, patch.Contents = target.kind, target.projection, target.contents @@ -180,7 +154,7 @@ func (h *Handler) listAutomations(w http.ResponseWriter, r *http.Request) { if !ok { return } - page, err := h.AutomationsReader.List(r.Context(), tenantID(r), automations.ListQuery{After: options.after, Limit: options.limit, Ascending: options.ascending, SessionID: r.URL.Query().Get("session_id")}) + page, err := h.AutomationsReader.List(r.Context(), tenantID(r), automations.ListQuery{After: options.after, Limit: options.limit, Ascending: options.ascending, SessionID: sessionFilter(r)}) if err != nil { writeAutomationsError(w, r, err) return @@ -259,12 +233,13 @@ func (h *Handler) listAutomationInvocations(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusOK, response) } -// writeTargetError reports a target that failed validation: a field error, or -// an error of the Session create or lookup rules the target reuses. -func (h *Handler) writeTargetError(w http.ResponseWriter, r *http.Request, err error) { - if !writeFieldError(w, err) { - writeSessionsError(w, r, err) +// sessionFilter reads the session_id filter. A supplied empty value matches no +// Automation, as an empty agent_id matches no Session. +func sessionFilter(r *http.Request) *string { + if values, present := r.URL.Query()["session_id"]; present { + return &values[0] } + return nil } // automationFields checks the name and metadata of a request. A nil name is @@ -285,6 +260,27 @@ func boundedText(param, value string, limit int) error { return nil } +// prefixTarget names a Session create field error under the target. +func prefixTarget(err error) error { + var field *fieldError + if !errors.As(err, &field) { + return err + } + param := "target" + if field.param != "" { + param += "." + field.param + } + return &fieldError{param: param, message: field.message, code: field.code} +} + +// writeTargetError reports a target that failed validation: a field error, or +// an error of the Session lookup rules the target reuses. +func writeTargetError(w http.ResponseWriter, r *http.Request, err error) { + if !writeFieldError(w, err) { + writeSessionsError(w, r, err) + } +} + // automationTarget validates a target and builds what is saved. A new_session // target is validated as the Session create body it replays; an // existing_session target must name a Session and hold deliverable text. @@ -294,37 +290,39 @@ func (h *Handler) automationTarget(r *http.Request, raw json.RawMessage) (automa var kind string _ = json.Unmarshal(fields["type"], &kind) if kind == "existing_session" { - return h.existingSessionTarget(r, raw, fields) + return h.existingSessionTarget(r, raw) } if kind != "new_session" && fields["type"] != nil { if err := checkValue("target.type", fields["type"], shape{kind: enumValue, values: []string{"new_session", "existing_session"}}); err != nil { - return automationTarget{}, err + return automationTarget{report: writeTargetError}, err } } - return h.newSessionTarget(raw, fields) + return h.newSessionTarget(r, raw, fields) } -func (h *Handler) existingSessionTarget(r *http.Request, raw json.RawMessage, fields map[string]json.RawMessage) (automationTarget, error) { +func (h *Handler) existingSessionTarget(r *http.Request, raw json.RawMessage) (automationTarget, error) { + fail := automationTarget{report: writeTargetError} if err := checkValue("target", raw, automationTargetParamExistingSession); err != nil { - return automationTarget{}, err + return fail, err } var target struct { SessionID string `json:"session_id"` Instructions string `json:"instructions"` } _ = json.Unmarshal(raw, &target) - if len(target.Instructions) > maxInstructions { - return automationTarget{}, &fieldError{param: "target.instructions", message: "instructions must not exceed 1 MiB."} + if utf8.RuneCountInString(target.Instructions) > maxInstructions { + return fail, &fieldError{param: "target.instructions", message: "instructions must not exceed 1048576 characters."} } if _, err := executionInputs([]json.RawMessage{instructionsEvent(target.Instructions)}); err != nil { - return automationTarget{}, &fieldError{param: "target.instructions", message: "instructions must be non-empty text this service can store."} + return fail, &fieldError{param: "target.instructions", message: "instructions must be non-empty text this service can store."} } - if _, err := h.SessionsReader.GetSession(r.Context(), tenantID(r), target.SessionID); err != nil { - return automationTarget{}, err + session, err := h.SessionsReader.GetSession(r.Context(), tenantID(r), target.SessionID) + if err != nil { + return fail, err } - delete(fields, "type") - contents, _ := json.Marshal(fields) - projection, _ := json.Marshal(v1.AutomationTarget{Type: "existing_session", SessionID: &target.SessionID, Instructions: &target.Instructions}) + // The saved target names the Session by its canonical id, whatever form the request used. + contents, _ := json.Marshal(map[string]string{"session_id": session.ID, "instructions": target.Instructions}) + projection, _ := json.Marshal(v1.AutomationTarget{Type: "existing_session", SessionID: &session.ID, Instructions: &target.Instructions}) return automationTarget{kind: "existing_session", projection: projection, contents: contents}, nil } @@ -335,61 +333,55 @@ func instructionsEvent(instructions string) json.RawMessage { return event } -func (h *Handler) newSessionTarget(raw json.RawMessage, fields map[string]json.RawMessage) (automationTarget, error) { +func (h *Handler) newSessionTarget(r *http.Request, raw json.RawMessage, fields map[string]json.RawMessage) (automationTarget, error) { + fail := automationTarget{report: writeTargetError} if err := checkValue("target", raw, automationTargetParamNewSession); err != nil { - return automationTarget{}, err + return fail, err } _, inline := fields["agent"] _, saved := fields["agent_id"] if inline == saved { - return automationTarget{}, &fieldError{param: "target", message: "Provide exactly one of agent and agent_id."} + return fail, &fieldError{param: "target", message: "Provide exactly one of agent and agent_id."} } if inline { var agent map[string]json.RawMessage _ = json.Unmarshal(fields["agent"], &agent) if agent["x_agents_core"] != nil { - return automationTarget{}, &fieldError{param: "target.agent.x_agents_core", message: "x_agents_core is not supported in an automation target."} - } - if agent["model"] == nil { - return automationTarget{}, &fieldError{param: "target.agent.model", message: "agent.model is required in an automation target."} + return fail, &fieldError{param: "target.agent.x_agents_core", message: "x_agents_core is not supported in an automation target."} } } delete(fields, "type") contents, _ := json.Marshal(fields) input, _, err := parseSessionRequest(contents) if err != nil { - var field *fieldError - if errors.As(err, &field) { - param := "target" - if field.param != "" { - param += "." + field.param - } - err = &fieldError{param: param, message: field.message, code: field.code} - } - return automationTarget{}, err + return fail, prefixTarget(err) } target := v1.AutomationTarget{Type: "new_session", AgentID: input.AgentID, Environment: automationEnvironment(input), VaultIDs: input.VaultIDs, Metadata: input.Metadata} _ = json.Unmarshal(fields["input"], &target.Input) for i := range target.Input { target.Input[i].Type = "message" } + plan, report, err := h.planSession(r.Context(), tenantID(r), "", &input) + if err != nil { + return automationTarget{report: report}, prefixTarget(err) + } if inline { - agent, err := resolveSessionAgent(input, nil) + var cfg configuration + _ = json.Unmarshal(plan.configuration, &cfg) + agent := cfg.Agent + tools, err := resourceTools(cfg) if err != nil { - return automationTarget{}, err + return fail, err } - config := v1.AutomationAgentConfig{Model: agent.Model, Reasoning: agent.Reasoning, Text: agent.Text, ServiceTier: agent.ServiceTier, Instructions: agent.Instructions, Tools: make([]json.RawMessage, 0, len(agent.Tools)), MultiAgent: agent.MultiAgent, Compaction: agent.Compaction, Experimental: agent.Experimental} - for _, tool := range agent.Tools { - config.Tools = append(config.Tools, withoutMCPHeaders(tool)) - } - target.Agent = &config + target.Agent = &v1.AutomationAgentConfig{Model: agent.Model, Reasoning: v1.ReasoningResource{Effort: agent.Reasoning.Effort, Summary: agent.Reasoning.Summary}, Text: agent.Text, ServiceTier: agent.ServiceTier, Instructions: agent.Instructions, Tools: tools, MultiAgent: agent.MultiAgent, Compaction: agent.Compaction, Experimental: agent.Experimental} } projection, err := json.Marshal(target) return automationTarget{kind: "new_session", projection: projection, contents: contents}, err } // automationEnvironment projects the environment the request supplied. A member -// the request left out stays nil, which the resource shows as null: inherit. +// the request left out stays nil, which the resource shows as null: inherit. A +// self_hosted environment always lists its capability directories. func automationEnvironment(input sessionRequest) *v1.AutomationEnvironment { env := input.Environment result := &v1.AutomationEnvironment{Type: env.Type} @@ -410,16 +402,19 @@ func automationEnvironment(input sessionRequest) *v1.AutomationEnvironment { result.Desktop = json.RawMessage(`{"enabled":false}`) } if has("capability_directories") { - result.CapabilityDirectories = append([]string{}, env.CapabilityDirectories...) + result.CapabilityDirectories = env.CapabilityDirectories + } + if result.CapabilityDirectories == nil && env.Type == "self_hosted" { + result.CapabilityDirectories = []string{} } if has("skills") { - result.Skills = append([]json.RawMessage{}, env.Skills...) + result.Skills = env.Skills } if has("plugins") { - result.Plugins = append([]json.RawMessage{}, env.Plugins...) + result.Plugins = env.Plugins } if has("files") { - result.Files = append([]json.RawMessage{}, env.Files...) + result.Files = env.Files } if env.WorkspaceDirectory != "" { result.WorkspaceDirectory = &env.WorkspaceDirectory diff --git a/services/core/internal/api/dependencies.go b/services/core/internal/api/dependencies.go index ff4d65fc6..98f767be7 100644 --- a/services/core/internal/api/dependencies.go +++ b/services/core/internal/api/dependencies.go @@ -106,34 +106,23 @@ type Handler struct { // NewHandler builds the HTTP handler from complete dependencies. func NewHandler(deps Dependencies) (http.Handler, error) { - h, err := newHandler(deps) - if err != nil { - return nil, err - } - return CanonicalPaths(h.routes()), nil -} - -// NewAutomationDelivery returns the pass that records due Automation -// occurrences and delivers queued invocations through the handler's Session -// create and input paths. -func NewAutomationDelivery(deps Dependencies) (func(context.Context) (*int64, int64, error), error) { - h, err := newHandler(deps) - if err != nil { - return nil, err - } - return h.deliverAutomations, nil + handler, _, err := NewHandlerWithDelivery(deps) + return handler, err } -func newHandler(deps Dependencies) (*Handler, error) { +// NewHandlerWithDelivery builds the HTTP handler and the pass that records due +// Automation occurrences and delivers queued invocations through the handler's +// Session create and input paths. +func NewHandlerWithDelivery(deps Dependencies) (http.Handler, func(context.Context) (*int64, int64, error), error) { if err := deps.validate(); err != nil { - return nil, err + return nil, nil, err } deps.Installation.Object = "core.installation" h := &Handler{Dependencies: deps, harnesses: make(map[string]bool, len(deps.Harnesses))} for _, kind := range deps.Harnesses { h.harnesses[kind] = true } - return h, nil + return CanonicalPaths(h.routes()), h.deliverAutomations, nil } func (d Dependencies) validate() error { diff --git a/services/core/internal/api/dependencies_test.go b/services/core/internal/api/dependencies_test.go index 6f09dc800..15058e2f5 100644 --- a/services/core/internal/api/dependencies_test.go +++ b/services/core/internal/api/dependencies_test.go @@ -2,11 +2,11 @@ package api import ( "context" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "net/http" "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" ) diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index bda44d301..c2e3593a1 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "net/http" "reflect" @@ -197,82 +196,21 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { if h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { return } - if input.templateID != "" { - template, err := h.EnvironmentTemplatesReader.Resolve(r.Context(), tenantID(r), input.templateID) - if err != nil { - if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { - writeEnvironmentTemplatesError(w, r, err) - } - return - } - if err := applyTemplateEnvironment(&input, template); err != nil { - if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) && !writeFieldError(w, err) { - writeSessionsError(w, r, err) - } - return - } - } - saved, inheritedProvider, err := h.sessionAgentDefaults(r.Context(), tenantID(r), input) + plan, report, err := h.planSession(r.Context(), tenantID(r), key, &input) if err != nil { if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { - writeAgentsError(w, r, err) - } - return - } - err = h.prepareSessionModelConfiguration(r.Context(), &input, saved, inheritedProvider) - var configuration json.RawMessage - if err == nil { - configuration, err = resolve(input, tenantID(r), key, saved) - } - if err == nil { - configuration, err = freezeSessionHarnessConfig(configuration, input.resolvedHarnessConfig) - } - if err == nil { - configuration, err = h.bindSessionCredentials(r.Context(), tenantID(r), configuration) - if err != nil { - if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { - writeVaultsError(w, r, err) - } - return - } - } - selectedEngine := h.Engine - var provider *v1.ModelProviderInput - var providerSource v1.ExecutionSource - var deploymentRevision uuid.UUID - if err == nil { - selectedEngine, provider, providerSource, deploymentRevision, err = h.resolveSessionExecution(r.Context(), input, inheritedProvider, configuration) - } - if err == nil { - if invalid := h.Policy.ValidateSessionConfiguration(selectedEngine, configuration); invalid != nil { - err = fmt.Errorf("Harness %s does not support the requested Agent/environment configuration: %w", selectedEngine, invalid) - } - } - if err == nil { - err = validateSessionModelConfiguration(selectedEngine, provider, configuration) - } - if err != nil { - if h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { - return - } - var required *modelProviderRequiredError - switch { - case errors.As(err, &required): - writeError(w, http.StatusBadRequest, "model_provider_required", required.message, "x_agents_core.model_provider") - case writeStoredDataError(w, r, err): - case !writeFieldError(w, err): - writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) + report(w, r, err) } return } - executionConfiguration := sessionExecutionProjection(input, saved, inheritedProvider, provider, selectedEngine, configuration) + executionConfiguration := sessionExecutionProjection(input, plan.saved, plan.inheritedProvider, plan.provider, plan.engine, plan.configuration) createInput := sessions.CreateSession{ ExecutionConfiguration: &executionConfiguration, - ModelProvider: provider, - ModelProviderSource: providerSource, - DeploymentProviderRevision: deploymentRevision, + ModelProvider: plan.provider, + ModelProviderSource: plan.source, + DeploymentProviderRevision: plan.revision, Creator: sessionCreator(r), InitialFiles: input.initialFiles, Initialization: input.initialization, - Engine: selectedEngine, IdempotencyKey: key, Metadata: input.Metadata, Configuration: configuration, InitialInputs: initialInputs, CreationRequest: creationRequest, + Engine: plan.engine, IdempotencyKey: key, Metadata: input.Metadata, Configuration: plan.configuration, InitialInputs: initialInputs, CreationRequest: creationRequest, } create := h.SessionCreation.CreateSession if len(initialInputs) > 0 || input.Environment.Type == "openai_hosted" { diff --git a/services/core/internal/api/official_shapes.gen.go b/services/core/internal/api/official_shapes.gen.go index 1d8cb5985..84579a7f5 100644 --- a/services/core/internal/api/official_shapes.gen.go +++ b/services/core/internal/api/official_shapes.gen.go @@ -569,7 +569,7 @@ var ( {"name", shape{kind: stringValue, required: true}}, {"target", shape{kind: openObject, required: true}}, {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}}}, - {"run_at", shape{kind: integerValue, minimum: 0, required: true}}, + {"run_at", shape{kind: integerValue, required: true}}, }} updateAutomationParams = shape{kind: objectValue, members: []member{ {"name", shape{kind: stringValue}}, @@ -577,7 +577,7 @@ var ( {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}}}, {"expression", shape{kind: stringValue}}, {"timezone", shape{kind: stringValue}}, - {"run_at", shape{kind: integerValue, minimum: 0}}, + {"run_at", shape{kind: integerValue}}, }} automationTargetParamNewSession = shape{kind: objectValue, members: []member{ {"type", shape{kind: enumValue, values: []string{"new_session"}, required: true}}, diff --git a/services/core/internal/api/session_plan.go b/services/core/internal/api/session_plan.go new file mode 100644 index 000000000..34663f604 --- /dev/null +++ b/services/core/internal/api/session_plan.go @@ -0,0 +1,79 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/google/uuid" +) + +// sessionPlan is a Session configuration that passed every check Session create +// runs before it creates anything. +type sessionPlan struct { + saved *v1.SavedAgent + inheritedProvider, provider *v1.ModelProviderInput + source v1.ExecutionSource + revision uuid.UUID + engine string + configuration json.RawMessage +} + +// planSession resolves the environment template and the saved Agent, freezes +// the configuration, binds its credentials and checks it against the Harness +// and the model provider. Deployment readiness, admission and leases are not +// part of it. On an error, report writes the response for the stage that failed. +func (h *Handler) planSession(ctx context.Context, tenant, key string, input *sessionRequest) (p sessionPlan, report func(http.ResponseWriter, *http.Request, error), err error) { + if input.templateID != "" { + template, err := h.EnvironmentTemplatesReader.Resolve(ctx, tenant, input.templateID) + if err != nil { + return p, writeEnvironmentTemplatesError, err + } + if err := applyTemplateEnvironment(input, template); err != nil { + return p, func(w http.ResponseWriter, r *http.Request, err error) { + if !writeFieldError(w, err) { + writeSessionsError(w, r, err) + } + }, err + } + } + if p.saved, p.inheritedProvider, err = h.sessionAgentDefaults(ctx, tenant, *input); err != nil { + return p, writeAgentsError, err + } + err = h.prepareSessionModelConfiguration(ctx, input, p.saved, p.inheritedProvider) + if err == nil { + p.configuration, err = resolve(*input, tenant, key, p.saved) + } + if err == nil { + p.configuration, err = freezeSessionHarnessConfig(p.configuration, input.resolvedHarnessConfig) + } + if err == nil { + if p.configuration, err = h.bindSessionCredentials(ctx, tenant, p.configuration); err != nil { + return p, writeVaultsError, err + } + p.engine, p.provider, p.source, p.revision, err = h.resolveSessionExecution(ctx, *input, p.inheritedProvider, p.configuration) + } + if err == nil { + if invalid := h.Policy.ValidateSessionConfiguration(p.engine, p.configuration); invalid != nil { + err = fmt.Errorf("Harness %s does not support the requested Agent/environment configuration: %w", p.engine, invalid) + } + } + if err == nil { + err = validateSessionModelConfiguration(p.engine, p.provider, p.configuration) + } + return p, writeSessionConfigurationError, err +} + +func writeSessionConfigurationError(w http.ResponseWriter, r *http.Request, err error) { + var required *modelProviderRequiredError + switch { + case errors.As(err, &required): + writeError(w, http.StatusBadRequest, "model_provider_required", required.message, "x_agents_core.model_provider") + case writeStoredDataError(w, r, err): + case !writeFieldError(w, err): + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) + } +} diff --git a/services/core/internal/api/session_response.go b/services/core/internal/api/session_response.go index f0cf6122a..c718aa0df 100644 --- a/services/core/internal/api/session_response.go +++ b/services/core/internal/api/session_response.go @@ -16,19 +16,9 @@ func sessionResponse(session sessions.Session, executorURL string) (v1.Session, if cfg.Agent.XAgentsCore != nil && session.Engine != "" { cfg.Agent.XAgentsCore = &v1.AgentsCore{Harness: session.Engine, HarnessConfig: cfg.Agent.XAgentsCore.HarnessConfig} } - // The pinned Session AgentTool resource union excludes the tool_search - // input declaration. Retain it in saved Agents and frozen execution input. - tools := make([]json.RawMessage, 0, len(cfg.Agent.Tools)) - for _, raw := range cfg.Agent.Tools { - var tool struct { - Type string `json:"type"` - } - if json.Unmarshal(raw, &tool) != nil { - return v1.Session{}, errors.New("unsupported stored tool configuration") - } - if tool.Type != "tool_search" { - tools = append(tools, projectedMCPCredential(withoutMCPHeaders(raw), cfg)) - } + tools, err := resourceTools(cfg) + if err != nil { + return v1.Session{}, err } cfg.Agent.Tools = tools environment, err := sessionEnvironment(session, cfg.Environment.Type, executorURL) @@ -155,3 +145,22 @@ func sessionEnvironment(session sessions.Session, kind, executorURL string) (v1. RemoteURL: executorURL, WorkspaceDirectory: cfg.WorkspaceDirectory, }, nil } + +// resourceTools projects the stored Agent tools onto the pinned resource union, +// which excludes the tool_search input declaration. Saved Agents and frozen +// execution input retain it. +func resourceTools(cfg configuration) ([]json.RawMessage, error) { + tools := make([]json.RawMessage, 0, len(cfg.Agent.Tools)) + for _, raw := range cfg.Agent.Tools { + var tool struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &tool) != nil { + return nil, errors.New("unsupported stored tool configuration") + } + if tool.Type != "tool_search" { + tools = append(tools, projectedMCPCredential(withoutMCPHeaders(raw), cfg)) + } + } + return tools, nil +} diff --git a/services/core/internal/automations/automation.go b/services/core/internal/automations/automation.go index 928880716..a8ee645f1 100644 --- a/services/core/internal/automations/automation.go +++ b/services/core/internal/automations/automation.go @@ -59,9 +59,9 @@ type Invocation struct { // Delivery is a queued invocation ready to deliver, with the Project that owns // the Automation and the opened target request. type Delivery struct { - InvocationID, AutomationID, TargetType string - Scope identity.Principal - Request json.RawMessage + InvocationID, TargetType string + Scope identity.Principal + Request json.RawMessage } // Outcome settles a queued invocation. Status is completed or failed. diff --git a/services/core/internal/automations/cron.go b/services/core/internal/automations/cron.go index 944fe0a44..77740009c 100644 --- a/services/core/internal/automations/cron.go +++ b/services/core/internal/automations/cron.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" ) // Schedule is a parsed five-field crontab(5) expression in a time zone. @@ -28,10 +29,10 @@ var ( // next five years. timezone is an IANA name. func ParseSchedule(expression, timezone string) (*Schedule, error) { fields := strings.Fields(expression) - if len(fields) != 5 { - return nil, &InvalidError{"expression", "expression must have five fields: minute, hour, day of month, month and day of week."} + if utf8.RuneCountInString(expression) > 256 || len(fields) != 5 { + return nil, &InvalidError{"expression", "expression must have five fields of at most 256 characters: minute, hour, day of month, month and day of week."} } - if timezone == "" || timezone == "Local" { + if timezone == "" || timezone == "Local" || utf8.RuneCountInString(timezone) > 128 { return nil, &InvalidError{"timezone", "timezone must be an IANA time zone name."} } location, err := time.LoadLocation(timezone) @@ -127,7 +128,12 @@ func (s *Schedule) Next(after time.Time) (time.Time, bool) { civil = civil.Add(time.Minute) default: t := time.Date(year, month, day, civil.Hour(), civil.Minute(), 0, 0, s.location) - if y, m, d := t.Date(); t.After(after) && y == year && m == month && d == day && t.Hour() == civil.Hour() && t.Minute() == civil.Minute() { + // Date may return either instant of a repeated minute; the earlier one is the occurrence. + _, before := t.Add(-24 * time.Hour).Zone() + if u := civil.Add(-time.Duration(before) * time.Second); u.Before(t) && s.atWall(u, civil) { + t = u + } + if s.atWall(t, civil) && t.After(after) { return t, true } civil = civil.Add(time.Minute) @@ -136,6 +142,11 @@ func (s *Schedule) Next(after time.Time) (time.Time, bool) { return time.Time{}, false } +// atWall reports whether the instant shows the wall-clock minute of civil in the Schedule's zone. +func (s *Schedule) atWall(t, civil time.Time) bool { + return t.In(s.location).Format("2006-01-02 15:04") == civil.Format("2006-01-02 15:04") +} + func (s *Schedule) dayMatches(civil time.Time) bool { day, weekday := s.days&(1<>'type' = 'existing_session' THEN (a.target->>'session_id')::uuid END, +SELECT sqlc.arg(id), a.id, sqlc.arg(scheduled_at), sqlc.arg(automation_snapshot), sqlc.arg(status)::text, sqlc.narg(skip_reason)::text, sqlc.narg(session_id), CASE WHEN sqlc.arg(status)::text = 'queued' THEN a.target_contents END FROM automations a WHERE a.id = sqlc.arg(automation_id) ON CONFLICT (automation_id, scheduled_at) DO NOTHING; @@ -80,18 +78,23 @@ ON CONFLICT (automation_id, scheduled_at) DO NOTHING; UPDATE automations SET next_run_at = sqlc.narg(next_run_at), status = CASE WHEN sqlc.arg(completed)::boolean THEN 'completed' ELSE status END WHERE id = sqlc.arg(id); -- name: DeliverableInvocations :many -SELECT * FROM ( - SELECT DISTINCT ON (i.automation_id) i.id, i.automation_id, a.tenant_id, (a.target->>'type')::text AS target_type, i.delivery, i.created_at, - p.subject_kind, p.subject_id, s.organization_id, s.project_id - FROM automation_invocations i - JOIN automations a ON a.id = i.automation_id - JOIN projects p ON p.tenant_id = a.tenant_id AND p.archived_at IS NULL - JOIN execution_project_scopes s ON s.tenant_id = a.tenant_id - WHERE i.status = 'queued' AND (i.scheduled_at IS NULL OR a.status = 'active') - ORDER BY i.automation_id, i.created_at, i.id -) oldest -ORDER BY created_at, id -LIMIT sqlc.arg(page_limit); +WITH picked AS ( + SELECT * FROM ( + SELECT DISTINCT ON (i.automation_id) i.id, i.automation_id, a.tenant_id, (a.target->>'type')::text AS target_type, i.delivery, i.created_at, i.attempted_at, + p.subject_kind, p.subject_id, s.organization_id, s.project_id + FROM automation_invocations i + JOIN automations a ON a.id = i.automation_id + JOIN projects p ON p.tenant_id = a.tenant_id AND p.archived_at IS NULL + JOIN execution_project_scopes s ON s.tenant_id = a.tenant_id + WHERE i.status = 'queued' AND (i.scheduled_at IS NULL OR a.status = 'active') + ORDER BY i.automation_id, i.created_at, i.id + ) oldest + ORDER BY attempted_at NULLS FIRST, created_at, id + LIMIT sqlc.arg(page_limit) +), marked AS ( + UPDATE automation_invocations u SET attempted_at = statement_timestamp() FROM picked WHERE u.id = picked.id +) +SELECT id, automation_id, tenant_id, target_type, delivery, subject_kind, subject_id, organization_id, project_id FROM picked; -- name: SettleInvocation :one UPDATE automation_invocations SET status = $2, session_id = sqlc.narg(session_id), error_code = sqlc.narg(error_code), error_message = sqlc.narg(error_message), delivery = NULL diff --git a/services/core/internal/db/sqlc/automations.sql.go b/services/core/internal/db/sqlc/automations.sql.go index a9c807a7c..1b6522c91 100644 --- a/services/core/internal/db/sqlc/automations.sql.go +++ b/services/core/internal/db/sqlc/automations.sql.go @@ -69,31 +69,35 @@ func (q *Queries) DeleteAutomation(ctx context.Context, arg DeleteAutomationPara } const deliverableInvocations = `-- name: DeliverableInvocations :many -SELECT id, automation_id, tenant_id, target_type, delivery, created_at, subject_kind, subject_id, organization_id, project_id FROM ( - SELECT DISTINCT ON (i.automation_id) i.id, i.automation_id, a.tenant_id, (a.target->>'type')::text AS target_type, i.delivery, i.created_at, - p.subject_kind, p.subject_id, s.organization_id, s.project_id - FROM automation_invocations i - JOIN automations a ON a.id = i.automation_id - JOIN projects p ON p.tenant_id = a.tenant_id AND p.archived_at IS NULL - JOIN execution_project_scopes s ON s.tenant_id = a.tenant_id - WHERE i.status = 'queued' AND (i.scheduled_at IS NULL OR a.status = 'active') - ORDER BY i.automation_id, i.created_at, i.id -) oldest -ORDER BY created_at, id -LIMIT $1 +WITH picked AS ( + SELECT id, automation_id, tenant_id, target_type, delivery, created_at, attempted_at, subject_kind, subject_id, organization_id, project_id FROM ( + SELECT DISTINCT ON (i.automation_id) i.id, i.automation_id, a.tenant_id, (a.target->>'type')::text AS target_type, i.delivery, i.created_at, i.attempted_at, + p.subject_kind, p.subject_id, s.organization_id, s.project_id + FROM automation_invocations i + JOIN automations a ON a.id = i.automation_id + JOIN projects p ON p.tenant_id = a.tenant_id AND p.archived_at IS NULL + JOIN execution_project_scopes s ON s.tenant_id = a.tenant_id + WHERE i.status = 'queued' AND (i.scheduled_at IS NULL OR a.status = 'active') + ORDER BY i.automation_id, i.created_at, i.id + ) oldest + ORDER BY attempted_at NULLS FIRST, created_at, id + LIMIT $1 +), marked AS ( + UPDATE automation_invocations u SET attempted_at = statement_timestamp() FROM picked WHERE u.id = picked.id +) +SELECT id, automation_id, tenant_id, target_type, delivery, subject_kind, subject_id, organization_id, project_id FROM picked ` type DeliverableInvocationsRow struct { - ID pgtype.UUID `json:"id"` - AutomationID pgtype.UUID `json:"automation_id"` - TenantID pgtype.UUID `json:"tenant_id"` - TargetType string `json:"target_type"` - Delivery []byte `json:"delivery"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - SubjectKind string `json:"subject_kind"` - SubjectID string `json:"subject_id"` - OrganizationID string `json:"organization_id"` - ProjectID string `json:"project_id"` + ID pgtype.UUID `json:"id"` + AutomationID pgtype.UUID `json:"automation_id"` + TenantID pgtype.UUID `json:"tenant_id"` + TargetType string `json:"target_type"` + Delivery []byte `json:"delivery"` + SubjectKind string `json:"subject_kind"` + SubjectID string `json:"subject_id"` + OrganizationID string `json:"organization_id"` + ProjectID string `json:"project_id"` } func (q *Queries) DeliverableInvocations(ctx context.Context, pageLimit int32) ([]DeliverableInvocationsRow, error) { @@ -111,7 +115,6 @@ func (q *Queries) DeliverableInvocations(ctx context.Context, pageLimit int32) ( &i.TenantID, &i.TargetType, &i.Delivery, - &i.CreatedAt, &i.SubjectKind, &i.SubjectID, &i.OrganizationID, @@ -132,7 +135,6 @@ SELECT a.tenant_id, a.id FROM automations a WHERE a.status = 'active' AND a.next_run_at <= $1::timestamptz AND EXISTS (SELECT 1 FROM projects p WHERE p.tenant_id = a.tenant_id AND p.archived_at IS NULL) ORDER BY a.next_run_at LIMIT $2 -FOR UPDATE OF a SKIP LOCKED ` type DueAutomationIDsParams struct { @@ -314,10 +316,9 @@ func (q *Queries) InsertManualInvocation(ctx context.Context, arg InsertManualIn const insertScheduledInvocation = `-- name: InsertScheduledInvocation :exec INSERT INTO automation_invocations (id, automation_id, scheduled_at, automation_snapshot, status, skip_reason, session_id, delivery) -SELECT $1, a.id, $2, $3, $4::text, $5::text, - CASE WHEN $4::text = 'skipped' AND a.target->>'type' = 'existing_session' THEN (a.target->>'session_id')::uuid END, +SELECT $1, a.id, $2, $3, $4::text, $5::text, $6, CASE WHEN $4::text = 'queued' THEN a.target_contents END -FROM automations a WHERE a.id = $6 +FROM automations a WHERE a.id = $7 ON CONFLICT (automation_id, scheduled_at) DO NOTHING ` @@ -327,6 +328,7 @@ type InsertScheduledInvocationParams struct { AutomationSnapshot []byte `json:"automation_snapshot"` Status string `json:"status"` SkipReason pgtype.Text `json:"skip_reason"` + SessionID pgtype.UUID `json:"session_id"` AutomationID pgtype.UUID `json:"automation_id"` } @@ -337,6 +339,7 @@ func (q *Queries) InsertScheduledInvocation(ctx context.Context, arg InsertSched arg.AutomationSnapshot, arg.Status, arg.SkipReason, + arg.SessionID, arg.AutomationID, ) return err diff --git a/services/core/internal/db/sqlc/models.go b/services/core/internal/db/sqlc/models.go index 520c9e309..3dee63fc7 100644 --- a/services/core/internal/db/sqlc/models.go +++ b/services/core/internal/db/sqlc/models.go @@ -64,6 +64,7 @@ type AutomationInvocation struct { ErrorCode pgtype.Text `json:"error_code"` ErrorMessage pgtype.Text `json:"error_message"` Delivery []byte `json:"delivery"` + AttemptedAt pgtype.Timestamptz `json:"attempted_at"` } type DeploymentModelProvider struct { diff --git a/services/core/internal/persistence/postgres/automationpg/store.go b/services/core/internal/persistence/postgres/automationpg/store.go index 10220231f..77ef574a9 100644 --- a/services/core/internal/persistence/postgres/automationpg/store.go +++ b/services/core/internal/persistence/postgres/automationpg/store.go @@ -10,6 +10,7 @@ import ( "errors" "time" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" @@ -152,47 +153,70 @@ func (s *Store) Invoke(ctx context.Context, tenantID, id string) (automations.In } // Record records the occurrence of each due active Automation of a live -// Project and advances it, in one transaction. A concurrent pass skips rows -// another holds, and the unique scheduled time keeps an occurrence single. +// Project and advances it, each in its own transaction so that one failing +// Automation stops no other. The unique scheduled time keeps an occurrence +// single across concurrent passes. func (s *Store) Record(ctx context.Context, now time.Time) (int, error) { + due, err := s.pool.Queries().DueAutomationIDs(ctx, sqlc.DueAutomationIDsParams{Now: timestamp(&now), PageLimit: maxDue}) + if err != nil { + return 0, err + } recorded := 0 - err := s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + var failures []error + for _, d := range due { + switch ok, err := s.record(ctx, d.TenantID, d.ID, now); { + case err != nil: + failures = append(failures, err) + case ok: + recorded++ + } + } + return recorded, errors.Join(failures...) +} + +// record locks one Automation, rechecks that it is still due and records it. +func (s *Store) record(ctx context.Context, tenant, id pgtype.UUID, now time.Time) (recorded bool, err error) { + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { q := sqlc.New(tx) - due, err := q.DueAutomationIDs(ctx, sqlc.DueAutomationIDsParams{Now: timestamp(&now), PageLimit: maxDue}) + row, err := q.LockAutomation(ctx, sqlc.LockAutomationParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return nil // deleted since the pass listed it + } if err != nil { return err } - for _, d := range due { - row, err := q.GetAutomation(ctx, sqlc.GetAutomationParams{TenantID: d.TenantID, ID: d.ID}) - if err != nil { - return err - } - a, err := automation(row) - if err != nil { - return err - } - flags, err := q.AutomationFlags(ctx, d.ID) - if err != nil { - return err - } - a.Queued = flags.Queued - snapshot, err := json.Marshal(a.Resource()) - if err != nil { - return err - } - occurrence, status, reason := a.Occurrence(now), "queued", pgtype.Text{} - if occurrence.Skipped { - status, reason = "skipped", text("pending_delivery") - } - if err := q.InsertScheduledInvocation(ctx, sqlc.InsertScheduledInvocationParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, AutomationID: d.ID, ScheduledAt: timestamp(&occurrence.ScheduledAt), AutomationSnapshot: snapshot, Status: status, SkipReason: reason}); err != nil { - return err + a, err := automation(sqlc.GetAutomationRow(row)) + if err != nil { + return err + } + if a.Status != automations.Active || a.NextRunAt == nil || a.NextRunAt.After(now) { + return nil + } + flags, err := q.AutomationFlags(ctx, id) + if err != nil { + return err + } + a.Queued = flags.Queued + snapshot, err := json.Marshal(a.Resource()) + if err != nil { + return err + } + occurrence := a.Occurrence(now) + invocation := sqlc.InsertScheduledInvocationParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, AutomationID: id, ScheduledAt: timestamp(&occurrence.ScheduledAt), AutomationSnapshot: snapshot, Status: "queued"} + if occurrence.Skipped { + invocation.Status, invocation.SkipReason = "skipped", text("pending_delivery") + var target struct { + SessionID string `json:"session_id"` } - if err := q.AdvanceAutomation(ctx, sqlc.AdvanceAutomationParams{ID: d.ID, NextRunAt: timestamp(occurrence.Next), Completed: occurrence.Completed}); err != nil { - return err + if json.Unmarshal(a.Target, &target) == nil { + invocation.SessionID, _ = pgunit.ParseID(target.SessionID) } - recorded++ } - return nil + if err := q.InsertScheduledInvocation(ctx, invocation); err != nil { + return err + } + recorded = true + return q.AdvanceAutomation(ctx, sqlc.AdvanceAutomationParams{ID: id, NextRunAt: timestamp(occurrence.Next), Completed: occurrence.Completed}) }) return recorded, err } @@ -207,19 +231,20 @@ func (s *Store) Deliverable(ctx context.Context, limit int) ([]automations.Deliv tenant, id := uuidString(row.TenantID), uuidString(row.AutomationID) request, err := s.cipher.OpenAutomationTarget(row.Delivery, tenant, id) if err != nil { - return nil, err + log.Ctx(ctx).Error("Automation target could not be opened", "automation_id", id, "error", err) + if err := s.Settle(ctx, automations.Outcome{InvocationID: uuidString(row.ID), Status: "failed", Code: "internal_error", Message: "The saved target could not be opened."}); err != nil { + return nil, err + } + continue } - deliveries = append(deliveries, automations.Delivery{InvocationID: uuidString(row.ID), AutomationID: id, TargetType: row.TargetType, Request: request, + deliveries = append(deliveries, automations.Delivery{InvocationID: uuidString(row.ID), TargetType: row.TargetType, Request: request, Scope: identity.Principal{ProjectScope: identity.ProjectScope{TenantID: tenant, OrganizationID: row.OrganizationID, ProjectID: row.ProjectID}, SubjectKind: row.SubjectKind, SubjectID: row.SubjectID}}) } return deliveries, nil } func (s *Store) Settle(ctx context.Context, outcome automations.Outcome) error { - id, err := pgunit.ParseID(outcome.InvocationID) - if err != nil { - return nil - } + id, _ := pgunit.ParseID(outcome.InvocationID) params := sqlc.SettleInvocationParams{ID: id, Status: outcome.Status, ErrorCode: text(outcome.Code), ErrorMessage: text(outcome.Message)} if session, err := pgunit.ParseID(outcome.SessionID); err == nil { params.SessionID = session @@ -255,7 +280,10 @@ func (s *Store) List(ctx context.Context, tenantID string, query automations.Lis if err != nil { return automations.Page{}, automations.ErrNotFound } - params := sqlc.ListAutomationsParams{TenantID: tenant, SessionID: pgtype.Text{String: query.SessionID, Valid: query.SessionID != ""}, PageLimit: int32(query.Limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: query.Ascending} + params := sqlc.ListAutomationsParams{TenantID: tenant, PageLimit: int32(query.Limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: query.Ascending} + if query.SessionID != nil { + params.SessionID = pgtype.Text{String: *query.SessionID, Valid: true} + } var rows []sqlc.ListAutomationsRow err = s.pool.Snapshot(ctx, func(ctx context.Context, tx pgx.Tx) error { q := sqlc.New(tx) diff --git a/services/core/migrations/000012_automations.sql b/services/core/migrations/000012_automations.sql index da1bd5a3b..9066fa14b 100644 --- a/services/core/migrations/000012_automations.sql +++ b/services/core/migrations/000012_automations.sql @@ -33,6 +33,7 @@ CREATE TABLE automation_invocations ( error_code text, error_message text, delivery bytea, + attempted_at timestamptz, UNIQUE (automation_id, scheduled_at), CHECK ((scheduled_at IS NULL) = (automation_snapshot IS NULL)), CHECK ((status = 'skipped') = (skip_reason IS NOT NULL)), diff --git a/services/core/tests/integration/automations_public_test.go b/services/core/tests/integration/automations_public_test.go index fd52b137c..849bc1fd9 100644 --- a/services/core/tests/integration/automations_public_test.go +++ b/services/core/tests/integration/automations_public_test.go @@ -36,7 +36,7 @@ func TestAutomationsOfficialClient(t *testing.T) { if err != nil { t.Fatal(err) } - deliver, err := api.NewAutomationDelivery(deps) + _, deliver, err := api.NewHandlerWithDelivery(deps) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/automations_store_test.go b/services/core/tests/integration/automations_store_test.go index 7c2d6a2ff..7e5763639 100644 --- a/services/core/tests/integration/automations_store_test.go +++ b/services/core/tests/integration/automations_store_test.go @@ -15,13 +15,21 @@ import ( func TestAutomationRecordingAndSettlement(t *testing.T) { s, _ := testStore(t) store, _ := fixtureAutomations(t, s) - tenant, project := uuid.NewString(), uuid.NewString() - if _, err := s.pool.Exec(t.Context(), "INSERT INTO execution_project_scopes(tenant_id,organization_id,project_id) VALUES($1,'test-org',$2)", tenant, project); err != nil { + // Record covers every tenant, so start from none. + if _, err := s.pool.Exec(t.Context(), "DELETE FROM automations"); err != nil { t.Fatal(err) } - if _, err := s.pool.Exec(t.Context(), "INSERT INTO projects(id,name,tenant_id,subject_kind,subject_id) VALUES($1,'Automation store',$1,'service_account','test-runner')", tenant); err != nil { - t.Fatal(err) + newTenant := func() (string, string) { + tenant, project := uuid.NewString(), uuid.NewString() + if _, err := s.pool.Exec(t.Context(), "INSERT INTO execution_project_scopes(tenant_id,organization_id,project_id) VALUES($1,'test-org',$2)", tenant, project); err != nil { + t.Fatal(err) + } + if _, err := s.pool.Exec(t.Context(), "INSERT INTO projects(id,name,tenant_id,subject_kind,subject_id) VALUES($1,'Automation store',$1,'service_account','test-runner')", tenant); err != nil { + t.Fatal(err) + } + return tenant, project } + tenant, project := newTenant() due := time.Now().Add(-time.Minute) created, err := store.Create(t.Context(), automations.Automation{TenantID: tenant, Type: automations.Once, Name: "due", Metadata: map[string]string{}, Status: automations.Active, RunAt: &due, NextRunAt: &due, TargetType: "existing_session", Target: []byte(`{"type":"existing_session"}`), Contents: []byte(`{"session_id":"s","instructions":"i"}`)}) @@ -85,4 +93,45 @@ func TestAutomationRecordingAndSettlement(t *testing.T) { if err != nil || len(page.Invocations) != 2 || page.Invocations[0].Status != "queued" || page.Invocations[1].Status != "skipped" || page.Invocations[1].SkipReason != "pending_delivery" || page.Invocations[1].SessionID != session { t.Fatalf("skipped occurrence: %+v %v", page, err) } + + // An occurrence whose stored target names its Session in a non-canonical form, or whose sealed + // target cannot be opened, stops no other Automation. + other, _ := newTenant() + odd, err := store.Create(t.Context(), automations.Automation{TenantID: other, Type: automations.Periodic, Name: "odd", Metadata: map[string]string{}, Status: automations.Active, Expression: "* * * * *", Timezone: "UTC", NextRunAt: &due, + TargetType: "existing_session", Target: []byte(`{"type":"existing_session","session_id":"urn:uuid:` + session + `"}`), Contents: []byte(`{}`)}) + if err != nil { + t.Fatal(err) + } + good, err := store.Create(t.Context(), automations.Automation{TenantID: tenant, Type: automations.Periodic, Name: "good", Metadata: map[string]string{}, Status: automations.Active, Expression: "* * * * *", Timezone: "UTC", NextRunAt: &due, + TargetType: "existing_session", Target: []byte(`{"type":"existing_session","session_id":"` + session + `"}`), Contents: []byte(`{}`)}) + if err != nil { + t.Fatal(err) + } + if _, err := store.Invoke(t.Context(), other, odd.ID); err != nil { + t.Fatal(err) + } + if n, err := store.Record(t.Context(), time.Now()); err != nil || n != 2 { + t.Fatalf("recorded %d: %v", n, err) + } + if _, err := s.pool.Exec(t.Context(), "UPDATE automation_invocations SET delivery = '\\x00' WHERE automation_id = $1 AND scheduled_at IS NULL", odd.ID); err != nil { + t.Fatal(err) + } + + // Pause holds the queued scheduled delivery; a manual invocation still delivers. + if _, err := store.Update(t.Context(), tenant, good.ID, automations.Automation.Pause); err != nil { + t.Fatal(err) + } + if held, err := store.Deliverable(t.Context(), 10); err != nil || len(held) != 1 { // only the earlier manual invocation of "busy" + t.Fatalf("paused or corrupt deliveries: %+v %v", held, err) + } + page, err = store.ListInvocations(t.Context(), other, odd.ID, automations.ListQuery{Limit: 10, Ascending: true}) + if err != nil || len(page.Invocations) != 2 || page.Invocations[0].Status != "failed" || page.Invocations[0].Code != "internal_error" || page.Invocations[1].Status != "skipped" { + t.Fatalf("corrupt row: %+v %v", page, err) + } + if _, err := store.Invoke(t.Context(), tenant, good.ID); err != nil { + t.Fatal(err) + } + if manual, err := store.Deliverable(t.Context(), 10); err != nil || len(manual) != 2 { + t.Fatalf("manual invocation of a paused automation: %+v %v", manual, err) + } } diff --git a/services/core/tests/official_automations.py b/services/core/tests/official_automations.py index 8da87505e..09e5c56c6 100644 --- a/services/core/tests/official_automations.py +++ b/services/core/tests/official_automations.py @@ -48,6 +48,17 @@ def main(): inline = automations.create(type="periodic", name="inline", expression="*/5 * * * *", timezone="Asia/Shanghai", target={"type": "new_session", "agent": {"model": "saved-model", "instructions": "Inline."}, "environment": {"type": "none"}, "input": MESSAGE}) assert inline.target.agent.model == "saved-model" and inline.target.agent_id is None + assert inline.target.agent.reasoning.effort is None and inline.target.agent.reasoning.summary is None + # Reasoning keeps both members and a self-hosted environment lists its directories. + rich = automations.create(type="periodic", name="rich", expression="0 0 1 1 *", timezone="UTC", + target={"type": "new_session", "agent": {"model": "saved-model", "reasoning": {"effort": "low"}}, "environment": {"type": "none"}, "input": MESSAGE}) + assert rich.target.agent.reasoning.effort == "low" and rich.target.agent.reasoning.summary is None + provider = {"x_agents_core": {"model_provider": {"protocol": "responses", "base_url": "https://model.fixture.example/v1", "api_key": "fixture-model-key"}}} + hosted = agents.create(model="saved-model", extra_body=provider) + own = automations.create(type="periodic", name="own", expression="0 0 1 1 *", timezone="UTC", target={"type": "new_session", "agent_id": hosted.id, "environment": {"type": "self_hosted", "workspace_directory": "/workspace"}, "input": MESSAGE}) + assert own.target.environment.type == "self_hosted" and own.target.environment.capability_directories == [] + for extra in (rich, own): + automations.delete(extra.id) once = automations.create(type="once", name="later", run_at=int(time.time()) + 3600, target=existing) assert once.target.session_id == session.id and once.target.instructions == "wake up" and once.status == "active" for created in (periodic, inline, once): @@ -56,6 +67,7 @@ def main(): assert [a.id for a in automations.list(order="asc", limit=2).data] == [periodic.id, inline.id] assert [a.id for a in automations.list(after=inline.id).data] == [periodic.id] assert [a.id for a in automations.list(session_id=session.id).data] == [once.id] + assert automations.list(session_id="").data == [] fails(NotFoundError, lambda: automations.list(after="missing")) fails(NotFoundError, lambda: other.beta.agents.automations.retrieve(periodic.id)) @@ -104,10 +116,16 @@ def main(): fails(BadRequestError, lambda: automations.create(type="once", name="bad", run_at=int(time.time()) - 5, target=existing), "run_at") fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {**new_session, "environment": {"type": "openai_hosted", "environment_id": "env"}}})) fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {**new_session, "agent": {"model": "m"}}}), "target") - fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {k: v for k, v in new_session.items() if k != "agent_id"} | {"agent": {"instructions": "no model"}}}), "target.agent.model") + inline_target = {k: v for k, v in new_session.items() if k != "agent_id"} + fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": inline_target | {"agent": {"instructions": "no model"}}})) + fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": inline_target | {"agent": {"model": "m", "tools": [{"type": "automations"}]}}})) fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {k: v for k, v in new_session.items() if k != "agent_id"} | {"agent": {"model": "m", "x_agents_core": {"harness": "codex"}}}}), "target.agent.x_agents_core") fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {**existing, "instructions": ""}}), "target.instructions") fails(NotFoundError, lambda: automations.create(**periodic_args | {"target": {**existing, "session_id": "missing"}})) + # A Session named in another uuid form is saved under its canonical id. + urn = automations.create(**periodic_args | {"name": "urn", "target": {**existing, "session_id": "urn:uuid:" + session.id}}) + assert urn.target.session_id == session.id and urn.id in [a.id for a in automations.list(session_id=session.id).data] + automations.delete(urn.id) assert len(automations.list(limit=100).data) == count # Delete removes the Automation and its records, not the Session. From ef251a5938cf179811051e90a4857712f8420cba Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 05:03:19 +0000 Subject: [PATCH 5/7] Align Automation docs with the fixes Co-Authored-By: Claude Sonnet 5.5 --- contracts/agents-api/automations.md | 16 +++++++--------- contracts/agents-api/index.md | 5 +++-- contracts/agents-api/zh/automations.md | 18 ++++++++---------- contracts/agents-api/zh/index.md | 5 +++-- services/core/IMPLEMENTATION.md | 2 +- 5 files changed, 22 insertions(+), 24 deletions(-) diff --git a/contracts/agents-api/automations.md b/contracts/agents-api/automations.md index 4e61cd85b..43ac8471f 100644 --- a/contracts/agents-api/automations.md +++ b/contracts/agents-api/automations.md @@ -15,8 +15,6 @@ An Automation saves a schedule and a target. When the schedule fires, Core deliv | `POST …/invocations` | 200, a queued manual invocation | | `GET …/invocations`, `GET …/invocations/{invocation_id}` | A page, or one invocation | -Lists take `after`, `limit` (default 20; 0 becomes 1 and above 100 becomes 100) and `order` (default `desc`). A cursor that names no record returns 404 `not_found_error`. - ## Resource `type` is `periodic` or `once`. @@ -28,7 +26,7 @@ Lists take `after`, `limit` (default 20; 0 becomes 1 and above 100 becomes 100) ### Schedules -`expression` is a five-field crontab: minute, hour, day of month, month and day of week. A field is `*` or a comma list of `n`, `a-b`, `*/s` and `a-b/s`; months and weekdays also take three-letter names, and weekday 7 is Sunday. When both day fields are restricted, a day matches either. Descriptors such as `@daily`, a seconds field, `Local` and an empty zone are rejected, and so is an expression with no occurrence in the next five years. +`expression` is a five-field crontab: minute, hour, day of month, month and day of week. A field is `*` or a comma list of `n`, `a-b`, `*/s` and `a-b/s`; months and weekdays also take three-letter names, and weekday 7 is Sunday. When neither day field starts with `*`, a day matches either. Descriptors such as `@daily`, a seconds field, `Local` and an empty zone are rejected, and so is an expression with no occurrence in the next five years. `timezone` is an IANA name, resolved from the tzdata Core embeds. A wall-clock minute that does not exist on a daylight saving change is skipped; a minute that occurs twice fires once, at its first instant. @@ -38,15 +36,15 @@ If Core was down when occurrences were due, it records only the latest one. `target.type` is `new_session` or `existing_session`. An update keeps the type; another type returns 400 with param `target.type`. -`new_session` takes the body of Session create without `type`: `environment` and `input` (an array of user messages) are required; `agent` or `agent_id` is optional, and exactly one of them must be given; `metadata` and `vault_ids` are optional. The target is validated by the code that validates Session create, so its rejections are the same, with their params prefixed `target.`: `environment.environment_id`, `container_size`, `desktop.enabled`, `packages.system` and `blocked_domains` are rejected. Beyond that: +`new_session` takes the fields of the Session create body, plus `type`: `environment` and `input` (an array of user messages) are required, exactly one of `agent` and `agent_id` must be given, and `metadata` and `vault_ids` are optional. Create and update run the checks Session create runs before it creates anything: the request shape, the Environment Template, the saved Agent, the model and its provider, the Vault bindings, and the Harness's support for the configuration, so an unsupported tool such as `automations` returns 400 at create. The rejections are Session create's, with their params prefixed `target.`: `environment.environment_id`, `container_size`, `desktop.enabled`, `packages.system` and `blocked_domains` are rejected. Deployment readiness, admission and leases are checked at each delivery. Beyond that: -- An inline `agent` needs `model` (param `target.agent.model`) and rejects `x_agents_core`. +- An inline `agent` follows Session create: `model` is required unless a deployment default model applies. - `x_agents_core` is rejected anywhere in the target. -- The saved resource shows the inline agent without `id` and `name`, MCP headers removed. `environment` shows the members the request supplied and `null` for the rest, which means inherit. `env` and `setup_commands` are never shown. +- The saved resource shows the inline agent without `id` and `name`, MCP headers removed. `environment` shows the members the request supplied and `null` for the rest, which means inherit; a `self_hosted` environment always lists `capability_directories`. `env` and `setup_commands` are never shown. -`existing_session` takes `session_id` and `instructions` (1 byte to 1 MiB). The Session must exist when the Automation is created or updated; a Session deleted later fails each delivery with the input path's 404. Core sends `instructions` as one user message with one `input_text` part, exactly as `agent.session.input.message` would. +`existing_session` takes `session_id` and `instructions` (1 to 1,048,576 characters). The Session must exist when the Automation is created or updated, and Core saves its canonical ID whatever form the request used; a Session deleted later fails each delivery with the input path's 404. Core sends `instructions` as one user message with one `input_text` part, exactly as `agent.session.input.message` would. -Create and update save the target; references inside it, such as a Skill, a File or an Agent, resolve at each invocation. +Create and update save the target; references inside it, such as a Skill, a File or an Agent, resolve at each invocation. A queued invocation delivers the target as it was when it was recorded or invoked, so an update applies to later invocations. The target request may hold values that reads never return, so Core stores it sealed with the credential key, bound to the Project and the Automation. @@ -61,7 +59,7 @@ A scheduled occurrence and a manual invocation each produce one invocation recor | `failed` | Delivery was rejected for good; `error` holds the `code` and `message` the same request would return | | `skipped` | `skip_reason` is `pending_delivery`: the Automation already had a queued invocation | -Core records due occurrences and delivers queued invocations once a second, and runs at most 16 deliveries at the same time, each for up to 5 s. A delivery uses the code of `POST /v1/agents/sessions` or `POST /v1/agents/sessions/{id}/events`, with the invocation ID as its `Idempotency-Key`, so a retry after a crash finds the Session or the input it already made. Core retries a delivery while the cause is temporary: execution unavailable, a sandbox reset, a pending earlier input, or a Turn that cannot take input. Any other rejection fails the invocation. One Automation delivers one invocation at a time, oldest first, and invocations are kept until the Automation is deleted. +Core records due occurrences and delivers queued invocations once a second, and runs at most 16 deliveries at the same time, each for up to 5 s. A delivery uses the code of `POST /v1/agents/sessions` or `POST /v1/agents/sessions/{id}/events`, with the invocation ID as its `Idempotency-Key`, so a retry after a crash finds the Session or the input it already made. Core retries a delivery while the cause is temporary: execution unavailable, a sandbox reset, a pending earlier input, or a Turn that cannot take input. Any other rejection fails the invocation. One Automation delivers one invocation at a time, oldest first, and the Automation attempted least recently goes first, so a stuck invocation does not hold every slot. An invocation whose saved target cannot be opened fails with `internal_error`. Invocations are kept until the Automation is deleted. A `once` Automation completes when its scheduled invocation reaches `completed`, `failed` or `skipped`. A manual invocation never changes the schedule. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 19c4e29f9..94f7f58a3 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -207,10 +207,11 @@ Each item is Core's deliberate or native behavior where the official service beh - A `once` Automation completes when its scheduled invocation is terminal. - Pause holds a queued scheduled invocation until resume; manual invocations still deliver, in every status. - Pausing a paused Automation and resuming an active one return it unchanged. Pausing or resuming a completed one, resuming a `once` Automation whose past `run_at` has no pending invocation, and changing `run_at` after its scheduled invocation exists return 409 `conflict_error`. - - A past `run_at`, a timing field of the other type, a target of another type, an invalid cron expression or time zone, and an inline `agent` without `model` return 400 `invalid_request_error` with Core's own params and messages. + - A past `run_at`, a timing field of the other type, a target of another type, an invalid cron expression or time zone, and an inline `agent` without `model` where no deployment default applies return 400 `invalid_request_error` with Core's own params and messages. - A skipped `existing_session` invocation carries the target's `session_id`; a `new_session` invocation has null until its Session exists. - A `limit` outside 1–100 is clamped, and an unresolved `after` returns 404. - - Create validates shapes, Core's Session rules and that the target Session exists; saved references resolve at each invocation. + - Create validates the target with the checks Session create runs before it creates anything (shape, Environment Template, saved Agent, model and provider, Vault bindings, Harness support) and that an `existing_session` target's Session exists. Deployment readiness, admission and leases are checked at each delivery. + - Saved references resolve at each invocation. A queued invocation delivers the target as it was when it was recorded or invoked; an update applies to later invocations. - Invocations are kept until the Automation is deleted. A deleted target Session fails each delivery. - The order of errors when one request has several faults, and error, default and payload-limit parity in general. - A Subagent's function call: Core makes it a required action of the root Turn that runs the child, with `turn_id` set to that Turn, and publishes the child's `function_call` Item once as `agent.session.turn.item.added` with the child Turn ID and a null `output_index`; a result may name either Turn. The Turn is `waiting` and the Session `requires_action` while root output may continue, and the `call_id` equals the `call_id` of the child's `function_call` Item. Claude Code publishes the child's Items when the root Turn settles, after the required action. diff --git a/contracts/agents-api/zh/automations.md b/contracts/agents-api/zh/automations.md index 2b80c82c1..8cf5ef48d 100644 --- a/contracts/agents-api/zh/automations.md +++ b/contracts/agents-api/zh/automations.md @@ -1,7 +1,7 @@ --- title: "Automations" source: contracts/agents-api/automations.md -source_hash: 0320feda2a052e74cb809d682793b5fad5b71250ae08ed80a35a14d368238450 +source_hash: d3bb3c4b1edc5d475c01a2c1e36c6bfbf83af0a90a7289791146d344d1d30ec0 --- Automation 保存一个计划和一个目标。计划触发时,Core 投递目标:创建新 Session,或向已有 Session 发送指令。锁定版本 SDK 的全部十个操作都在 `/v1/agents/automations` 下实现,并与其他 Beta 路由一样需要项目身份验证和 `OpenAI-Beta: agents=v1`。Automation 属于其密钥所在的项目;不存在的 ID 与其他项目的 ID 都返回 404。 @@ -17,8 +17,6 @@ Automation 保存一个计划和一个目标。计划触发时,Core 投递目 | `POST …/invocations` | 200,一个排队中的手动 invocation | | `GET …/invocations`、`GET …/invocations/{invocation_id}` | 一页结果,或单个 invocation | -列表接受 `after`、`limit`(默认 20;0 变为 1,大于 100 变为 100)和 `order`(默认 `desc`)。不指向任何记录的游标返回 404 `not_found_error`。 - ## 资源 {#resource} `type` 为 `periodic` 或 `once`。 @@ -30,7 +28,7 @@ Automation 保存一个计划和一个目标。计划触发时,Core 投递目 ### 计划 {#schedules} -`expression` 是五字段 crontab:分钟、小时、日、月和星期。每个字段为 `*`,或由 `n`、`a-b`、`*/s` 和 `a-b/s` 组成的逗号列表;月和星期还接受三字母名称,星期 7 表示周日。日和星期两个字段都有限制时,满足任一个即匹配该日。`@daily` 这类描述符、秒字段、`Local` 和空时区会被拒绝,未来五年内没有触发时间的表达式也会被拒绝。 +`expression` 是五字段 crontab:分钟、小时、日、月和星期。每个字段为 `*`,或由 `n`、`a-b`、`*/s` 和 `a-b/s` 组成的逗号列表;月和星期还接受三字母名称,星期 7 表示周日。日和星期两个字段都不以 `*` 开头时,满足任一个即匹配该日。`@daily` 这类描述符、秒字段、`Local` 和空时区会被拒绝,未来五年内没有触发时间的表达式也会被拒绝。 `timezone` 是 IANA 名称,由 Core 内嵌的 tzdata 解析。夏令时切换中不存在的墙上时钟分钟会被跳过;出现两次的分钟只触发一次,在其第一个时刻。 @@ -40,15 +38,15 @@ Automation 保存一个计划和一个目标。计划触发时,Core 投递目 `target.type` 为 `new_session` 或 `existing_session`。更新保持类型不变;另一种类型返回 400,param 为 `target.type`。 -`new_session` 采用去掉 `type` 的 Session 创建请求体:`environment` 和 `input`(用户消息数组)必填;`agent` 或 `agent_id` 可选,且必须恰好提供其中一个;`metadata` 和 `vault_ids` 可选。目标由校验 Session 创建的同一段代码校验,因此拒绝规则相同,param 加上前缀 `target.`:`environment.environment_id`、`container_size`、`desktop.enabled`、`packages.system` 和 `blocked_domains` 会被拒绝。此外: +`new_session` 采用 Session 创建请求体的字段,外加 `type`:`environment` 和 `input`(用户消息数组)必填,`agent` 与 `agent_id` 必须恰好提供其中一个,`metadata` 和 `vault_ids` 可选。创建和更新会执行 Session 创建在创建任何东西之前执行的检查:请求形状、Environment Template、已保存的 Agent、模型及其 provider、Vault 绑定,以及 Harness 对该配置的支持,因此不受支持的工具(如 `automations`)在创建时返回 400。拒绝规则与 Session 创建相同,param 加上前缀 `target.`:`environment.environment_id`、`container_size`、`desktop.enabled`、`packages.system` 和 `blocked_domains` 会被拒绝。部署就绪、准入和租约在每次投递时检查。此外: -- 内联 `agent` 需要 `model`(param 为 `target.agent.model`),并拒绝 `x_agents_core`。 +- 内联 `agent` 与 Session 创建一致:除非适用部署默认模型,否则 `model` 必填。 - 目标中任何位置的 `x_agents_core` 都会被拒绝。 -- 保存后的资源展示的内联 agent 不含 `id` 和 `name`,并去掉 MCP headers。`environment` 展示请求提供的成员,其余为 `null`,表示继承。`env` 和 `setup_commands` 从不展示。 +- 保存后的资源展示的内联 agent 不含 `id` 和 `name`,并去掉 MCP headers。`environment` 展示请求提供的成员,其余为 `null`,表示继承;`self_hosted` 环境总会列出 `capability_directories`。`env` 和 `setup_commands` 从不展示。 -`existing_session` 采用 `session_id` 和 `instructions`(1 字节到 1 MiB)。创建或更新 Automation 时,该 Session 必须存在;之后被删除会让每次投递以输入路径的 404 失败。Core 把 `instructions` 作为一条带有一个 `input_text` 部分的用户消息发送,与 `agent.session.input.message` 完全一样。 +`existing_session` 采用 `session_id` 和 `instructions`(1 到 1,048,576 个字符)。创建或更新 Automation 时,该 Session 必须存在,Core 保存其规范 ID,无论请求使用哪种形式;之后被删除会让每次投递以输入路径的 404 失败。Core 把 `instructions` 作为一条带有一个 `input_text` 部分的用户消息发送,与 `agent.session.input.message` 完全一样。 -创建和更新只保存目标;目标中的引用,如 Skill、File 或 Agent,在每次 invocation 时解析。 +创建和更新只保存目标;目标中的引用,如 Skill、File 或 Agent,在每次 invocation 时解析。排队中的 invocation 投递的是它被记录或触发时的目标,因此更新只作用于之后的 invocation。 目标请求可能包含读取时不会返回的值,因此 Core 用凭据密钥将其密封存储,并绑定到项目和 Automation。 @@ -63,7 +61,7 @@ Automation 保存一个计划和一个目标。计划触发时,Core 投递目 | `failed` | 投递被永久拒绝;`error` 包含相同请求会返回的 `code` 和 `message` | | `skipped` | `skip_reason` 为 `pending_delivery`:该 Automation 已有排队中的 invocation | -Core 每秒记录到期的触发并投递排队中的 invocation,最多同时运行 16 个投递,每个最长 5 s。投递使用 `POST /v1/agents/sessions` 或 `POST /v1/agents/sessions/{id}/events` 的代码,并以 invocation ID 作为 `Idempotency-Key`,因此崩溃后的重试会找到已经创建的 Session 或输入。原因是暂时的,Core 就会重试:执行不可用、沙箱重置、较早的输入仍在待处理,或 Turn 无法接收输入。其他任何拒绝都会让 invocation 失败。同一个 Automation 一次只投递一个 invocation,先到先投递;invocation 保留到 Automation 被删除。 +Core 每秒记录到期的触发并投递排队中的 invocation,最多同时运行 16 个投递,每个最长 5 s。投递使用 `POST /v1/agents/sessions` 或 `POST /v1/agents/sessions/{id}/events` 的代码,并以 invocation ID 作为 `Idempotency-Key`,因此崩溃后的重试会找到已经创建的 Session 或输入。原因是暂时的,Core 就会重试:执行不可用、沙箱重置、较早的输入仍在待处理,或 Turn 无法接收输入。其他任何拒绝都会让 invocation 失败。同一个 Automation 一次只投递一个 invocation,先到先投递,最久未尝试的 Automation 先投递,因此卡住的 invocation 不会占满所有名额。已保存的目标无法打开的 invocation 以 `internal_error` 失败。invocation 保留到 Automation 被删除。 `once` Automation 在其计划 invocation 到达 `completed`、`failed` 或 `skipped` 时完成。手动 invocation 从不改变计划。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index d89d1013c..56cf218a2 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -210,10 +210,11 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - `once` Automation 在其计划 invocation 到达终态时完成。 - 暂停会保留排队中的计划 invocation 直到恢复;手动 invocation 在任何状态下仍会投递。 - 暂停已暂停的 Automation、恢复已激活的 Automation,会原样返回。暂停或恢复已完成的 Automation、恢复过去 `run_at` 且没有待处理 invocation 的 `once` Automation,以及在计划 invocation 产生后修改 `run_at`,均返回 409 `conflict_error`。 - - 过去的 `run_at`、另一类型的时间字段、另一类型的目标、无效的 cron 表达式或时区、缺少 `model` 的内联 `agent`,返回 400 `invalid_request_error`,param 和消息为 Core 自己的内容。 + - 过去的 `run_at`、另一类型的时间字段、另一类型的目标、无效的 cron 表达式或时区、没有适用部署默认值而缺少 `model` 的内联 `agent`,返回 400 `invalid_request_error`,param 和消息为 Core 自己的内容。 - 被跳过的 `existing_session` invocation 带有目标的 `session_id`;`new_session` invocation 在其 Session 存在之前为 null。 - 超出 1–100 的 `limit` 会被截断,无法解析的 `after` 返回 404。 - - 创建时校验 schema、Core 的 Session 规则以及目标 Session 是否存在;目标中保存的引用在每次 invocation 时解析。 + - 创建时用 Session 创建在创建任何东西之前执行的检查校验目标(形状、Environment Template、已保存的 Agent、模型和 provider、Vault 绑定、Harness 支持),并校验 `existing_session` 目标的 Session 是否存在。部署就绪、准入和租约在每次投递时检查。 + - 目标中保存的引用在每次 invocation 时解析。排队中的 invocation 投递的是它被记录或触发时的目标;更新只作用于之后的 invocation。 - invocation 保留到 Automation 被删除。目标 Session 被删除后,每次投递都会失败。 - 当一个请求存在多项故障时的错误顺序,以及错误处理、默认值和载荷限制的总体一致性。 - Subagent 的函数调用:Core 将其作为运行该子级的根 Turn 的待执行操作,`turn_id` 为该 Turn,并把子级的 `function_call` Item 以 `agent.session.turn.item.added` 发布一次,`turn_id` 为子级 Turn ID,`output_index` 为 null;结果可以指定其中任一 Turn。该 Turn 为 `waiting`、Session 为 `requires_action`,根输出可继续,`call_id` 等于该子级 `function_call` Item 的 `call_id`。Claude Code 在根 Turn 收敛时才发布子级的 Item,晚于该待执行操作。 diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index 5ba54dff2..33d06f433 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -29,7 +29,7 @@ Adapters' execution repositories require a `*pgunit.Lease` at construction, and Domain owners, each with its PostgreSQL adapter under `internal/persistence/postgres`: - `agents` (`agentpg`): saved Agents, their configuration merge and bounds, and the encrypted model-provider bundle bound to each Agent. -- `automations` (`automationpg`): Automations, their schedules and invocation records. `automations` owns the cron grammar and the pure decisions (an occurrence, pause, resume, update); `automationpg` seals each target request with the credential key, bound to the tenant and the Automation, and copies the ciphertext unopened into each queued invocation until it settles. The `automation_delivery` Periodic job (every second) records at most 100 due occurrences in one transaction (`FOR UPDATE SKIP LOCKED` and a unique scheduled time keep a replica from recording one twice), then delivers at most 16 queued invocations at a time, each under 5 s, through `Handler.createSession` for `new_session` and `executionInputs` with `Execution.InputAdmission.SubmitInputs` for `existing_session`, using the invocation ID as the `Idempotency-Key`. The owning Project's principal comes from its `projects` and `execution_project_scopes` rows. A temporary cause (execution unavailable, a sandbox reset, a pending input or Turn conflict, the delivery timeout) leaves the invocation queued; any other rejection settles it `failed` with the error the public route would return. These constants are not settings. +- `automations` (`automationpg`): Automations, schedules and invocation records, and the delivery pass; see [Automations](../../contracts/agents-api/automations.md). - `files` (`filepg`): source Files. - `projects` (`projectpg`): Projects, Project API keys and key resolution for authentication. `projects` validates Project and key names by rune count, separately from the byte limits on node names, and decides key issuance over the share-locked Project so an archive and an issuance never both commit. - `vaults` (`vaultpg`): Vaults and Credentials, the encryption of Credential secrets, OAuth access-token refresh, and the MCP credential selection that Session creation freezes and the Dispatcher's `Credentials` resolves into a bearer token. From ea6024abcc07a4d8cc2ba48dda93c12342836b8f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 05:38:52 +0000 Subject: [PATCH 6/7] Fix Automation review 2: DST rules, retry rule, isolated tests, docs Co-Authored-By: Claude --- contracts/agents-api/automations.md | 9 +- contracts/agents-api/core-metrics.md | 2 +- contracts/agents-api/index.md | 19 ++--- contracts/agents-api/zh/automations.md | 11 ++- contracts/agents-api/zh/core-metrics.md | 4 +- contracts/agents-api/zh/index.md | 21 ++--- contracts/agents-api/zh/wire-semantics.md | 2 +- docs/api/public-agent-api.md | 2 - docs/zh/api/public-agent-api.md | 4 +- scripts/generate-public-api.py | 2 +- services/core/IMPLEMENTATION.md | 2 +- services/core/cmd/server/http_routes_test.go | 2 +- services/core/cmd/server/main.go | 2 +- .../core/internal/api/automation_delivery.go | 23 +++-- .../internal/api/automation_delivery_test.go | 35 ++++++++ services/core/internal/api/automations.go | 74 ++++++++++------ services/core/internal/api/dependencies.go | 10 +-- .../core/internal/api/dependencies_test.go | 8 +- .../core/internal/api/official_shapes.gen.go | 4 +- .../core/internal/automations/automation.go | 13 ++- services/core/internal/automations/cron.go | 85 ++++++++++++------- .../core/internal/automations/cron_test.go | 24 +++++- .../core/internal/db/queries/automations.sql | 43 +++++----- .../core/internal/db/sqlc/automations.sql.go | 72 ++++++++-------- .../execution/environment_admission.go | 2 +- services/core/internal/execution/support.go | 2 +- services/core/internal/execution/worker.go | 2 +- .../postgres/automationpg/store.go | 18 +--- .../providers/configuration_flow_test.go | 2 +- ...automations.sql => 000013_automations.sql} | 0 .../integration/automations_public_test.go | 4 +- .../integration/automations_store_test.go | 11 +-- .../public_handler_fixture_test.go | 3 +- services/core/tests/official_automations.py | 2 + 34 files changed, 300 insertions(+), 219 deletions(-) create mode 100644 services/core/internal/api/automation_delivery_test.go rename services/core/migrations/{000012_automations.sql => 000013_automations.sql} (100%) diff --git a/contracts/agents-api/automations.md b/contracts/agents-api/automations.md index 43ac8471f..341a484f6 100644 --- a/contracts/agents-api/automations.md +++ b/contracts/agents-api/automations.md @@ -7,7 +7,7 @@ An Automation saves a schedule and a target. When the schedule fires, Core deliv | Operation | Result | | --- | --- | | `POST /agents/automations` | 201, an active Automation; nothing is delivered | -| `GET /agents/automations` | A page; `session_id` keeps Automations whose target is that Session | +| `GET /agents/automations` | A page; `session_id` keeps Automations whose target is that Session. Lists clamp `limit` to 1–100, and an unresolved `after` returns 404 | | `GET /agents/automations/{automation_id}` | The Automation | | `POST /agents/automations/{automation_id}` | Replaces the fields in the body; `target` and `metadata` replace the whole field | | `DELETE /agents/automations/{automation_id}` | `agent.automation.deleted`; the target Session and the Sessions it created stay | @@ -20,7 +20,7 @@ An Automation saves a schedule and a target. When the schedule fires, Core deliv `type` is `periodic` or `once`. - `periodic` has `expression` and `timezone`. Status is `active` or `paused`. -- `once` has `run_at`, a Unix timestamp in seconds. Status is `active`, `paused` or `completed`; completed means the schedule has finished, not that the agent's work has. +- `once` has `run_at`, a Unix timestamp in seconds, at most 253402300799. Status is `active`, `paused` or `completed`; completed means the schedule has finished, not that the agent's work has. - `name` holds 1–256 characters, `expression` 1–256, `timezone` 1–128. `metadata` follows the [validation errors](./wire-semantics.md#validation-errors). - `created_by` is always `{"type": "developer"}`: the `automations` agent tool is not supported, so an agent cannot create one ([Session admission](./wire-semantics.md#session-admission)). @@ -28,7 +28,10 @@ An Automation saves a schedule and a target. When the schedule fires, Core deliv `expression` is a five-field crontab: minute, hour, day of month, month and day of week. A field is `*` or a comma list of `n`, `a-b`, `*/s` and `a-b/s`; months and weekdays also take three-letter names, and weekday 7 is Sunday. When neither day field starts with `*`, a day matches either. Descriptors such as `@daily`, a seconds field, `Local` and an empty zone are rejected, and so is an expression with no occurrence in the next five years. -`timezone` is an IANA name, resolved from the tzdata Core embeds. A wall-clock minute that does not exist on a daylight saving change is skipped; a minute that occurs twice fires once, at its first instant. +`timezone` is an IANA name, resolved from the system zoneinfo (or the directory in `ZONEINFO`) first and from the tzdata Core embeds otherwise. A job whose minute and hour fields both start with something other than `*` is fixed-time. On a daylight saving change: + +- Spring forward: a fixed-time job whose time falls in the gap runs at the first instant after the gap. A job with a `*` minute or hour runs only at wall times that exist. +- Fall back: a fixed-time job in the repeated hour runs once, at its first instant. A job with a `*` minute or hour runs on both passes. If Core was down when occurrences were due, it records only the latest one. diff --git a/contracts/agents-api/core-metrics.md b/contracts/agents-api/core-metrics.md index a3a9c5f35..e5c76fab4 100644 --- a/contracts/agents-api/core-metrics.md +++ b/contracts/agents-api/core-metrics.md @@ -61,6 +61,6 @@ Core resolves its own cgroup, including nested and subtree mounts, and reports t - The scheduler's `processed` counts the Turn and Environment work it selected in its last poll. - The Runtime sampler's `processed` and `failed` count the targets its last sweep observed and failed to observe. -- The Automation delivery job's `processed` counts the occurrences it recorded and the invocations it settled in its last pass; `failed` counts storage failures. An invocation that failed for good is a settled record and does not count. +- The Automation delivery job's `processed` counts the occurrences it recorded and the invocations it settled in its last pass; `failed` counts storage failures and panicked deliveries. An invocation that failed for good is a settled record and does not count. - A cleanup job's `processed` counts the rows it removed. - For the scheduler, the Automation delivery job and the cleanup jobs, a failed pass sets `processed` to null and `failed` to 1; `failed` never estimates lost rows or failed Turns. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 94f7f58a3..eb6ecaa13 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -200,19 +200,12 @@ Each item is Core's deliberate or native behavior where the official service beh - When a Session update binds to a queued Turn: Core binds each root Turn's settings when it accepts the input that creates the Turn, so a Turn queued before an update keeps the earlier values. - The meaning of "automatic review is not enabled" in a Session response: Core omits `review` unless it is `auto_review`. - Automations, where the pinned sources are silent ([Automations](./automations.md)): - - A target `existing_session` delivers its `instructions` as one `agent.session.input.message` with one `input_text` part, since the schema defines no notification Item or event. - - The cron grammar is Vixie crontab beyond "standard five-field". A nonexistent daylight saving minute is skipped, and a repeated one fires once at its first instant. - - After downtime, only the latest due occurrence is recorded. - - `pending_delivery` skips an occurrence while any invocation of the Automation is queued. - - A `once` Automation completes when its scheduled invocation is terminal. - - Pause holds a queued scheduled invocation until resume; manual invocations still deliver, in every status. - - Pausing a paused Automation and resuming an active one return it unchanged. Pausing or resuming a completed one, resuming a `once` Automation whose past `run_at` has no pending invocation, and changing `run_at` after its scheduled invocation exists return 409 `conflict_error`. - - A past `run_at`, a timing field of the other type, a target of another type, an invalid cron expression or time zone, and an inline `agent` without `model` where no deployment default applies return 400 `invalid_request_error` with Core's own params and messages. - - A skipped `existing_session` invocation carries the target's `session_id`; a `new_session` invocation has null until its Session exists. - - A `limit` outside 1–100 is clamped, and an unresolved `after` returns 404. - - Create validates the target with the checks Session create runs before it creates anything (shape, Environment Template, saved Agent, model and provider, Vault bindings, Harness support) and that an `existing_session` target's Session exists. Deployment readiness, admission and leases are checked at each delivery. - - Saved references resolve at each invocation. A queued invocation delivers the target as it was when it was recorded or invoked; an update applies to later invocations. - - Invocations are kept until the Automation is deleted. A deleted target Session fails each delivery. + - [Instructions delivery](./automations.md#targets) as one input message. + - [Cron grammar and daylight saving](./automations.md#schedules). + - [Downtime, skipping, completion and pause](./automations.md#invocations). + - [Conflict and rejection cases](./automations.md#state-changes), including the `run_at` limit. + - [Target validation and saved references](./automations.md#targets). + - [Paging](./automations.md): `limit` clamping and unresolved `after`. - The order of errors when one request has several faults, and error, default and payload-limit parity in general. - A Subagent's function call: Core makes it a required action of the root Turn that runs the child, with `turn_id` set to that Turn, and publishes the child's `function_call` Item once as `agent.session.turn.item.added` with the child Turn ID and a null `output_index`; a result may name either Turn. The Turn is `waiting` and the Session `requires_action` while root output may continue, and the `call_id` equals the `call_id` of the child's `function_call` Item. Claude Code publishes the child's Items when the root Turn settles, after the required action. - Whether the official service emits Environment `pending` and when: Core emits it when a hosted Environment's initialization starts, and the one official sample showed only `ready`, about 29 s after creation. diff --git a/contracts/agents-api/zh/automations.md b/contracts/agents-api/zh/automations.md index 8cf5ef48d..adc1b31e5 100644 --- a/contracts/agents-api/zh/automations.md +++ b/contracts/agents-api/zh/automations.md @@ -1,7 +1,7 @@ --- title: "Automations" source: contracts/agents-api/automations.md -source_hash: d3bb3c4b1edc5d475c01a2c1e36c6bfbf83af0a90a7289791146d344d1d30ec0 +source_hash: 7973306d7ac27b5cb7aedc164368760a133f2e75663acfe8a401c3d09e8433b3 --- Automation 保存一个计划和一个目标。计划触发时,Core 投递目标:创建新 Session,或向已有 Session 发送指令。锁定版本 SDK 的全部十个操作都在 `/v1/agents/automations` 下实现,并与其他 Beta 路由一样需要项目身份验证和 `OpenAI-Beta: agents=v1`。Automation 属于其密钥所在的项目;不存在的 ID 与其他项目的 ID 都返回 404。 @@ -9,7 +9,7 @@ Automation 保存一个计划和一个目标。计划触发时,Core 投递目 | 操作 | 结果 | | --- | --- | | `POST /agents/automations` | 201,一个 active 的 Automation;不会投递任何内容 | -| `GET /agents/automations` | 一页结果;`session_id` 只保留目标为该 Session 的 Automation | +| `GET /agents/automations` | 一页结果;`session_id` 只保留目标为该 Session 的 Automation。列表把 `limit` 截断到 1–100,无法解析的 `after` 返回 404 | | `GET /agents/automations/{automation_id}` | 该 Automation | | `POST /agents/automations/{automation_id}` | 替换请求体中的字段;`target` 和 `metadata` 整体替换 | | `DELETE /agents/automations/{automation_id}` | `agent.automation.deleted`;目标 Session 和它创建的 Session 保留 | @@ -22,7 +22,7 @@ Automation 保存一个计划和一个目标。计划触发时,Core 投递目 `type` 为 `periodic` 或 `once`。 - `periodic` 带有 `expression` 和 `timezone`。状态为 `active` 或 `paused`。 -- `once` 带有 `run_at`,即以秒为单位的 Unix 时间戳。状态为 `active`、`paused` 或 `completed`;completed 表示计划已结束,而不是 agent 的工作已结束。 +- `once` 带有 `run_at`,即以秒为单位的 Unix 时间戳,最大为 253402300799。状态为 `active`、`paused` 或 `completed`;completed 表示计划已结束,而不是 agent 的工作已结束。 - `name` 为 1–256 个字符,`expression` 为 1–256,`timezone` 为 1–128。`metadata` 遵循[校验错误](wire-semantics.md#validation-errors)中的规则。 - `created_by` 始终为 `{"type": "developer"}`:不支持 `automations` agent 工具,因此 agent 无法创建 Automation([Session 准入](wire-semantics.md#session-admission))。 @@ -30,7 +30,10 @@ Automation 保存一个计划和一个目标。计划触发时,Core 投递目 `expression` 是五字段 crontab:分钟、小时、日、月和星期。每个字段为 `*`,或由 `n`、`a-b`、`*/s` 和 `a-b/s` 组成的逗号列表;月和星期还接受三字母名称,星期 7 表示周日。日和星期两个字段都不以 `*` 开头时,满足任一个即匹配该日。`@daily` 这类描述符、秒字段、`Local` 和空时区会被拒绝,未来五年内没有触发时间的表达式也会被拒绝。 -`timezone` 是 IANA 名称,由 Core 内嵌的 tzdata 解析。夏令时切换中不存在的墙上时钟分钟会被跳过;出现两次的分钟只触发一次,在其第一个时刻。 +`timezone` 是 IANA 名称,先由系统 zoneinfo(或 `ZONEINFO` 指向的目录)解析,找不到时再用 Core 内嵌的 tzdata。分钟和小时字段都不以 `*` 开头的任务是定点任务。夏令时切换时: + +- 向前拨:落在空档里的定点任务在空档结束后的第一个时刻运行。分钟或小时字段为 `*` 的任务只在存在的墙上时间运行。 +- 向后拨:落在重复小时里的定点任务只在第一个时刻运行一次。分钟或小时字段为 `*` 的任务在两遍中都运行。 如果 Core 停机期间有多次触发到期,它只记录最近一次。 diff --git a/contracts/agents-api/zh/core-metrics.md b/contracts/agents-api/zh/core-metrics.md index 7ff32847d..63998d6d7 100644 --- a/contracts/agents-api/zh/core-metrics.md +++ b/contracts/agents-api/zh/core-metrics.md @@ -1,7 +1,7 @@ --- title: "Core 指标" source: contracts/agents-api/core-metrics.md -source_hash: 79bdf1be68c3765366370f2da4fbb032bda1474adc8c096410f2a8c0f93361db +source_hash: 8be639e4dfa07567ea3c79873f6b7f3aee5ffb9889dc506ac334a2ceb5f686b9 --- `GET /core/v1/metrics?range=1h|6h|24h|7d` 报告 Core 自身的健康状况:进程、执行队列与槽位、PostgreSQL 和后台任务。它要求 Core 密钥([Core 管理 API](admin-api.md))。 @@ -63,6 +63,6 @@ Core 解析自身的 cgroup,包括嵌套与子树挂载,并报告该 cgroup - scheduler 的 `processed` 统计上次轮询选中的 Turn 和 Environment 工作。 - Runtime sampler 的 `processed` 和 `failed` 统计上次扫描成功观察和未能观察的目标。 -- Automation 投递任务的 `processed` 统计上一轮记录的触发和结算的 invocation 数;`failed` 统计存储失败。永久失败的 invocation 是已结算的记录,不计入。 +- Automation 投递任务的 `processed` 统计上一轮记录的触发和结算的 invocation 数;`failed` 统计存储失败和 panic 的投递。永久失败的 invocation 是已结算的记录,不计入。 - 清理任务的 `processed` 统计删除的行数。 - scheduler、Automation 投递任务和清理任务失败时,`processed` 为 null,`failed` 为 1;`failed` 不估算丢失行数或失败 Turn 数量。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 56cf218a2..422cef3a8 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 198b4deb88832ac7cdb28e67a0781469e44621b1baa2cccc54b7a2b9bceea36e +source_hash: cc7259f262c8aad57eb45516d738dcc94dc38dfbd80011bb7a68d0c7f4458a3e --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -203,19 +203,12 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - Session 更新何时绑定到排队中的 Turn:Core 在接受创建根 Turn 的输入时绑定该 Turn 的设置,因此更新之前已排队的 Turn 保持原有取值。 - Session 响应中“automatic review is not enabled”的含义:除 `auto_review` 外,Core 省略 `review`。 - Automations 中固定版本来源未说明的部分([Automations](automations.md)): - - `existing_session` 目标把 `instructions` 作为一条 `agent.session.input.message` 投递,其中只有一个 `input_text` 部分,因为 schema 没有定义通知 Item 或事件。 - - cron 语法在“标准五字段”之外采用 Vixie crontab。夏令时切换中不存在的分钟会被跳过,重复出现的分钟只在其第一个时刻触发一次。 - - 停机之后,只记录最近一次到期的触发。 - - Automation 已有排队中的 invocation 时,`pending_delivery` 会跳过新的触发。 - - `once` Automation 在其计划 invocation 到达终态时完成。 - - 暂停会保留排队中的计划 invocation 直到恢复;手动 invocation 在任何状态下仍会投递。 - - 暂停已暂停的 Automation、恢复已激活的 Automation,会原样返回。暂停或恢复已完成的 Automation、恢复过去 `run_at` 且没有待处理 invocation 的 `once` Automation,以及在计划 invocation 产生后修改 `run_at`,均返回 409 `conflict_error`。 - - 过去的 `run_at`、另一类型的时间字段、另一类型的目标、无效的 cron 表达式或时区、没有适用部署默认值而缺少 `model` 的内联 `agent`,返回 400 `invalid_request_error`,param 和消息为 Core 自己的内容。 - - 被跳过的 `existing_session` invocation 带有目标的 `session_id`;`new_session` invocation 在其 Session 存在之前为 null。 - - 超出 1–100 的 `limit` 会被截断,无法解析的 `after` 返回 404。 - - 创建时用 Session 创建在创建任何东西之前执行的检查校验目标(形状、Environment Template、已保存的 Agent、模型和 provider、Vault 绑定、Harness 支持),并校验 `existing_session` 目标的 Session 是否存在。部署就绪、准入和租约在每次投递时检查。 - - 目标中保存的引用在每次 invocation 时解析。排队中的 invocation 投递的是它被记录或触发时的目标;更新只作用于之后的 invocation。 - - invocation 保留到 Automation 被删除。目标 Session 被删除后,每次投递都会失败。 + - [指令投递](automations.md#targets)为一条输入消息。 + - [cron 语法与夏令时](automations.md#schedules)。 + - [停机、跳过、完成与暂停](automations.md#invocations)。 + - [冲突与拒绝的情形](automations.md#state-changes),包括 `run_at` 上限。 + - [目标校验与保存的引用](automations.md#targets)。 + - [分页](automations.md):`limit` 截断与无法解析的 `after`。 - 当一个请求存在多项故障时的错误顺序,以及错误处理、默认值和载荷限制的总体一致性。 - Subagent 的函数调用:Core 将其作为运行该子级的根 Turn 的待执行操作,`turn_id` 为该 Turn,并把子级的 `function_call` Item 以 `agent.session.turn.item.added` 发布一次,`turn_id` 为子级 Turn ID,`output_index` 为 null;结果可以指定其中任一 Turn。该 Turn 为 `waiting`、Session 为 `requires_action`,根输出可继续,`call_id` 等于该子级 `function_call` Item 的 `call_id`。Claude Code 在根 Turn 收敛时才发布子级的 Item,晚于该待执行操作。 - 官方服务是否发出 Environment `pending` 以及何时发出:Core 在 hosted Environment 初始化开始时发出,而仅有的一个官方样本只显示了创建约 29 秒后的 `ready`。 diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index 82c472b57..5e7b91f3c 100644 --- a/contracts/agents-api/zh/wire-semantics.md +++ b/contracts/agents-api/zh/wire-semantics.md @@ -1,7 +1,7 @@ --- title: "API 行为" source: contracts/agents-api/wire-semantics.md -source_hash: 73dcaa859ac6e54b4b51950922b8cb781daff8e159e718dddde363a8e089168a +source_hash: b0e9bfc53d015b5daacfc846ff8e1a27d3f6021f89f84131afdb1a892f2c868e --- 已锁定版本的 OpenAI Python SDK([upstream.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))定义了 `/v1` 路由、字段和类型。本页面说明这些类型未作规定之处 Core 的行为,例如状态码、错误字段、默认值和列表边界,以及 Core 与官方服务存在差异的地方。[OpenAI 兼容性](index.md) 列出了这些差异和尚存缺口;[Sessions, events and history](sessions-events.md)、[message content](message-content.md)、[Vaults](vaults.md)、[source Files and Skills](source-files.md) 和 [Environment files and Artifacts](environment-files.md) 分别负责各自资源的规则。 diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index 9c1c8cb6b..b06389a46 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -561,8 +561,6 @@ A Session freezes the template when it starts. Details: [Environment Templates]( ## Automations -An Automation saves a schedule and a target. A `periodic` Automation fires on a five-field cron `expression` in an IANA `timezone`; a `once` Automation fires at `run_at`. The target either creates a new Session (`new_session`, the body of Session create) or sends `instructions` to an existing one (`existing_session`): - ```python automation = client.beta.agents.automations.create( type="periodic", diff --git a/docs/zh/api/public-agent-api.md b/docs/zh/api/public-agent-api.md index e88ee4d56..f980de19f 100644 --- a/docs/zh/api/public-agent-api.md +++ b/docs/zh/api/public-agent-api.md @@ -1,7 +1,7 @@ --- title: "Agents API 指南" source: docs/api/public-agent-api.md -source_hash: a54b7abdac172ce02ed09ad501f33eff155278236a188a4897117eca35fb4055 +source_hash: 8846a755d119bc752f903715629d7be7c6aefef4498766c00889a714ac11d4b5 --- Core 在 `/v1` 提供 OpenAI Agents API。本指南用官方 OpenAI SDK 和普通 HTTP 两种方式演示每项常见任务,并说明 Core 与 OpenAI 的差异。如果尚未启动过 Session,请先阅读[运行第一个 Session](../getting-started/quickstart.md)。 @@ -563,8 +563,6 @@ Session 启动时会冻结模板。详细信息请参阅 [Environment 模板](.. ## Automations {#automations} -Automation 保存一个计划和一个目标。`periodic` Automation 按 IANA `timezone` 中的五字段 cron `expression` 触发;`once` Automation 在 `run_at` 触发。目标要么创建新 Session(`new_session`,即 Session 创建的请求体),要么向已有 Session 发送 `instructions`(`existing_session`): - ```python automation = client.beta.agents.automations.create( type="periodic", diff --git a/scripts/generate-public-api.py b/scripts/generate-public-api.py index 76d70b9c9..bab980913 100644 --- a/scripts/generate-public-api.py +++ b/scripts/generate-public-api.py @@ -233,7 +233,7 @@ def go_shape(document, schema, required=False): elif kind == 'array': fields = ['kind: arrayValue', 'items: &' + go_shape(document, value['items'])] elif kind == 'integer': - fields = ['kind: integerValue'] + ([f'minimum: {value["minimum"]}'] if 'minimum' in value else []) + fields = ['kind: integerValue', f'minimum: {value.get("minimum", "-1 << 63")}'] else: fields = [{'string': 'kind: stringValue', 'boolean': 'kind: booleanValue'}[kind]] if required: diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index 33d06f433..10ff1ad47 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -29,7 +29,7 @@ Adapters' execution repositories require a `*pgunit.Lease` at construction, and Domain owners, each with its PostgreSQL adapter under `internal/persistence/postgres`: - `agents` (`agentpg`): saved Agents, their configuration merge and bounds, and the encrypted model-provider bundle bound to each Agent. -- `automations` (`automationpg`): Automations, schedules and invocation records, and the delivery pass; see [Automations](../../contracts/agents-api/automations.md). +- `automations` (`automationpg`): Automations, schedules and invocation records; see [Automations](../../contracts/agents-api/automations.md). - `files` (`filepg`): source Files. - `projects` (`projectpg`): Projects, Project API keys and key resolution for authentication. `projects` validates Project and key names by rune count, separately from the byte limits on node names, and decides key issuance over the share-locked Project so an archive and an issuance never both commit. - `vaults` (`vaultpg`): Vaults and Credentials, the encryption of Credential secrets, OAuth access-token refresh, and the MCP credential selection that Session creation freezes and the Dispatcher's `Credentials` resolves into a bearer token. diff --git a/services/core/cmd/server/http_routes_test.go b/services/core/cmd/server/http_routes_test.go index 1e08b2e5b..028db6ffd 100644 --- a/services/core/cmd/server/http_routes_test.go +++ b/services/core/cmd/server/http_routes_test.go @@ -88,7 +88,7 @@ func daemonComposition(t testing.TB) http.Handler { if err != nil { t.Fatal(err) } - apiHandler, err := api.NewHandler(api.Dependencies{ + apiHandler, _, err := api.NewHandler(api.Dependencies{ Engine: "codex", CoreKeys: admin, InstallationBindings: struct{ api.InstallationBindings }{}, Projects: struct{ api.Projects }{}, ProjectsReader: trapProjectsReader{keys: keys}, ModelProviders: struct{ api.ModelProviders }{}, ModelProvidersReader: struct{ api.ModelProvidersReader }{}, diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index bf00fa67c..afa79654a 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -383,7 +383,7 @@ func run(config processconfig.Config) error { ConfigurationDiscovery: managedNodes.setup, }, } - apiHandler, deliverAutomations, err := api.NewHandlerWithDelivery(deps) + apiHandler, deliverAutomations, err := api.NewHandler(deps) if err != nil { return err } diff --git a/services/core/internal/api/automation_delivery.go b/services/core/internal/api/automation_delivery.go index f920556ca..3a67a3cd4 100644 --- a/services/core/internal/api/automation_delivery.go +++ b/services/core/internal/api/automation_delivery.go @@ -13,7 +13,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -38,13 +37,19 @@ func (h *Handler) deliverAutomations(ctx context.Context) (*int64, int64, error) } deliveries, err := h.Automations.Deliverable(ctx, automationBatch) if err != nil { - return nil, failed.Load() + 1, err + return nil, 1, err } var wg sync.WaitGroup for _, d := range deliveries { wg.Add(1) go func() { defer wg.Done() + defer func() { + if p := recover(); p != nil { + log.Ctx(ctx).Error("Automation delivery panicked", "invocation_id", d.InvocationID, "panic", p) + failed.Add(1) + } + }() outcome, done := h.deliverAutomation(ctx, d) if !done { return @@ -101,11 +106,15 @@ func (h *Handler) deliverAutomation(ctx context.Context, d automations.Delivery) outcome.SessionID = target.SessionID return outcome, true } - if errors.Is(err, sessions.ErrTurnConflict) || errors.Is(err, execution.ErrExecutionUnavailable) || ctx.Err() != nil { + if errors.Is(err, sessions.ErrTurnConflict) { return outcome, false } writeInputError(response, request, err) } + // A server error, a busy Turn or a timeout is temporary; only a client error is permanent. + if response.Code >= 500 || ctx.Err() != nil { + return outcome, false + } var envelope struct { Error struct { Type string `json:"type"` @@ -114,14 +123,10 @@ func (h *Handler) deliverAutomation(ctx context.Context, d automations.Delivery) } `json:"error"` } _ = json.Unmarshal(response.Body.Bytes(), &envelope) - code := envelope.Error.Type + outcome.Status, outcome.Code, outcome.Message = "failed", envelope.Error.Type, envelope.Error.Message if envelope.Error.Code != nil { - code = *envelope.Error.Code - } - if response.Code == http.StatusServiceUnavailable && (code == "execution_unavailable" || code == "sandbox_reset_in_progress") || ctx.Err() != nil { - return outcome, false + outcome.Code = *envelope.Error.Code } - outcome.Status, outcome.Code, outcome.Message = "failed", code, envelope.Error.Message if outcome.Code == "" { outcome.Code, outcome.Message = "internal_error", "The delivery could not be completed." } diff --git a/services/core/internal/api/automation_delivery_test.go b/services/core/internal/api/automation_delivery_test.go new file mode 100644 index 000000000..3dda4e0fc --- /dev/null +++ b/services/core/internal/api/automation_delivery_test.go @@ -0,0 +1,35 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +// A server error or a busy Turn keeps the invocation queued; only a client +// error settles it as failed. +func TestAutomationDeliveryRetriesTemporaryErrors(t *testing.T) { + for _, tc := range []struct { + err error + done bool + }{ + {errors.New("database down"), false}, + {sessions.ErrTurnConflict, false}, + {sessions.ErrNotFound, true}, + } { + deps, fakes := testDependencies(t) + fakes.inputAdmission.submitInputs = func(context.Context, string, string, string, []sessions.Input) ([]sessions.InputReceipt, error) { + return nil, tc.err + } + h := &Handler{Dependencies: deps} + request, _ := json.Marshal(map[string]string{"session_id": "s", "instructions": "go"}) + outcome, done := h.deliverAutomation(context.Background(), automations.Delivery{InvocationID: "i", TargetType: "existing_session", Request: request}) + if done != tc.done || (done && outcome.Status != "failed") { + t.Fatalf("%v: done=%v outcome=%+v", tc.err, done, outcome) + } + } +} diff --git a/services/core/internal/api/automations.go b/services/core/internal/api/automations.go index 9bbd6534c..0f8c40fdb 100644 --- a/services/core/internal/api/automations.go +++ b/services/core/internal/api/automations.go @@ -59,12 +59,18 @@ func (h *Handler) createAutomation(w http.ResponseWriter, r *http.Request) { return } var kind struct { - Type string `json:"type"` + Type json.RawMessage `json:"type"` } _ = json.Unmarshal(raw, &kind) pinned := createAutomationParamsPeriodic - if kind.Type == automations.Once { + switch string(kind.Type) { + case `"once"`: pinned = createAutomationParamsOnce + case `"periodic"`, "": + default: + if writeFieldError(w, checkValue("type", kind.Type, shape{kind: enumValue, values: []string{automations.Periodic, automations.Once}})) { + return + } } if writeFieldError(w, validateBody(raw, pinned)) { return @@ -90,7 +96,11 @@ func (h *Handler) createAutomation(w http.ResponseWriter, r *http.Request) { } a := automations.Automation{TenantID: tenantID(r), Type: body.Type, Name: body.Name, Metadata: metadataOrEmpty(body.Metadata), Expression: body.Expression, Timezone: body.Timezone, TargetType: target.kind, Target: target.projection, Contents: target.contents} if body.Type == automations.Once { - runAt := time.Unix(body.RunAt, 0) + runAt, err := automations.RunAt(body.RunAt) + if err != nil { + writeAutomationsError(w, r, err) + return + } a.RunAt = &runAt } created, err := h.Automations.Create(r.Context(), a) @@ -121,7 +131,11 @@ func (h *Handler) updateAutomation(w http.ResponseWriter, r *http.Request) { return } if body.RunAt != nil { - runAt := time.Unix(*body.RunAt, 0) + runAt, err := automations.RunAt(*body.RunAt) + if err != nil { + writeAutomationsError(w, r, err) + return + } patch.RunAt = &runAt } if body.Target != nil { @@ -154,7 +168,12 @@ func (h *Handler) listAutomations(w http.ResponseWriter, r *http.Request) { if !ok { return } - page, err := h.AutomationsReader.List(r.Context(), tenantID(r), automations.ListQuery{After: options.after, Limit: options.limit, Ascending: options.ascending, SessionID: sessionFilter(r)}) + // A supplied empty session_id matches no Automation, as an empty agent_id matches no Session. + var sessionID *string + if values, present := r.URL.Query()["session_id"]; present { + sessionID = &values[0] + } + page, err := h.AutomationsReader.List(r.Context(), tenantID(r), automations.ListQuery{After: options.after, Limit: options.limit, Ascending: options.ascending, SessionID: sessionID}) if err != nil { writeAutomationsError(w, r, err) return @@ -233,33 +252,20 @@ func (h *Handler) listAutomationInvocations(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusOK, response) } -// sessionFilter reads the session_id filter. A supplied empty value matches no -// Automation, as an empty agent_id matches no Session. -func sessionFilter(r *http.Request) *string { - if values, present := r.URL.Query()["session_id"]; present { - return &values[0] - } - return nil -} - // automationFields checks the name and metadata of a request. A nil name is // not checked. func automationFields(name *string, values map[string]string) error { if name != nil { - if err := boundedText("name", *name, 256); err != nil { - return err + switch n := utf8.RuneCountInString(*name); { + case n < 1: + return &fieldError{param: "name", message: "Invalid 'name': empty string. Expected a string with minimum length 1, but got an empty string instead."} + case n > 256: + return &fieldError{param: "name", message: fmt.Sprintf("Invalid 'name': string too long. Expected a string with maximum length 256, but got a string with length %d instead.", n)} } } return metadataFieldError(metadata.Validate(values)) } -func boundedText(param, value string, limit int) error { - if n := utf8.RuneCountInString(value); n < 1 || n > limit { - return &fieldError{param: param, message: fmt.Sprintf("%s must contain 1 to %d characters.", param, limit)} - } - return nil -} - // prefixTarget names a Session create field error under the target. func prefixTarget(err error) error { var field *fieldError @@ -313,13 +319,17 @@ func (h *Handler) existingSessionTarget(r *http.Request, raw json.RawMessage) (a if utf8.RuneCountInString(target.Instructions) > maxInstructions { return fail, &fieldError{param: "target.instructions", message: "instructions must not exceed 1048576 characters."} } - if _, err := executionInputs([]json.RawMessage{instructionsEvent(target.Instructions)}); err != nil { + inputs, err := executionInputs([]json.RawMessage{instructionsEvent(target.Instructions)}) + if err != nil { return fail, &fieldError{param: "target.instructions", message: "instructions must be non-empty text this service can store."} } session, err := h.SessionsReader.GetSession(r.Context(), tenantID(r), target.SessionID) if err != nil { return fail, err } + if h.Policy.ValidateEngineInputs(session.Engine, session.Configuration, inputs) != nil { + return fail, &fieldError{param: "target.instructions", message: "The Session's Harness does not accept these instructions."} + } // The saved target names the Session by its canonical id, whatever form the request used. contents, _ := json.Marshal(map[string]string{"session_id": session.ID, "instructions": target.Instructions}) projection, _ := json.Marshal(v1.AutomationTarget{Type: "existing_session", SessionID: &session.ID, Instructions: &target.Instructions}) @@ -343,6 +353,9 @@ func (h *Handler) newSessionTarget(r *http.Request, raw json.RawMessage, fields if inline == saved { return fail, &fieldError{param: "target", message: "Provide exactly one of agent and agent_id."} } + if length := utf8.RuneCountInString(stringField(fields["agent_id"])); length > 64 { + return fail, &fieldError{param: "target.agent_id", message: fmt.Sprintf("Invalid 'target.agent_id': string too long. Expected a string with maximum length 64, but got a string with length %d instead.", length)} + } if inline { var agent map[string]json.RawMessage _ = json.Unmarshal(fields["agent"], &agent) @@ -363,6 +376,12 @@ func (h *Handler) newSessionTarget(r *http.Request, raw json.RawMessage, fields } plan, report, err := h.planSession(r.Context(), tenantID(r), "", &input) if err != nil { + var required *modelProviderRequiredError + if errors.As(err, &required) { + report = func(w http.ResponseWriter, _ *http.Request, _ error) { + writeError(w, http.StatusBadRequest, "model_provider_required", required.message, "target.x_agents_core.model_provider") + } + } return automationTarget{report: report}, prefixTarget(err) } if inline { @@ -421,3 +440,10 @@ func automationEnvironment(input sessionRequest) *v1.AutomationEnvironment { } return result } + +// stringField reads a JSON string, or "" for anything else. +func stringField(raw json.RawMessage) string { + var value string + _ = json.Unmarshal(raw, &value) + return value +} diff --git a/services/core/internal/api/dependencies.go b/services/core/internal/api/dependencies.go index 98f767be7..c3b70d4bf 100644 --- a/services/core/internal/api/dependencies.go +++ b/services/core/internal/api/dependencies.go @@ -104,16 +104,10 @@ type Handler struct { requestLogs *requestLogs } -// NewHandler builds the HTTP handler from complete dependencies. -func NewHandler(deps Dependencies) (http.Handler, error) { - handler, _, err := NewHandlerWithDelivery(deps) - return handler, err -} - -// NewHandlerWithDelivery builds the HTTP handler and the pass that records due +// NewHandler builds the HTTP handler and the pass that records due // Automation occurrences and delivers queued invocations through the handler's // Session create and input paths. -func NewHandlerWithDelivery(deps Dependencies) (http.Handler, func(context.Context) (*int64, int64, error), error) { +func NewHandler(deps Dependencies) (http.Handler, func(context.Context) (*int64, int64, error), error) { if err := deps.validate(); err != nil { return nil, nil, err } diff --git a/services/core/internal/api/dependencies_test.go b/services/core/internal/api/dependencies_test.go index 15058e2f5..b1f7236d8 100644 --- a/services/core/internal/api/dependencies_test.go +++ b/services/core/internal/api/dependencies_test.go @@ -155,7 +155,7 @@ func coreKeys(t testing.TB, tokens ...string) *DeploymentAuthenticator { // NewHandler rejects them. func newTestHandler(t testing.TB, deps Dependencies) http.Handler { t.Helper() - h, err := NewHandler(deps) + h, _, err := NewHandler(deps) if err != nil { t.Fatal(err) } @@ -169,11 +169,11 @@ func noDeploymentModelProvider(context.Context, string) (*modelconfiguration.Sna func TestNewHandlerAcceptsCompleteDependencies(t *testing.T) { deps, _ := testDependencies(t) - if _, err := NewHandler(deps); err != nil { + if _, _, err := NewHandler(deps); err != nil { t.Fatal(err) } deps.Execution.NativeInstaller = &NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/"} - if _, err := NewHandler(deps); err != nil { + if _, _, err := NewHandler(deps); err != nil { t.Fatal(err) } } @@ -202,7 +202,7 @@ func TestNewHandlerRejectsIncompleteDependencies(t *testing.T) { t.Run(test.missing, func(t *testing.T) { deps, f := testDependencies(t) test.change(&deps, f) - if h, err := NewHandler(deps); err == nil || h != nil || !strings.Contains(err.Error(), test.missing) { + if h, _, err := NewHandler(deps); err == nil || h != nil || !strings.Contains(err.Error(), test.missing) { t.Fatalf("NewHandler = %v, %v", h, err) } }) diff --git a/services/core/internal/api/official_shapes.gen.go b/services/core/internal/api/official_shapes.gen.go index 84579a7f5..ac84ff905 100644 --- a/services/core/internal/api/official_shapes.gen.go +++ b/services/core/internal/api/official_shapes.gen.go @@ -569,7 +569,7 @@ var ( {"name", shape{kind: stringValue, required: true}}, {"target", shape{kind: openObject, required: true}}, {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}}}, - {"run_at", shape{kind: integerValue, required: true}}, + {"run_at", shape{kind: integerValue, minimum: -1 << 63, required: true}}, }} updateAutomationParams = shape{kind: objectValue, members: []member{ {"name", shape{kind: stringValue}}, @@ -577,7 +577,7 @@ var ( {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}}}, {"expression", shape{kind: stringValue}}, {"timezone", shape{kind: stringValue}}, - {"run_at", shape{kind: integerValue}}, + {"run_at", shape{kind: integerValue, minimum: -1 << 63}}, }} automationTargetParamNewSession = shape{kind: objectValue, members: []member{ {"type", shape{kind: enumValue, values: []string{"new_session"}, required: true}}, diff --git a/services/core/internal/automations/automation.go b/services/core/internal/automations/automation.go index a8ee645f1..cdaaf0bcb 100644 --- a/services/core/internal/automations/automation.go +++ b/services/core/internal/automations/automation.go @@ -41,7 +41,7 @@ type Automation struct { Contents json.RawMessage CreatedAt time.Time // Pending and Queued report a queued scheduled invocation and a queued - // invocation of any source. Only locked reads fill them. + // invocation of any source. Pending, Queued bool } @@ -80,6 +80,17 @@ type Patch struct { RunAt *time.Time } +// maxRunAt is the last second of year 9999, the last a stored timestamp holds. +const maxRunAt = 253402300799 + +// RunAt converts a Unix timestamp in seconds, which must not pass year 9999. +func RunAt(unix int64) (time.Time, error) { + if unix > maxRunAt { + return time.Time{}, &InvalidError{"run_at", "run_at must not be later than 253402300799."} + } + return time.Unix(unix, 0), nil +} + // Occurrence is what a due Automation records. type Occurrence struct { ScheduledAt time.Time diff --git a/services/core/internal/automations/cron.go b/services/core/internal/automations/cron.go index 77740009c..3c3346db5 100644 --- a/services/core/internal/automations/cron.go +++ b/services/core/internal/automations/cron.go @@ -12,9 +12,10 @@ import ( type Schedule struct { minutes, hours, days, months, weekdays uint64 // anyDay and anyWeekday report a day field that starts with "*". When - // neither does, a day matches either field. - anyDay, anyWeekday bool - location *time.Location + // neither does, a day matches either field. fixed reports a minute and an + // hour field that both do not. + anyDay, anyWeekday, fixed bool + location *time.Location } var ( @@ -39,7 +40,7 @@ func ParseSchedule(expression, timezone string) (*Schedule, error) { if err != nil { return nil, &InvalidError{"timezone", "timezone must be an IANA time zone name."} } - s := &Schedule{location: location, anyDay: strings.HasPrefix(fields[2], "*"), anyWeekday: strings.HasPrefix(fields[4], "*")} + s := &Schedule{location: location, anyDay: strings.HasPrefix(fields[2], "*"), anyWeekday: strings.HasPrefix(fields[4], "*"), fixed: !strings.HasPrefix(fields[0], "*") && !strings.HasPrefix(fields[1], "*")} for i, target := range []struct { mask *uint64 min, max int @@ -108,43 +109,61 @@ func parseValue(text string, names []string, first int) (int, error) { } // Next returns the first occurrence after the given time, or false when there -// is none in the next five years. It walks wall-clock minutes in the Schedule's -// zone. A wall-clock minute that does not exist is skipped, and one that occurs -// twice fires once, at its first instant. +// is none in the next five years. It follows crontab(5) around daylight saving +// changes. A fixed-time Schedule, whose minute and hour fields do not start +// with "*", runs a wall-clock time in a gap at the first instant after the gap +// and a repeated one only at its first instant. Any other Schedule runs at +// every instant whose wall-clock time matches: never in a gap, on both passes +// of a repeated hour. func (s *Schedule) Next(after time.Time) (time.Time, bool) { - a := after.In(s.location) - civil := time.Date(a.Year(), a.Month(), a.Day(), a.Hour(), a.Minute()+1, 0, 0, time.UTC) - limit := civil.AddDate(5, 0, 0) - for civil.Before(limit) { - year, month, day := civil.Date() + t := after.Truncate(time.Minute).Add(time.Minute) + limit := t.AddDate(5, 0, 0) + for t.Before(limit) { + w := t.In(s.location) switch { - case s.months&(1<>'type' = 'existing_session' AND target->>'session_id' = sqlc.narg(session_id)::text)) +SELECT a.id, a.tenant_id, a.type, a.name, a.metadata, a.status, a.run_at, a.expression, a.timezone, a.next_run_at, a.target, a.created_at, EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued' AND i.scheduled_at IS NOT NULL) AS pending, + EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued') AS queued +FROM automations a +WHERE a.tenant_id = sqlc.arg(tenant_id) + AND (sqlc.narg(session_id)::text IS NULL OR (a.target->>'type' = 'existing_session' AND a.target->>'session_id' = sqlc.narg(session_id)::text)) AND (sqlc.narg(after_created)::timestamptz IS NULL - OR (NOT sqlc.arg(ascending)::boolean AND (created_at, id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) - OR (sqlc.arg(ascending)::boolean AND (created_at, id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) + OR (NOT sqlc.arg(ascending)::boolean AND (a.created_at, a.id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (a.created_at, a.id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) ORDER BY - CASE WHEN sqlc.arg(ascending)::boolean THEN created_at END ASC, - CASE WHEN sqlc.arg(ascending)::boolean THEN id END ASC, - CASE WHEN NOT sqlc.arg(ascending)::boolean THEN created_at END DESC, - CASE WHEN NOT sqlc.arg(ascending)::boolean THEN id END DESC + CASE WHEN sqlc.arg(ascending)::boolean THEN a.created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN a.id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN a.created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN a.id END DESC LIMIT sqlc.arg(page_limit); -- name: InsertManualInvocation :one -INSERT INTO automation_invocations (id, automation_id, status, delivery) -SELECT sqlc.arg(id), a.id, 'queued', a.target_contents FROM automations a WHERE a.tenant_id = sqlc.arg(tenant_id) AND a.id = sqlc.arg(automation_id) +INSERT INTO automation_invocations (id, automation_id, status, session_id, delivery) +SELECT sqlc.arg(id), a.id, 'queued', (CASE WHEN a.target->>'type' = 'existing_session' THEN a.target->>'session_id' END)::uuid, a.target_contents FROM automations a WHERE a.tenant_id = sqlc.arg(tenant_id) AND a.id = sqlc.arg(automation_id) RETURNING created_at; -- name: GetInvocation :one @@ -69,7 +69,8 @@ ORDER BY a.next_run_at LIMIT sqlc.arg(page_limit); -- name: InsertScheduledInvocation :exec INSERT INTO automation_invocations (id, automation_id, scheduled_at, automation_snapshot, status, skip_reason, session_id, delivery) -SELECT sqlc.arg(id), a.id, sqlc.arg(scheduled_at), sqlc.arg(automation_snapshot), sqlc.arg(status)::text, sqlc.narg(skip_reason)::text, sqlc.narg(session_id), +SELECT sqlc.arg(id), a.id, sqlc.arg(scheduled_at), sqlc.arg(automation_snapshot), sqlc.arg(status)::text, sqlc.narg(skip_reason)::text, + (CASE WHEN a.target->>'type' = 'existing_session' THEN a.target->>'session_id' END)::uuid, CASE WHEN sqlc.arg(status)::text = 'queued' THEN a.target_contents END FROM automations a WHERE a.id = sqlc.arg(automation_id) ON CONFLICT (automation_id, scheduled_at) DO NOTHING; @@ -97,7 +98,7 @@ WITH picked AS ( SELECT id, automation_id, tenant_id, target_type, delivery, subject_kind, subject_id, organization_id, project_id FROM picked; -- name: SettleInvocation :one -UPDATE automation_invocations SET status = $2, session_id = sqlc.narg(session_id), error_code = sqlc.narg(error_code), error_message = sqlc.narg(error_message), delivery = NULL +UPDATE automation_invocations SET status = $2, session_id = COALESCE(sqlc.narg(session_id), session_id), error_code = sqlc.narg(error_code), error_message = sqlc.narg(error_message), delivery = NULL WHERE id = $1 AND status = 'queued' RETURNING automation_id, scheduled_at; -- name: CompleteOnceAutomation :exec diff --git a/services/core/internal/db/sqlc/automations.sql.go b/services/core/internal/db/sqlc/automations.sql.go index 1b6522c91..aa2ff4feb 100644 --- a/services/core/internal/db/sqlc/automations.sql.go +++ b/services/core/internal/db/sqlc/automations.sql.go @@ -26,23 +26,6 @@ func (q *Queries) AdvanceAutomation(ctx context.Context, arg AdvanceAutomationPa return err } -const automationFlags = `-- name: AutomationFlags :one -SELECT EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = $1 AND i.status = 'queued' AND i.scheduled_at IS NOT NULL) AS pending, - EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = $1 AND i.status = 'queued') AS queued -` - -type AutomationFlagsRow struct { - Pending bool `json:"pending"` - Queued bool `json:"queued"` -} - -func (q *Queries) AutomationFlags(ctx context.Context, id pgtype.UUID) (AutomationFlagsRow, error) { - row := q.db.QueryRow(ctx, automationFlags, id) - var i AutomationFlagsRow - err := row.Scan(&i.Pending, &i.Queued) - return i, err -} - const completeOnceAutomation = `-- name: CompleteOnceAutomation :exec UPDATE automations SET status = 'completed', next_run_at = NULL WHERE id = $1 AND type = 'once' ` @@ -168,8 +151,9 @@ func (q *Queries) DueAutomationIDs(ctx context.Context, arg DueAutomationIDsPara } const getAutomation = `-- name: GetAutomation :one -SELECT id, tenant_id, type, name, metadata, status, run_at, expression, timezone, next_run_at, target, created_at FROM automations -WHERE tenant_id = $1 AND id = $2 +SELECT a.id, a.tenant_id, a.type, a.name, a.metadata, a.status, a.run_at, a.expression, a.timezone, a.next_run_at, a.target, a.created_at, EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued' AND i.scheduled_at IS NOT NULL) AS pending, + EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued') AS queued +FROM automations a WHERE a.tenant_id = $1 AND a.id = $2 ` type GetAutomationParams struct { @@ -190,6 +174,8 @@ type GetAutomationRow struct { NextRunAt pgtype.Timestamptz `json:"next_run_at"` Target []byte `json:"target"` CreatedAt pgtype.Timestamptz `json:"created_at"` + Pending bool `json:"pending"` + Queued bool `json:"queued"` } func (q *Queries) GetAutomation(ctx context.Context, arg GetAutomationParams) (GetAutomationRow, error) { @@ -208,6 +194,8 @@ func (q *Queries) GetAutomation(ctx context.Context, arg GetAutomationParams) (G &i.NextRunAt, &i.Target, &i.CreatedAt, + &i.Pending, + &i.Queued, ) return i, err } @@ -296,8 +284,8 @@ func (q *Queries) InsertAutomation(ctx context.Context, arg InsertAutomationPara } const insertManualInvocation = `-- name: InsertManualInvocation :one -INSERT INTO automation_invocations (id, automation_id, status, delivery) -SELECT $1, a.id, 'queued', a.target_contents FROM automations a WHERE a.tenant_id = $2 AND a.id = $3 +INSERT INTO automation_invocations (id, automation_id, status, session_id, delivery) +SELECT $1, a.id, 'queued', (CASE WHEN a.target->>'type' = 'existing_session' THEN a.target->>'session_id' END)::uuid, a.target_contents FROM automations a WHERE a.tenant_id = $2 AND a.id = $3 RETURNING created_at ` @@ -316,9 +304,10 @@ func (q *Queries) InsertManualInvocation(ctx context.Context, arg InsertManualIn const insertScheduledInvocation = `-- name: InsertScheduledInvocation :exec INSERT INTO automation_invocations (id, automation_id, scheduled_at, automation_snapshot, status, skip_reason, session_id, delivery) -SELECT $1, a.id, $2, $3, $4::text, $5::text, $6, +SELECT $1, a.id, $2, $3, $4::text, $5::text, + (CASE WHEN a.target->>'type' = 'existing_session' THEN a.target->>'session_id' END)::uuid, CASE WHEN $4::text = 'queued' THEN a.target_contents END -FROM automations a WHERE a.id = $7 +FROM automations a WHERE a.id = $6 ON CONFLICT (automation_id, scheduled_at) DO NOTHING ` @@ -328,7 +317,6 @@ type InsertScheduledInvocationParams struct { AutomationSnapshot []byte `json:"automation_snapshot"` Status string `json:"status"` SkipReason pgtype.Text `json:"skip_reason"` - SessionID pgtype.UUID `json:"session_id"` AutomationID pgtype.UUID `json:"automation_id"` } @@ -339,24 +327,25 @@ func (q *Queries) InsertScheduledInvocation(ctx context.Context, arg InsertSched arg.AutomationSnapshot, arg.Status, arg.SkipReason, - arg.SessionID, arg.AutomationID, ) return err } const listAutomations = `-- name: ListAutomations :many -SELECT id, tenant_id, type, name, metadata, status, run_at, expression, timezone, next_run_at, target, created_at FROM automations -WHERE tenant_id = $1 - AND ($2::text IS NULL OR (target->>'type' = 'existing_session' AND target->>'session_id' = $2::text)) +SELECT a.id, a.tenant_id, a.type, a.name, a.metadata, a.status, a.run_at, a.expression, a.timezone, a.next_run_at, a.target, a.created_at, EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued' AND i.scheduled_at IS NOT NULL) AS pending, + EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued') AS queued +FROM automations a +WHERE a.tenant_id = $1 + AND ($2::text IS NULL OR (a.target->>'type' = 'existing_session' AND a.target->>'session_id' = $2::text)) AND ($3::timestamptz IS NULL - OR (NOT $4::boolean AND (created_at, id) < ($3::timestamptz, $5::uuid)) - OR ($4::boolean AND (created_at, id) > ($3::timestamptz, $5::uuid))) + OR (NOT $4::boolean AND (a.created_at, a.id) < ($3::timestamptz, $5::uuid)) + OR ($4::boolean AND (a.created_at, a.id) > ($3::timestamptz, $5::uuid))) ORDER BY - CASE WHEN $4::boolean THEN created_at END ASC, - CASE WHEN $4::boolean THEN id END ASC, - CASE WHEN NOT $4::boolean THEN created_at END DESC, - CASE WHEN NOT $4::boolean THEN id END DESC + CASE WHEN $4::boolean THEN a.created_at END ASC, + CASE WHEN $4::boolean THEN a.id END ASC, + CASE WHEN NOT $4::boolean THEN a.created_at END DESC, + CASE WHEN NOT $4::boolean THEN a.id END DESC LIMIT $6 ` @@ -382,6 +371,8 @@ type ListAutomationsRow struct { NextRunAt pgtype.Timestamptz `json:"next_run_at"` Target []byte `json:"target"` CreatedAt pgtype.Timestamptz `json:"created_at"` + Pending bool `json:"pending"` + Queued bool `json:"queued"` } func (q *Queries) ListAutomations(ctx context.Context, arg ListAutomationsParams) ([]ListAutomationsRow, error) { @@ -413,6 +404,8 @@ func (q *Queries) ListAutomations(ctx context.Context, arg ListAutomationsParams &i.NextRunAt, &i.Target, &i.CreatedAt, + &i.Pending, + &i.Queued, ); err != nil { return nil, err } @@ -498,8 +491,9 @@ func (q *Queries) ListInvocations(ctx context.Context, arg ListInvocationsParams } const lockAutomation = `-- name: LockAutomation :one -SELECT id, tenant_id, type, name, metadata, status, run_at, expression, timezone, next_run_at, target, created_at FROM automations -WHERE tenant_id = $1 AND id = $2 FOR UPDATE +SELECT a.id, a.tenant_id, a.type, a.name, a.metadata, a.status, a.run_at, a.expression, a.timezone, a.next_run_at, a.target, a.created_at, EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued' AND i.scheduled_at IS NOT NULL) AS pending, + EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued') AS queued +FROM automations a WHERE a.tenant_id = $1 AND a.id = $2 FOR UPDATE OF a ` type LockAutomationParams struct { @@ -520,6 +514,8 @@ type LockAutomationRow struct { NextRunAt pgtype.Timestamptz `json:"next_run_at"` Target []byte `json:"target"` CreatedAt pgtype.Timestamptz `json:"created_at"` + Pending bool `json:"pending"` + Queued bool `json:"queued"` } func (q *Queries) LockAutomation(ctx context.Context, arg LockAutomationParams) (LockAutomationRow, error) { @@ -538,12 +534,14 @@ func (q *Queries) LockAutomation(ctx context.Context, arg LockAutomationParams) &i.NextRunAt, &i.Target, &i.CreatedAt, + &i.Pending, + &i.Queued, ) return i, err } const settleInvocation = `-- name: SettleInvocation :one -UPDATE automation_invocations SET status = $2, session_id = $3, error_code = $4, error_message = $5, delivery = NULL +UPDATE automation_invocations SET status = $2, session_id = COALESCE($3, session_id), error_code = $4, error_message = $5, delivery = NULL WHERE id = $1 AND status = 'queued' RETURNING automation_id, scheduled_at ` diff --git a/services/core/internal/execution/environment_admission.go b/services/core/internal/execution/environment_admission.go index 5e19b55e9..266591c6a 100644 --- a/services/core/internal/execution/environment_admission.go +++ b/services/core/internal/execution/environment_admission.go @@ -49,7 +49,7 @@ func (w *Worker) validateEnvironmentAdmission(ctx context.Context, engine string } func (w *Worker) validateCreation(ctx context.Context, input sessions.CreateSession) error { - if err := w.dispatcher.validateEngineInputs(input.Engine, input.Configuration, input.InitialInputs); err != nil { + if err := w.dispatcher.ValidateEngineInputs(input.Engine, input.Configuration, input.InitialInputs); err != nil { return err } if preparedEnvironmentConfiguration(input.Configuration) { diff --git a/services/core/internal/execution/support.go b/services/core/internal/execution/support.go index 07ce721b2..5fe34b3fd 100644 --- a/services/core/internal/execution/support.go +++ b/services/core/internal/execution/support.go @@ -68,7 +68,7 @@ func (p Policy) canAdmitInputs(engine string, configuration json.RawMessage) boo return p.ValidateSessionConfiguration(engine, configuration) == nil } -func (p Policy) validateEngineInputs(engine string, configuration json.RawMessage, inputs []sessions.Input) error { +func (p Policy) ValidateEngineInputs(engine string, configuration json.RawMessage, inputs []sessions.Input) error { declaration, ok := p.Harnesses().Lookup(engine) if !ok { return sessions.ErrInvalidInput diff --git a/services/core/internal/execution/worker.go b/services/core/internal/execution/worker.go index 0d48162cc..9d946dba7 100644 --- a/services/core/internal/execution/worker.go +++ b/services/core/internal/execution/worker.go @@ -117,7 +117,7 @@ func (w *Worker) SubmitInputs(ctx context.Context, tenant, session, key string, if err != nil { return nil, err } - if err := w.dispatcher.validateEngineInputs(value.Engine, value.Configuration, inputs); err != nil { + if err := w.dispatcher.ValidateEngineInputs(value.Engine, value.Configuration, inputs); err != nil { return nil, err } if err := w.checkInputCredentials(ctx, value, key, inputs); err != nil { diff --git a/services/core/internal/persistence/postgres/automationpg/store.go b/services/core/internal/persistence/postgres/automationpg/store.go index 77ef574a9..c4b941ffb 100644 --- a/services/core/internal/persistence/postgres/automationpg/store.go +++ b/services/core/internal/persistence/postgres/automationpg/store.go @@ -87,11 +87,6 @@ func (s *Store) Update(ctx context.Context, tenantID, id string, change func(aut if err != nil { return err } - flags, err := q.AutomationFlags(ctx, row.ID) - if err != nil { - return err - } - current.Pending, current.Queued = flags.Pending, flags.Queued if result, err = change(current); err != nil { return err } @@ -192,11 +187,6 @@ func (s *Store) record(ctx context.Context, tenant, id pgtype.UUID, now time.Tim if a.Status != automations.Active || a.NextRunAt == nil || a.NextRunAt.After(now) { return nil } - flags, err := q.AutomationFlags(ctx, id) - if err != nil { - return err - } - a.Queued = flags.Queued snapshot, err := json.Marshal(a.Resource()) if err != nil { return err @@ -205,12 +195,6 @@ func (s *Store) record(ctx context.Context, tenant, id pgtype.UUID, now time.Tim invocation := sqlc.InsertScheduledInvocationParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, AutomationID: id, ScheduledAt: timestamp(&occurrence.ScheduledAt), AutomationSnapshot: snapshot, Status: "queued"} if occurrence.Skipped { invocation.Status, invocation.SkipReason = "skipped", text("pending_delivery") - var target struct { - SessionID string `json:"session_id"` - } - if json.Unmarshal(a.Target, &target) == nil { - invocation.SessionID, _ = pgunit.ParseID(target.SessionID) - } } if err := q.InsertScheduledInvocation(ctx, invocation); err != nil { return err @@ -362,7 +346,7 @@ func (s *Store) ListInvocations(ctx context.Context, tenantID, id string, query func automation(row sqlc.GetAutomationRow) (automations.Automation, error) { a := automations.Automation{ID: uuidString(row.ID), TenantID: uuidString(row.TenantID), Type: row.Type, Name: row.Name, Status: row.Status, RunAt: timePtr(row.RunAt), - Expression: row.Expression.String, Timezone: row.Timezone.String, NextRunAt: timePtr(row.NextRunAt), Target: row.Target, CreatedAt: row.CreatedAt.Time} + Expression: row.Expression.String, Timezone: row.Timezone.String, NextRunAt: timePtr(row.NextRunAt), Target: row.Target, CreatedAt: row.CreatedAt.Time, Pending: row.Pending, Queued: row.Queued} var target struct { Type string `json:"type"` } diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index d9f5df356..7efcf0f2a 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -133,7 +133,7 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { } // The flow reaches only the deployment setup; every other dependency // panics if called. - h, err := api.NewHandler(api.Dependencies{ + h, _, err := api.NewHandler(api.Dependencies{ Engine: "codex", CoreKeys: auth, InstallationBindings: service, Projects: struct{ api.Projects }{}, ProjectsReader: struct{ api.ProjectsReader }{}, ModelProviders: struct{ api.ModelProviders }{}, ModelProvidersReader: struct{ api.ModelProvidersReader }{}, diff --git a/services/core/migrations/000012_automations.sql b/services/core/migrations/000013_automations.sql similarity index 100% rename from services/core/migrations/000012_automations.sql rename to services/core/migrations/000013_automations.sql diff --git a/services/core/tests/integration/automations_public_test.go b/services/core/tests/integration/automations_public_test.go index 849bc1fd9..fbd0e804f 100644 --- a/services/core/tests/integration/automations_public_test.go +++ b/services/core/tests/integration/automations_public_test.go @@ -18,7 +18,7 @@ func TestAutomationsOfficialClient(t *testing.T) { if python == "" { t.Skip("pinned official Python SDK required") } - s, _ := testStore(t) + s, _ := newManagedTestStore(t) token, foreign, tenant, project := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{ {OrganizationID: "test-org", ProjectID: project, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}, @@ -36,7 +36,7 @@ func TestAutomationsOfficialClient(t *testing.T) { if err != nil { t.Fatal(err) } - _, deliver, err := api.NewHandlerWithDelivery(deps) + _, deliver, err := api.NewHandler(deps) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/automations_store_test.go b/services/core/tests/integration/automations_store_test.go index 7e5763639..658103684 100644 --- a/services/core/tests/integration/automations_store_test.go +++ b/services/core/tests/integration/automations_store_test.go @@ -13,12 +13,8 @@ import ( // Concurrent recording makes one invocation per occurrence, and settling is // idempotent: the first outcome stays and completes the one-time Automation. func TestAutomationRecordingAndSettlement(t *testing.T) { - s, _ := testStore(t) + s, _ := newManagedTestStore(t) store, _ := fixtureAutomations(t, s) - // Record covers every tenant, so start from none. - if _, err := s.pool.Exec(t.Context(), "DELETE FROM automations"); err != nil { - t.Fatal(err) - } newTenant := func() (string, string) { tenant, project := uuid.NewString(), uuid.NewString() if _, err := s.pool.Exec(t.Context(), "INSERT INTO execution_project_scopes(tenant_id,organization_id,project_id) VALUES($1,'test-org',$2)", tenant, project); err != nil { @@ -94,11 +90,10 @@ func TestAutomationRecordingAndSettlement(t *testing.T) { t.Fatalf("skipped occurrence: %+v %v", page, err) } - // An occurrence whose stored target names its Session in a non-canonical form, or whose sealed - // target cannot be opened, stops no other Automation. + // A sealed target that cannot be opened stops no other Automation. other, _ := newTenant() odd, err := store.Create(t.Context(), automations.Automation{TenantID: other, Type: automations.Periodic, Name: "odd", Metadata: map[string]string{}, Status: automations.Active, Expression: "* * * * *", Timezone: "UTC", NextRunAt: &due, - TargetType: "existing_session", Target: []byte(`{"type":"existing_session","session_id":"urn:uuid:` + session + `"}`), Contents: []byte(`{}`)}) + TargetType: "existing_session", Target: []byte(`{"type":"existing_session","session_id":"` + session + `"}`), Contents: []byte(`{}`)}) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/public_handler_fixture_test.go b/services/core/tests/integration/public_handler_fixture_test.go index 2f12e1ba8..11b9feb3d 100644 --- a/services/core/tests/integration/public_handler_fixture_test.go +++ b/services/core/tests/integration/public_handler_fixture_test.go @@ -107,7 +107,8 @@ func publicHandler(t testing.TB, s *Store, keys fixtureKeyResolver, engine strin for _, c := range configure { c(&deps) } - return api.NewHandler(deps) + h, _, err := api.NewHandler(deps) + return h, err } // fixtureAgents builds the Agent adapter and service on s. diff --git a/services/core/tests/official_automations.py b/services/core/tests/official_automations.py index 09e5c56c6..933647e24 100644 --- a/services/core/tests/official_automations.py +++ b/services/core/tests/official_automations.py @@ -114,6 +114,8 @@ def main(): fails(BadRequestError, lambda: automations.create(**periodic_args | {"expression": "bad"}), "expression") fails(BadRequestError, lambda: automations.create(**periodic_args | {"timezone": "Mars/Base"}), "timezone") fails(BadRequestError, lambda: automations.create(type="once", name="bad", run_at=int(time.time()) - 5, target=existing), "run_at") + fails(BadRequestError, lambda: automations.create(type="once", name="bad", run_at=253402300800, target=existing), "run_at") + fails(BadRequestError, lambda: automations.update(once.id, run_at=253402300800), "run_at") fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {**new_session, "environment": {"type": "openai_hosted", "environment_id": "env"}}})) fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {**new_session, "agent": {"model": "m"}}}), "target") inline_target = {k: v for k, v in new_session.items() if k != "agent_id"} From 3a160a7fa5500905e52f520266e7e3e1296bfccd Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 07:27:28 +0000 Subject: [PATCH 7/7] Fix Automation review findings: midnight daylight saving gaps, lock reads, invocation session_id --- contracts/agents-api/automations.md | 2 +- contracts/agents-api/index.md | 3 +- contracts/agents-api/zh/automations.md | 4 +- contracts/agents-api/zh/index.md | 5 +- .../core/internal/api/automation_delivery.go | 3 - services/core/internal/api/automations.go | 12 +--- services/core/internal/api/session_request.go | 5 ++ services/core/internal/automations/cron.go | 38 ++++++++----- .../core/internal/automations/cron_test.go | 2 + .../core/internal/db/queries/automations.sql | 18 +++--- .../core/internal/db/sqlc/automations.sql.go | 56 +++++++++++-------- .../postgres/automationpg/store.go | 34 ++++++----- .../integration/automations_store_test.go | 2 +- 13 files changed, 104 insertions(+), 80 deletions(-) diff --git a/contracts/agents-api/automations.md b/contracts/agents-api/automations.md index 341a484f6..f5b18b315 100644 --- a/contracts/agents-api/automations.md +++ b/contracts/agents-api/automations.md @@ -62,7 +62,7 @@ A scheduled occurrence and a manual invocation each produce one invocation recor | `failed` | Delivery was rejected for good; `error` holds the `code` and `message` the same request would return | | `skipped` | `skip_reason` is `pending_delivery`: the Automation already had a queued invocation | -Core records due occurrences and delivers queued invocations once a second, and runs at most 16 deliveries at the same time, each for up to 5 s. A delivery uses the code of `POST /v1/agents/sessions` or `POST /v1/agents/sessions/{id}/events`, with the invocation ID as its `Idempotency-Key`, so a retry after a crash finds the Session or the input it already made. Core retries a delivery while the cause is temporary: execution unavailable, a sandbox reset, a pending earlier input, or a Turn that cannot take input. Any other rejection fails the invocation. One Automation delivers one invocation at a time, oldest first, and the Automation attempted least recently goes first, so a stuck invocation does not hold every slot. An invocation whose saved target cannot be opened fails with `internal_error`. Invocations are kept until the Automation is deleted. +Core records due occurrences and delivers queued invocations once a second, and runs at most 16 deliveries at the same time, each for up to 5 s. A delivery uses the code of `POST /v1/agents/sessions` or `POST /v1/agents/sessions/{id}/events`, with the invocation ID as its `Idempotency-Key`, so a retry after a crash finds the Session or the input it already made. Core retries a delivery that gets a status of 500 or above, finds a Turn that cannot take input, or times out; a 4xx status fails the invocation. One Automation delivers one invocation at a time, oldest first, and the Automation attempted least recently goes first, so a stuck invocation does not hold every slot. An invocation whose saved target cannot be opened fails with `internal_error`. Invocations are kept until the Automation is deleted. A `once` Automation completes when its scheduled invocation reaches `completed`, `failed` or `skipped`. A manual invocation never changes the schedule. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index eb6ecaa13..ca1002210 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -130,6 +130,7 @@ Each item is Core's deliberate or native behavior where the official service beh **Automations** ([Automations](./automations.md)) - An empty `instructions` in an `existing_session` target returns 400 at create and update; the pinned schema allows 0 bytes, and Core cannot send an empty message. +- A `run_at` above 253402300799 (year 9999) returns 400; the pinned schema sets no maximum. **Vaults and Credentials** ([Vaults and Credentials](./vaults.md)) @@ -203,7 +204,7 @@ Each item is Core's deliberate or native behavior where the official service beh - [Instructions delivery](./automations.md#targets) as one input message. - [Cron grammar and daylight saving](./automations.md#schedules). - [Downtime, skipping, completion and pause](./automations.md#invocations). - - [Conflict and rejection cases](./automations.md#state-changes), including the `run_at` limit. + - [Conflict and rejection cases](./automations.md#state-changes). - [Target validation and saved references](./automations.md#targets). - [Paging](./automations.md): `limit` clamping and unresolved `after`. - The order of errors when one request has several faults, and error, default and payload-limit parity in general. diff --git a/contracts/agents-api/zh/automations.md b/contracts/agents-api/zh/automations.md index adc1b31e5..14f1661b6 100644 --- a/contracts/agents-api/zh/automations.md +++ b/contracts/agents-api/zh/automations.md @@ -1,7 +1,7 @@ --- title: "Automations" source: contracts/agents-api/automations.md -source_hash: 7973306d7ac27b5cb7aedc164368760a133f2e75663acfe8a401c3d09e8433b3 +source_hash: 4644f72c63abb5381898a794a5421bbe968bf5ab418237ea40d111dee850a515 --- Automation 保存一个计划和一个目标。计划触发时,Core 投递目标:创建新 Session,或向已有 Session 发送指令。锁定版本 SDK 的全部十个操作都在 `/v1/agents/automations` 下实现,并与其他 Beta 路由一样需要项目身份验证和 `OpenAI-Beta: agents=v1`。Automation 属于其密钥所在的项目;不存在的 ID 与其他项目的 ID 都返回 404。 @@ -64,7 +64,7 @@ Automation 保存一个计划和一个目标。计划触发时,Core 投递目 | `failed` | 投递被永久拒绝;`error` 包含相同请求会返回的 `code` 和 `message` | | `skipped` | `skip_reason` 为 `pending_delivery`:该 Automation 已有排队中的 invocation | -Core 每秒记录到期的触发并投递排队中的 invocation,最多同时运行 16 个投递,每个最长 5 s。投递使用 `POST /v1/agents/sessions` 或 `POST /v1/agents/sessions/{id}/events` 的代码,并以 invocation ID 作为 `Idempotency-Key`,因此崩溃后的重试会找到已经创建的 Session 或输入。原因是暂时的,Core 就会重试:执行不可用、沙箱重置、较早的输入仍在待处理,或 Turn 无法接收输入。其他任何拒绝都会让 invocation 失败。同一个 Automation 一次只投递一个 invocation,先到先投递,最久未尝试的 Automation 先投递,因此卡住的 invocation 不会占满所有名额。已保存的目标无法打开的 invocation 以 `internal_error` 失败。invocation 保留到 Automation 被删除。 +Core 每秒记录到期的触发并投递排队中的 invocation,最多同时运行 16 个投递,每个最长 5 s。投递使用 `POST /v1/agents/sessions` 或 `POST /v1/agents/sessions/{id}/events` 的代码,并以 invocation ID 作为 `Idempotency-Key`,因此崩溃后的重试会找到已经创建的 Session 或输入。投递得到 500 及以上的状态、遇到无法接收输入的 Turn 或超时,Core 会重试;4xx 状态会让 invocation 失败。同一个 Automation 一次只投递一个 invocation,先到先投递,最久未尝试的 Automation 先投递,因此卡住的 invocation 不会占满所有名额。已保存的目标无法打开的 invocation 以 `internal_error` 失败。invocation 保留到 Automation 被删除。 `once` Automation 在其计划 invocation 到达 `completed`、`failed` 或 `skipped` 时完成。手动 invocation 从不改变计划。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 422cef3a8..c793a13fe 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: cc7259f262c8aad57eb45516d738dcc94dc38dfbd80011bb7a68d0c7f4458a3e +source_hash: 1bd8a9d1f30c1986d41a2f0dffe0dcd8bdf3570fbc62d777570ef6750c8a4022 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -133,6 +133,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **Automations**([Automations](automations.md)) - `existing_session` 目标的 `instructions` 为空时,创建和更新返回 400;固定版本 schema 允许 0 字节,而 Core 无法发送空消息。 +- `run_at` 大于 253402300799(9999 年)时返回 400;固定版本 schema 没有设上限。 **Vaults 和 Credentials**([Vaults and Credentials](vaults.md)) @@ -206,7 +207,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - [指令投递](automations.md#targets)为一条输入消息。 - [cron 语法与夏令时](automations.md#schedules)。 - [停机、跳过、完成与暂停](automations.md#invocations)。 - - [冲突与拒绝的情形](automations.md#state-changes),包括 `run_at` 上限。 + - [冲突与拒绝的情形](automations.md#state-changes)。 - [目标校验与保存的引用](automations.md#targets)。 - [分页](automations.md):`limit` 截断与无法解析的 `after`。 - 当一个请求存在多项故障时的错误顺序,以及错误处理、默认值和载荷限制的总体一致性。 diff --git a/services/core/internal/api/automation_delivery.go b/services/core/internal/api/automation_delivery.go index 3a67a3cd4..9c05c0635 100644 --- a/services/core/internal/api/automation_delivery.go +++ b/services/core/internal/api/automation_delivery.go @@ -64,9 +64,6 @@ func (h *Handler) deliverAutomations(ctx context.Context) (*int64, int64, error) } wg.Wait() processed := int64(recorded) + settled.Load() - if processed == 0 && failed.Load() == 0 { - return nil, 0, nil - } return &processed, failed.Load(), nil } diff --git a/services/core/internal/api/automations.go b/services/core/internal/api/automations.go index 0f8c40fdb..e6aaa01b6 100644 --- a/services/core/internal/api/automations.go +++ b/services/core/internal/api/automations.go @@ -353,9 +353,6 @@ func (h *Handler) newSessionTarget(r *http.Request, raw json.RawMessage, fields if inline == saved { return fail, &fieldError{param: "target", message: "Provide exactly one of agent and agent_id."} } - if length := utf8.RuneCountInString(stringField(fields["agent_id"])); length > 64 { - return fail, &fieldError{param: "target.agent_id", message: fmt.Sprintf("Invalid 'target.agent_id': string too long. Expected a string with maximum length 64, but got a string with length %d instead.", length)} - } if inline { var agent map[string]json.RawMessage _ = json.Unmarshal(fields["agent"], &agent) @@ -379,7 +376,7 @@ func (h *Handler) newSessionTarget(r *http.Request, raw json.RawMessage, fields var required *modelProviderRequiredError if errors.As(err, &required) { report = func(w http.ResponseWriter, _ *http.Request, _ error) { - writeError(w, http.StatusBadRequest, "model_provider_required", required.message, "target.x_agents_core.model_provider") + writeError(w, http.StatusBadRequest, "model_provider_required", required.message, "target") } } return automationTarget{report: report}, prefixTarget(err) @@ -440,10 +437,3 @@ func automationEnvironment(input sessionRequest) *v1.AutomationEnvironment { } return result } - -// stringField reads a JSON string, or "" for anything else. -func stringField(raw json.RawMessage) string { - var value string - _ = json.Unmarshal(raw, &value) - return value -} diff --git a/services/core/internal/api/session_request.go b/services/core/internal/api/session_request.go index 2a1d2a8c4..6ebe519b8 100644 --- a/services/core/internal/api/session_request.go +++ b/services/core/internal/api/session_request.go @@ -3,6 +3,8 @@ package api import ( "bytes" "encoding/json" + "fmt" + "unicode/utf8" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" @@ -39,6 +41,9 @@ type sessionRequest struct { func (request decodedSessionRequest) validated() (sessionRequest, error) { input := sessionRequest{CreateSessionRequest: request.CreateSessionRequest, Input: request.Input} + if request.AgentID != nil && utf8.RuneCountInString(*request.AgentID) > 64 { + return input, &fieldError{param: "agent_id", message: fmt.Sprintf("Invalid 'agent_id': string too long. Expected a string with maximum length 64, but got a string with length %d instead.", utf8.RuneCountInString(*request.AgentID))} + } if len(request.Execution) > 0 && !bytes.Equal(bytes.TrimSpace(request.Execution), []byte("null")) { if decodeInputObject(request.Execution, &input.XAgentsCore, "model_provider", "harness_config", "environment") != nil { return input, sessions.ErrInvalidInput diff --git a/services/core/internal/automations/cron.go b/services/core/internal/automations/cron.go index 3c3346db5..2658d7094 100644 --- a/services/core/internal/automations/cron.go +++ b/services/core/internal/automations/cron.go @@ -11,11 +11,10 @@ import ( // Schedule is a parsed five-field crontab(5) expression in a time zone. type Schedule struct { minutes, hours, days, months, weekdays uint64 - // anyDay and anyWeekday report a day field that starts with "*". When - // neither does, a day matches either field. fixed reports a minute and an - // hour field that both do not. - anyDay, anyWeekday, fixed bool - location *time.Location + // dayOr reports day fields that both do not start with "*", so a day + // matches either. fixed reports the same of the minute and hour fields. + dayOr, fixed bool + location *time.Location } var ( @@ -40,7 +39,7 @@ func ParseSchedule(expression, timezone string) (*Schedule, error) { if err != nil { return nil, &InvalidError{"timezone", "timezone must be an IANA time zone name."} } - s := &Schedule{location: location, anyDay: strings.HasPrefix(fields[2], "*"), anyWeekday: strings.HasPrefix(fields[4], "*"), fixed: !strings.HasPrefix(fields[0], "*") && !strings.HasPrefix(fields[1], "*")} + s := &Schedule{location: location, dayOr: !strings.HasPrefix(fields[2], "*") && !strings.HasPrefix(fields[4], "*"), fixed: !strings.HasPrefix(fields[0], "*") && !strings.HasPrefix(fields[1], "*")} for i, target := range []struct { mask *uint64 min, max int @@ -122,9 +121,9 @@ func (s *Schedule) Next(after time.Time) (time.Time, bool) { w := t.In(s.location) switch { case s.months&(1<>'type' = 'existing_session' AND a.target->>'session_id' = sqlc.narg(session_id)::text)) @@ -40,7 +42,7 @@ LIMIT sqlc.arg(page_limit); -- name: InsertManualInvocation :one INSERT INTO automation_invocations (id, automation_id, status, session_id, delivery) SELECT sqlc.arg(id), a.id, 'queued', (CASE WHEN a.target->>'type' = 'existing_session' THEN a.target->>'session_id' END)::uuid, a.target_contents FROM automations a WHERE a.tenant_id = sqlc.arg(tenant_id) AND a.id = sqlc.arg(automation_id) -RETURNING created_at; +RETURNING created_at, session_id; -- name: GetInvocation :one SELECT i.id, i.automation_id, i.created_at, i.scheduled_at, i.automation_snapshot, i.status, i.skip_reason, i.session_id, i.error_code, i.error_message diff --git a/services/core/internal/db/sqlc/automations.sql.go b/services/core/internal/db/sqlc/automations.sql.go index aa2ff4feb..30fb11fdf 100644 --- a/services/core/internal/db/sqlc/automations.sql.go +++ b/services/core/internal/db/sqlc/automations.sql.go @@ -26,6 +26,24 @@ func (q *Queries) AdvanceAutomation(ctx context.Context, arg AdvanceAutomationPa return err } +const automationQueue = `-- name: AutomationQueue :one +SELECT EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = $1::uuid AND i.status = 'queued' AND i.scheduled_at IS NOT NULL) AS pending, + EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = $1::uuid AND i.status = 'queued') AS queued +` + +type AutomationQueueRow struct { + Pending bool `json:"pending"` + Queued bool `json:"queued"` +} + +// A statement after LockAutomation, so it reads what committed while the lock waited. +func (q *Queries) AutomationQueue(ctx context.Context, id pgtype.UUID) (AutomationQueueRow, error) { + row := q.db.QueryRow(ctx, automationQueue, id) + var i AutomationQueueRow + err := row.Scan(&i.Pending, &i.Queued) + return i, err +} + const completeOnceAutomation = `-- name: CompleteOnceAutomation :exec UPDATE automations SET status = 'completed', next_run_at = NULL WHERE id = $1 AND type = 'once' ` @@ -151,8 +169,7 @@ func (q *Queries) DueAutomationIDs(ctx context.Context, arg DueAutomationIDsPara } const getAutomation = `-- name: GetAutomation :one -SELECT a.id, a.tenant_id, a.type, a.name, a.metadata, a.status, a.run_at, a.expression, a.timezone, a.next_run_at, a.target, a.created_at, EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued' AND i.scheduled_at IS NOT NULL) AS pending, - EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued') AS queued +SELECT a.id, a.tenant_id, a.type, a.name, a.metadata, a.status, a.run_at, a.expression, a.timezone, a.next_run_at, a.target, a.created_at FROM automations a WHERE a.tenant_id = $1 AND a.id = $2 ` @@ -174,8 +191,6 @@ type GetAutomationRow struct { NextRunAt pgtype.Timestamptz `json:"next_run_at"` Target []byte `json:"target"` CreatedAt pgtype.Timestamptz `json:"created_at"` - Pending bool `json:"pending"` - Queued bool `json:"queued"` } func (q *Queries) GetAutomation(ctx context.Context, arg GetAutomationParams) (GetAutomationRow, error) { @@ -194,8 +209,6 @@ func (q *Queries) GetAutomation(ctx context.Context, arg GetAutomationParams) (G &i.NextRunAt, &i.Target, &i.CreatedAt, - &i.Pending, - &i.Queued, ) return i, err } @@ -286,7 +299,7 @@ func (q *Queries) InsertAutomation(ctx context.Context, arg InsertAutomationPara const insertManualInvocation = `-- name: InsertManualInvocation :one INSERT INTO automation_invocations (id, automation_id, status, session_id, delivery) SELECT $1, a.id, 'queued', (CASE WHEN a.target->>'type' = 'existing_session' THEN a.target->>'session_id' END)::uuid, a.target_contents FROM automations a WHERE a.tenant_id = $2 AND a.id = $3 -RETURNING created_at +RETURNING created_at, session_id ` type InsertManualInvocationParams struct { @@ -295,11 +308,16 @@ type InsertManualInvocationParams struct { AutomationID pgtype.UUID `json:"automation_id"` } -func (q *Queries) InsertManualInvocation(ctx context.Context, arg InsertManualInvocationParams) (pgtype.Timestamptz, error) { +type InsertManualInvocationRow struct { + CreatedAt pgtype.Timestamptz `json:"created_at"` + SessionID pgtype.UUID `json:"session_id"` +} + +func (q *Queries) InsertManualInvocation(ctx context.Context, arg InsertManualInvocationParams) (InsertManualInvocationRow, error) { row := q.db.QueryRow(ctx, insertManualInvocation, arg.ID, arg.TenantID, arg.AutomationID) - var created_at pgtype.Timestamptz - err := row.Scan(&created_at) - return created_at, err + var i InsertManualInvocationRow + err := row.Scan(&i.CreatedAt, &i.SessionID) + return i, err } const insertScheduledInvocation = `-- name: InsertScheduledInvocation :exec @@ -333,8 +351,7 @@ func (q *Queries) InsertScheduledInvocation(ctx context.Context, arg InsertSched } const listAutomations = `-- name: ListAutomations :many -SELECT a.id, a.tenant_id, a.type, a.name, a.metadata, a.status, a.run_at, a.expression, a.timezone, a.next_run_at, a.target, a.created_at, EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued' AND i.scheduled_at IS NOT NULL) AS pending, - EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued') AS queued +SELECT a.id, a.tenant_id, a.type, a.name, a.metadata, a.status, a.run_at, a.expression, a.timezone, a.next_run_at, a.target, a.created_at FROM automations a WHERE a.tenant_id = $1 AND ($2::text IS NULL OR (a.target->>'type' = 'existing_session' AND a.target->>'session_id' = $2::text)) @@ -371,8 +388,6 @@ type ListAutomationsRow struct { NextRunAt pgtype.Timestamptz `json:"next_run_at"` Target []byte `json:"target"` CreatedAt pgtype.Timestamptz `json:"created_at"` - Pending bool `json:"pending"` - Queued bool `json:"queued"` } func (q *Queries) ListAutomations(ctx context.Context, arg ListAutomationsParams) ([]ListAutomationsRow, error) { @@ -404,8 +419,6 @@ func (q *Queries) ListAutomations(ctx context.Context, arg ListAutomationsParams &i.NextRunAt, &i.Target, &i.CreatedAt, - &i.Pending, - &i.Queued, ); err != nil { return nil, err } @@ -491,9 +504,8 @@ func (q *Queries) ListInvocations(ctx context.Context, arg ListInvocationsParams } const lockAutomation = `-- name: LockAutomation :one -SELECT a.id, a.tenant_id, a.type, a.name, a.metadata, a.status, a.run_at, a.expression, a.timezone, a.next_run_at, a.target, a.created_at, EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued' AND i.scheduled_at IS NOT NULL) AS pending, - EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = a.id AND i.status = 'queued') AS queued -FROM automations a WHERE a.tenant_id = $1 AND a.id = $2 FOR UPDATE OF a +SELECT a.id, a.tenant_id, a.type, a.name, a.metadata, a.status, a.run_at, a.expression, a.timezone, a.next_run_at, a.target, a.created_at +FROM automations a WHERE a.tenant_id = $1 AND a.id = $2 FOR UPDATE ` type LockAutomationParams struct { @@ -514,8 +526,6 @@ type LockAutomationRow struct { NextRunAt pgtype.Timestamptz `json:"next_run_at"` Target []byte `json:"target"` CreatedAt pgtype.Timestamptz `json:"created_at"` - Pending bool `json:"pending"` - Queued bool `json:"queued"` } func (q *Queries) LockAutomation(ctx context.Context, arg LockAutomationParams) (LockAutomationRow, error) { @@ -534,8 +544,6 @@ func (q *Queries) LockAutomation(ctx context.Context, arg LockAutomationParams) &i.NextRunAt, &i.Target, &i.CreatedAt, - &i.Pending, - &i.Queued, ) return i, err } diff --git a/services/core/internal/persistence/postgres/automationpg/store.go b/services/core/internal/persistence/postgres/automationpg/store.go index c4b941ffb..1454044fe 100644 --- a/services/core/internal/persistence/postgres/automationpg/store.go +++ b/services/core/internal/persistence/postgres/automationpg/store.go @@ -79,11 +79,7 @@ func (s *Store) Update(ctx context.Context, tenantID, id string, change func(aut var result automations.Automation err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { q := sqlc.New(tx) - row, err := q.LockAutomation(ctx, sqlc.LockAutomationParams{TenantID: tenant, ID: pgunit.PathID(id)}) - if err != nil { - return err - } - current, err := automation(sqlc.GetAutomationRow(row)) + current, err := lock(ctx, q, tenant, pgunit.PathID(id)) if err != nil { return err } @@ -100,7 +96,7 @@ func (s *Store) Update(ctx context.Context, tenantID, id string, change func(aut return err } } - if err := q.UpdateAutomation(ctx, sqlc.UpdateAutomationParams{TenantID: tenant, ID: row.ID, Name: result.Name, Metadata: metadata, Status: result.Status, RunAt: timestamp(result.RunAt), + if err := q.UpdateAutomation(ctx, sqlc.UpdateAutomationParams{TenantID: tenant, ID: pgunit.PathID(id), Name: result.Name, Metadata: metadata, Status: result.Status, RunAt: timestamp(result.RunAt), Expression: text(result.Expression), Timezone: text(result.Timezone), NextRunAt: timestamp(result.NextRunAt), SetTarget: result.Contents != nil, Target: result.Target, TargetContents: contents}); err != nil { return err } @@ -141,7 +137,7 @@ func (s *Store) Invoke(ctx context.Context, tenantID, id string) (automations.In if err != nil { return err } - result.CreatedAt = created.Time + result.CreatedAt, result.SessionID = created.CreatedAt.Time, uuidString(created.SessionID) return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionCreate, writeaudit.ResourceAutomationInvocation, result.ID, result.AutomationID, writeaudit.Resource{Type: writeaudit.ResourceAutomationInvocation, ID: result.ID, ParentID: result.AutomationID}) }) return result, storageError(err) @@ -173,17 +169,13 @@ func (s *Store) Record(ctx context.Context, now time.Time) (int, error) { func (s *Store) record(ctx context.Context, tenant, id pgtype.UUID, now time.Time) (recorded bool, err error) { err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { q := sqlc.New(tx) - row, err := q.LockAutomation(ctx, sqlc.LockAutomationParams{TenantID: tenant, ID: id}) + a, err := lock(ctx, q, tenant, id) if errors.Is(err, pgx.ErrNoRows) { return nil // deleted since the pass listed it } if err != nil { return err } - a, err := automation(sqlc.GetAutomationRow(row)) - if err != nil { - return err - } if a.Status != automations.Active || a.NextRunAt == nil || a.NextRunAt.After(now) { return nil } @@ -344,9 +336,25 @@ func (s *Store) ListInvocations(ctx context.Context, tenantID, id string, query return page, nil } +// lock locks one Automation and only then reads its queue, so the queue +// reflects every commit made while the lock waited. +func lock(ctx context.Context, q *sqlc.Queries, tenant, id pgtype.UUID) (automations.Automation, error) { + row, err := q.LockAutomation(ctx, sqlc.LockAutomationParams{TenantID: tenant, ID: id}) + if err != nil { + return automations.Automation{}, err + } + a, err := automation(sqlc.GetAutomationRow(row)) + if err != nil { + return a, err + } + queue, err := q.AutomationQueue(ctx, row.ID) + a.Pending, a.Queued = queue.Pending, queue.Queued + return a, err +} + func automation(row sqlc.GetAutomationRow) (automations.Automation, error) { a := automations.Automation{ID: uuidString(row.ID), TenantID: uuidString(row.TenantID), Type: row.Type, Name: row.Name, Status: row.Status, RunAt: timePtr(row.RunAt), - Expression: row.Expression.String, Timezone: row.Timezone.String, NextRunAt: timePtr(row.NextRunAt), Target: row.Target, CreatedAt: row.CreatedAt.Time, Pending: row.Pending, Queued: row.Queued} + Expression: row.Expression.String, Timezone: row.Timezone.String, NextRunAt: timePtr(row.NextRunAt), Target: row.Target, CreatedAt: row.CreatedAt.Time} var target struct { Type string `json:"type"` } diff --git a/services/core/tests/integration/automations_store_test.go b/services/core/tests/integration/automations_store_test.go index 658103684..0484893a7 100644 --- a/services/core/tests/integration/automations_store_test.go +++ b/services/core/tests/integration/automations_store_test.go @@ -74,7 +74,7 @@ func TestAutomationRecordingAndSettlement(t *testing.T) { // An occurrence that finds a queued invocation is recorded as skipped, with the target Session. session := uuid.NewString() - periodic, err := store.Create(t.Context(), automations.Automation{TenantID: tenant, Type: automations.Periodic, Name: "busy", Metadata: map[string]string{}, Status: automations.Active, Expression: "* * * * *", Timezone: "UTC", NextRunAt: &due, + periodic, err := store.Create(t.Context(), automations.Automation{TenantID: tenant, Type: automations.Periodic, Name: "busy", Metadata: map[string]string{}, Status: automations.Active, Expression: "0 0 1 1 *", Timezone: "UTC", NextRunAt: &due, TargetType: "existing_session", Target: []byte(`{"type":"existing_session","session_id":"` + session + `"}`), Contents: []byte(`{}`)}) if err != nil { t.Fatal(err)