diff --git a/apps/x/packages/core/src/agent-schedule/repo.ts b/apps/x/packages/core/src/agent-schedule/repo.ts new file mode 100644 index 00000000..f32eb0ae --- /dev/null +++ b/apps/x/packages/core/src/agent-schedule/repo.ts @@ -0,0 +1,43 @@ +import { WorkDir } from "../config/config.js"; +import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js"; +import fs from "fs/promises"; +import path from "path"; +import z from "zod"; + +const DEFAULT_AGENT_SCHEDULES: z.infer["agents"] = {}; + +export interface IAgentScheduleRepo { + ensureConfig(): Promise; + getConfig(): Promise>; + upsert(agentName: string, entry: z.infer): Promise; + delete(agentName: string): Promise; +} + +export class FSAgentScheduleRepo implements IAgentScheduleRepo { + private readonly configPath = path.join(WorkDir, "config", "agent-schedule.json"); + + async ensureConfig(): Promise { + try { + await fs.access(this.configPath); + } catch { + await fs.writeFile(this.configPath, JSON.stringify({ agents: DEFAULT_AGENT_SCHEDULES }, null, 2)); + } + } + + async getConfig(): Promise> { + const config = await fs.readFile(this.configPath, "utf8"); + return AgentScheduleConfig.parse(JSON.parse(config)); + } + + async upsert(agentName: string, entry: z.infer): Promise { + const conf = await this.getConfig(); + conf.agents[agentName] = entry; + await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2)); + } + + async delete(agentName: string): Promise { + const conf = await this.getConfig(); + delete conf.agents[agentName]; + await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2)); + } +} diff --git a/apps/x/packages/core/src/agent-schedule/state-repo.ts b/apps/x/packages/core/src/agent-schedule/state-repo.ts new file mode 100644 index 00000000..5396c29b --- /dev/null +++ b/apps/x/packages/core/src/agent-schedule/state-repo.ts @@ -0,0 +1,63 @@ +import { WorkDir } from "../config/config.js"; +import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js"; +import fs from "fs/promises"; +import path from "path"; +import z from "zod"; + +const DEFAULT_AGENT_SCHEDULE_STATE: z.infer["agents"] = {}; + +export interface IAgentScheduleStateRepo { + ensureState(): Promise; + getState(): Promise>; + getAgentState(agentName: string): Promise | null>; + updateAgentState(agentName: string, entry: Partial>): Promise; + setAgentState(agentName: string, entry: z.infer): Promise; + deleteAgentState(agentName: string): Promise; +} + +export class FSAgentScheduleStateRepo implements IAgentScheduleStateRepo { + private readonly statePath = path.join(WorkDir, "config", "agent-schedule-state.json"); + + async ensureState(): Promise { + try { + await fs.access(this.statePath); + } catch { + await fs.writeFile(this.statePath, JSON.stringify({ agents: DEFAULT_AGENT_SCHEDULE_STATE }, null, 2)); + } + } + + async getState(): Promise> { + const state = await fs.readFile(this.statePath, "utf8"); + return AgentScheduleState.parse(JSON.parse(state)); + } + + async getAgentState(agentName: string): Promise | null> { + const state = await this.getState(); + return state.agents[agentName] ?? null; + } + + async updateAgentState(agentName: string, entry: Partial>): Promise { + const state = await this.getState(); + const existing = state.agents[agentName] ?? { + status: "scheduled" as const, + lastRunAt: null, + nextRunAt: null, + lastError: null, + runCount: 0, + }; + state.agents[agentName] = { ...existing, ...entry }; + await fs.writeFile(this.statePath, JSON.stringify(state, null, 2)); + } + + async setAgentState(agentName: string, entry: z.infer): Promise { + const state = await this.getState(); + state.agents[agentName] = entry; + await fs.writeFile(this.statePath, JSON.stringify(state, null, 2)); + } + + async deleteAgentState(agentName: string): Promise { + const state = await this.getState(); + delete state.agents[agentName]; + await fs.writeFile(this.statePath, JSON.stringify(state, null, 2)); + } +} diff --git a/apps/x/packages/core/src/config/initConfigs.ts b/apps/x/packages/core/src/config/initConfigs.ts index 1c447e37..adfb8b24 100644 --- a/apps/x/packages/core/src/config/initConfigs.ts +++ b/apps/x/packages/core/src/config/initConfigs.ts @@ -1,6 +1,8 @@ import container from "../di/container.js"; import type { IModelConfigRepo } from "../models/repo.js"; import type { IMcpConfigRepo } from "../mcp/repo.js"; +import type { IAgentScheduleRepo } from "../agent-schedule/repo.js"; +import type { IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; import { ensureSecurityConfig } from "./security.js"; /** @@ -11,10 +13,14 @@ export async function initConfigs(): Promise { // Resolve repos and explicitly call their ensureConfig methods const modelConfigRepo = container.resolve("modelConfigRepo"); const mcpConfigRepo = container.resolve("mcpConfigRepo"); + const agentScheduleRepo = container.resolve("agentScheduleRepo"); + const agentScheduleStateRepo = container.resolve("agentScheduleStateRepo"); await Promise.all([ modelConfigRepo.ensureConfig(), mcpConfigRepo.ensureConfig(), + agentScheduleRepo.ensureConfig(), + agentScheduleStateRepo.ensureState(), ensureSecurityConfig(), ]); } diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 2b3fd2d7..d02ca7e6 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -12,6 +12,8 @@ import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js"; import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js"; import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js"; import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js"; +import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js"; +import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; const container = createContainer({ injectionMode: InjectionMode.PROXY, @@ -33,6 +35,8 @@ container.register({ oauthRepo: asClass(FSOAuthRepo).singleton(), clientRegistrationRepo: asClass(FSClientRegistrationRepo).singleton(), granolaConfigRepo: asClass(FSGranolaConfigRepo).singleton(), + agentScheduleRepo: asClass(FSAgentScheduleRepo).singleton(), + agentScheduleStateRepo: asClass(FSAgentScheduleStateRepo).singleton(), }); export default container; \ No newline at end of file diff --git a/apps/x/packages/shared/src/agent-schedule-state.ts b/apps/x/packages/shared/src/agent-schedule-state.ts new file mode 100644 index 00000000..34264d40 --- /dev/null +++ b/apps/x/packages/shared/src/agent-schedule-state.ts @@ -0,0 +1,16 @@ +import z from "zod"; + +// "triggered" is terminal state for once-schedules (will not run again) +export const AgentScheduleStatus = z.enum(["scheduled", "running", "finished", "failed", "triggered"]); + +export const AgentScheduleStateEntry = z.object({ + status: AgentScheduleStatus, + lastRunAt: z.string().datetime().nullable(), + nextRunAt: z.string().datetime().nullable(), + lastError: z.string().nullable(), + runCount: z.number().default(0), +}); + +export const AgentScheduleState = z.object({ + agents: z.record(z.string(), AgentScheduleStateEntry), +}); diff --git a/apps/x/packages/shared/src/agent-schedule.ts b/apps/x/packages/shared/src/agent-schedule.ts new file mode 100644 index 00000000..3e0402c8 --- /dev/null +++ b/apps/x/packages/shared/src/agent-schedule.ts @@ -0,0 +1,42 @@ +import z from "zod"; + +// Cron schedule - runs at exact times defined by cron expression. +// Examples: +// - Every 5 minutes: "*/5 * * * *" +// - Everyday at 8am: "0 8 * * *" +// - Every Monday at 9am: "0 9 * * 1" +export const CronSchedule = z.object({ + type: z.literal("cron"), + expression: z.string(), +}); + +// Window schedule - runs once during a time window. +// The agent will run once at a random time within the specified window. +// Examples: +// - Daily between 8am and 10am: cron="0 0 * * *", startTime="08:00", endTime="10:00" +// - Weekly on Monday between 9am-12pm: cron="0 0 * * 1", startTime="09:00", endTime="12:00" +export const WindowSchedule = z.object({ + type: z.literal("window"), + cron: z.string(), // Base frequency cron expression + startTime: z.string(), // "HH:MM" format + endTime: z.string(), // "HH:MM" format +}); + +// Once schedule - runs exactly once at a specific time, then never again. +// Examples: +// - Run once at specific datetime: runAt="2024-02-05T10:30:00Z" +export const OnceSchedule = z.object({ + type: z.literal("once"), + runAt: z.string().datetime(), // ISO 8601 datetime +}); + +export const ScheduleDefinition = z.union([CronSchedule, WindowSchedule, OnceSchedule]); + +export const AgentScheduleEntry = z.object({ + schedule: ScheduleDefinition, + enabled: z.boolean().optional().default(true), +}); + +export const AgentScheduleConfig = z.object({ + agents: z.record(z.string(), AgentScheduleEntry), +}); diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index 3bca8969..5d54883f 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -4,4 +4,6 @@ export * as ipc from './ipc.js'; export * as models from './models.js'; export * as workspace from './workspace.js'; export * as mcp from './mcp.js'; +export * as agentSchedule from './agent-schedule.js'; +export * as agentScheduleState from './agent-schedule-state.js'; export { PrefixLogger };