diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3358ea9d63..e23b7af4a9 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -170,6 +170,61 @@ } ] }, + "AutomationStatus": { + "enum": [ + "ACTIVE", + "PAUSED" + ], + "type": "string" + }, + "AutomationTarget": { + "description": "EXPERIMENTAL - typed automation target for recurring scheduled work.", + "oneOf": [ + { + "properties": { + "cwds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "cron" + ], + "title": "CronAutomationTargetType", + "type": "string" + } + }, + "required": [ + "cwds", + "type" + ], + "title": "CronAutomationTarget", + "type": "object" + }, + { + "properties": { + "threadId": { + "type": "string" + }, + "type": { + "enum": [ + "heartbeat" + ], + "title": "HeartbeatAutomationTargetType", + "type": "string" + } + }, + "required": [ + "threadId", + "type" + ], + "title": "HeartbeatAutomationTarget", + "type": "object" + } + ] + }, "ByteRange": { "properties": { "end": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 1e11c76ae4..e5a887925c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6893,6 +6893,133 @@ ], "type": "string" }, + "Automation": { + "description": "EXPERIMENTAL - durable recurring automation or heartbeat.", + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "id": { + "type": "string" + }, + "lastRunAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "nextRunAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "prompt": { + "type": "string" + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "rrule": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/v2/AutomationStatus" + }, + "target": { + "$ref": "#/definitions/v2/AutomationTarget" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "id", + "name", + "prompt", + "rrule", + "status", + "target", + "updatedAt" + ], + "type": "object" + }, + "AutomationStatus": { + "enum": [ + "ACTIVE", + "PAUSED" + ], + "type": "string" + }, + "AutomationTarget": { + "description": "EXPERIMENTAL - typed automation target for recurring scheduled work.", + "oneOf": [ + { + "properties": { + "cwds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "cron" + ], + "title": "CronAutomationTargetType", + "type": "string" + } + }, + "required": [ + "cwds", + "type" + ], + "title": "CronAutomationTarget", + "type": "object" + }, + { + "properties": { + "threadId": { + "type": "string" + }, + "type": { + "enum": [ + "heartbeat" + ], + "title": "HeartbeatAutomationTargetType", + "type": "string" + } + }, + "required": [ + "threadId", + "type" + ], + "title": "HeartbeatAutomationTarget", + "type": "object" + } + ] + }, "ByteRange": { "properties": { "end": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 326bfdfc9a..2e133357c5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1095,6 +1095,133 @@ ], "type": "string" }, + "Automation": { + "description": "EXPERIMENTAL - durable recurring automation or heartbeat.", + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "id": { + "type": "string" + }, + "lastRunAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "nextRunAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "prompt": { + "type": "string" + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "rrule": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/AutomationStatus" + }, + "target": { + "$ref": "#/definitions/AutomationTarget" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "id", + "name", + "prompt", + "rrule", + "status", + "target", + "updatedAt" + ], + "type": "object" + }, + "AutomationStatus": { + "enum": [ + "ACTIVE", + "PAUSED" + ], + "type": "string" + }, + "AutomationTarget": { + "description": "EXPERIMENTAL - typed automation target for recurring scheduled work.", + "oneOf": [ + { + "properties": { + "cwds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "cron" + ], + "title": "CronAutomationTargetType", + "type": "string" + } + }, + "required": [ + "cwds", + "type" + ], + "title": "CronAutomationTarget", + "type": "object" + }, + { + "properties": { + "threadId": { + "type": "string" + }, + "type": { + "enum": [ + "heartbeat" + ], + "title": "HeartbeatAutomationTargetType", + "type": "string" + } + }, + "required": [ + "threadId", + "type" + ], + "title": "HeartbeatAutomationTarget", + "type": "object" + } + ] + }, "ByteRange": { "properties": { "end": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Automation.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Automation.ts new file mode 100644 index 0000000000..35730b9c88 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Automation.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ReasoningEffort } from "../ReasoningEffort"; +import type { AutomationStatus } from "./AutomationStatus"; +import type { AutomationTarget } from "./AutomationTarget"; + +/** + * EXPERIMENTAL - durable recurring automation or heartbeat. + */ +export type Automation = { id: string, name: string, prompt: string, status: AutomationStatus, rrule: string, nextRunAt: bigint | null, lastRunAt: bigint | null, createdAt: bigint, updatedAt: bigint, model: string | null, reasoningEffort: ReasoningEffort | null, target: AutomationTarget, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AutomationStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AutomationStatus.ts new file mode 100644 index 0000000000..05f423e2fa --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AutomationStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AutomationStatus = "ACTIVE" | "PAUSED"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AutomationTarget.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AutomationTarget.ts new file mode 100644 index 0000000000..2a5566ce16 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AutomationTarget.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - typed automation target for recurring scheduled work. + */ +export type AutomationTarget = { "type": "cron", cwds: Array, } | { "type": "heartbeat", threadId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 2e59b0fe19..0ee66db3f9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -36,6 +36,9 @@ export type { AskForApproval } from "./AskForApproval"; export type { AttestationGenerateParams } from "./AttestationGenerateParams"; export type { AttestationGenerateResponse } from "./AttestationGenerateResponse"; export type { AutoReviewDecisionSource } from "./AutoReviewDecisionSource"; +export type { Automation } from "./Automation"; +export type { AutomationStatus } from "./AutomationStatus"; +export type { AutomationTarget } from "./AutomationTarget"; export type { ByteRange } from "./ByteRange"; export type { CancelLoginAccountParams } from "./CancelLoginAccountParams"; export type { CancelLoginAccountResponse } from "./CancelLoginAccountResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 7611c27ff1..1927c8cda3 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -618,6 +618,42 @@ client_request_definitions! { serialization: None, response: v2::ThreadListResponse, }, + #[experimental("automation/list")] + AutomationList => "automation/list" { + params: v2::AutomationListParams, + serialization: global_shared_read("automation"), + response: v2::AutomationListResponse, + }, + #[experimental("automation/read")] + AutomationRead => "automation/read" { + params: v2::AutomationReadParams, + serialization: global_shared_read("automation"), + response: v2::AutomationReadResponse, + }, + #[experimental("automation/create")] + AutomationCreate => "automation/create" { + params: v2::AutomationCreateParams, + serialization: global("automation"), + response: v2::AutomationCreateResponse, + }, + #[experimental("automation/update")] + AutomationUpdate => "automation/update" { + params: v2::AutomationUpdateParams, + serialization: global("automation"), + response: v2::AutomationUpdateResponse, + }, + #[experimental("automation/delete")] + AutomationDelete => "automation/delete" { + params: v2::AutomationDeleteParams, + serialization: global("automation"), + response: v2::AutomationDeleteResponse, + }, + #[experimental("automation/runNow")] + AutomationRunNow => "automation/runNow" { + params: v2::AutomationRunNowParams, + serialization: global("automation"), + response: v2::AutomationRunNowResponse, + }, #[experimental("thread/search")] ThreadSearch => "thread/search" { params: v2::ThreadSearchParams, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/automation.rs b/codex-rs/app-server-protocol/src/protocol/v2/automation.rs new file mode 100644 index 0000000000..8a0af9d888 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/automation.rs @@ -0,0 +1,197 @@ +use codex_protocol::openai_models::ReasoningEffort; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", tag = "type")] +#[ts(export_to = "v2/", rename_all = "camelCase", tag = "type")] +/// EXPERIMENTAL - typed automation target for recurring scheduled work. +pub enum AutomationTarget { + Cron { + cwds: Vec, + }, + Heartbeat { + #[serde(rename = "threadId")] + #[ts(rename = "threadId")] + thread_id: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum AutomationStatus { + #[serde(rename = "ACTIVE")] + #[ts(rename = "ACTIVE")] + Active, + #[serde(rename = "PAUSED")] + #[ts(rename = "PAUSED")] + Paused, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - durable recurring automation or heartbeat. +pub struct Automation { + pub id: String, + pub name: String, + pub prompt: String, + pub status: AutomationStatus, + pub rrule: String, + pub next_run_at: Option, + pub last_run_at: Option, + pub created_at: i64, + pub updated_at: i64, + pub model: Option, + pub reasoning_effort: Option, + pub target: AutomationTarget, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - list recurring automations. +pub struct AutomationListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - paginated automation list response. +pub struct AutomationListResponse { + pub data: Vec, + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - read one automation by id. +pub struct AutomationReadParams { + pub automation_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AutomationReadResponse { + pub automation: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - create a recurring automation or heartbeat. +pub struct AutomationCreateParams { + pub name: String, + pub prompt: String, + #[ts(optional = nullable)] + pub rrule: Option, + #[ts(optional = nullable)] + pub model: Option, + #[ts(optional = nullable)] + pub reasoning_effort: Option, + #[ts(optional = nullable)] + pub status: Option, + pub target: AutomationTarget, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AutomationCreateResponse { + pub automation: Automation, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - update an existing automation. +pub struct AutomationUpdateParams { + pub automation_id: String, + #[ts(optional = nullable)] + pub name: Option, + #[ts(optional = nullable)] + pub prompt: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub rrule: Option>, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub model: Option>, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub reasoning_effort: Option>, + #[ts(optional = nullable)] + pub status: Option, + #[ts(optional = nullable)] + pub target: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AutomationUpdateResponse { + pub automation: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - delete an automation by id. +pub struct AutomationDeleteParams { + pub automation_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AutomationDeleteResponse { + pub deleted: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - start an immediate run for an automation. +pub struct AutomationRunNowParams { + pub automation_id: String, + #[ts(optional = nullable)] + pub thread_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AutomationRunNowResponse { + pub found: bool, + pub started_count: u32, +} + +#[cfg(test)] +#[path = "automation_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/automation_tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/automation_tests.rs new file mode 100644 index 0000000000..712fb94a06 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/automation_tests.rs @@ -0,0 +1,26 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn automation_update_omits_absent_clearable_fields() { + let params = AutomationUpdateParams { + automation_id: "automation-1".to_string(), + name: None, + prompt: None, + rrule: None, + model: None, + reasoning_effort: None, + status: None, + target: None, + }; + + let serialized = serde_json::to_value(params).expect("serialize automation update"); + + assert_eq!( + serialized["automationId"], + serde_json::json!("automation-1") + ); + assert!(serialized.get("rrule").is_none()); + assert!(serialized.get("model").is_none()); + assert!(serialized.get("reasoningEffort").is_none()); +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index b5fa9fdc65..9116b62e52 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -3,6 +3,7 @@ mod shared; mod account; mod apps; mod attestation; +mod automation; mod collaboration_mode; mod command_exec; mod config; @@ -29,6 +30,7 @@ mod windows_sandbox; pub use account::*; pub use apps::*; pub use attestation::*; +pub use automation::*; pub use collaboration_mode::*; pub use command_exec::*; pub use config::*; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 336f769d71..2a7cf551f1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -148,6 +148,12 @@ Example with notification opt-out: - `thread/goal/clear` — clear the current persisted goal for a materialized thread; returns whether a goal was removed and emits `thread/goal/cleared` when state changes. - `thread/goal/updated` — notification emitted whenever a thread goal changes; includes the full current goal. - `thread/goal/cleared` — notification emitted whenever a thread goal is removed. +- `automation/list` — experimental; list recurring automations visible from the initialized client, with cursor pagination. +- `automation/read` — experimental; read one automation by id; returns `automation: null` when it is missing or not visible. +- `automation/create` — experimental; create a recurring cron automation or heartbeat. Cron targets provide `cwds` and spawn a new thread per cwd when dispatched. Heartbeat targets provide `threadId` and deliver to that existing thread. +- `automation/update` — experimental; update an automation's name, prompt, schedule, status, model, reasoning effort, or target when the target remains valid for the caller. +- `automation/delete` — experimental; delete an automation by id; returns `deleted: false` when it is missing or not visible. +- `automation/runNow` — experimental; immediately dispatch an automation once. Returns `{ found, startedCount }`; `found: true, startedCount: 0` means another app-server already owns an in-flight run. - `thread/settings/updated` — experimental notification emitted to subscribed clients when a loaded thread’s effective next-turn settings change; includes `threadId` and the full `threadSettings`. - `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`). - `thread/archive` — move a thread’s rollout file into the archived directory and attempt to move any spawned descendant thread rollout files; returns `{}` on success and emits `thread/archived` for each archived thread. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c32b92c4ea..a366d4bddb 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -8,6 +8,7 @@ use crate::attestation::app_server_attestation_provider; use crate::config_manager::ConfigManager; use crate::connection_rpc_gate::ConnectionRpcGate; use crate::error_code::invalid_request; +use crate::error_code::method_not_found; use crate::extensions::ThreadExtensionDependencies; use crate::extensions::app_server_extension_event_sink; use crate::extensions::guardian_agent_spawner; @@ -1183,6 +1184,24 @@ impl MessageProcessor { ClientRequest::ThreadList { params, .. } => { self.thread_processor.thread_list(params).await } + ClientRequest::AutomationList { .. } => { + Err(automation_api_not_implemented("automation/list")) + } + ClientRequest::AutomationRead { .. } => { + Err(automation_api_not_implemented("automation/read")) + } + ClientRequest::AutomationCreate { .. } => { + Err(automation_api_not_implemented("automation/create")) + } + ClientRequest::AutomationUpdate { .. } => { + Err(automation_api_not_implemented("automation/update")) + } + ClientRequest::AutomationDelete { .. } => { + Err(automation_api_not_implemented("automation/delete")) + } + ClientRequest::AutomationRunNow { .. } => { + Err(automation_api_not_implemented("automation/runNow")) + } ClientRequest::ThreadSearch { params, .. } => { self.thread_processor.thread_search(params).await } @@ -1485,6 +1504,10 @@ impl MessageProcessor { } } +fn automation_api_not_implemented(method: &str) -> JSONRPCErrorError { + method_not_found(format!("{method} is not supported yet")) +} + #[cfg(test)] #[path = "message_processor_tracing_tests.rs"] mod message_processor_tracing_tests;