diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index 6b5f723..c78e110 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -14,6 +14,7 @@ "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.180", + "cron": "^3.5.0", "effect": "^3.21.2", "hono": "^4.12.19", "zod": "^4.4.3", diff --git a/packages/codingcode/src/layer.ts b/packages/codingcode/src/layer.ts index 9114dbc..9160a45 100644 --- a/packages/codingcode/src/layer.ts +++ b/packages/codingcode/src/layer.ts @@ -12,6 +12,7 @@ import { CheckpointService } from './checkpoint/checkpoint-service'; import { ToolSearchService } from './tools/tool-search-service'; import { SubagentRegistry } from './subagent/registry'; import { ProjectRuntimeService } from './runtime/project-runtime'; +import { SchedulerService } from './scheduler/service'; export const AgentLayer = AgentService.Default; export const SessionLayer = SessionService.Default; @@ -41,6 +42,11 @@ export const CheckpointLayer = CheckpointService.Default.pipe(Layer.provide(Chec export const ToolSearchLayer = ToolSearchService.Default; +/** Scheduler depends on SessionService. */ +export const SchedulerLayer = SchedulerService.Default.pipe( + Layer.provide(SessionLayer) +); + /** Agent depends on ToolExecutor + ContextService + SessionService + CheckpointService + ToolSearchService + HookLayer + ProjectRuntime. */ const AgentDeps = Layer.mergeAll( ExecutorLayer, @@ -67,5 +73,6 @@ export const AppLayer = Layer.mergeAll( CheckpointLayer, ToolSearchLayer, SubagentRegistryLayer, - ProjectRuntimeLayer + ProjectRuntimeLayer, + SchedulerLayer ); diff --git a/packages/codingcode/src/scheduler/index.ts b/packages/codingcode/src/scheduler/index.ts new file mode 100644 index 0000000..a4bcb3f --- /dev/null +++ b/packages/codingcode/src/scheduler/index.ts @@ -0,0 +1,3 @@ +export * from './types.js'; +export { readAutomations, writeAutomations } from './store.js'; +export { SchedulerService } from './service.js'; diff --git a/packages/codingcode/src/scheduler/service.ts b/packages/codingcode/src/scheduler/service.ts new file mode 100644 index 0000000..4aad82b --- /dev/null +++ b/packages/codingcode/src/scheduler/service.ts @@ -0,0 +1,217 @@ +import { Effect } from 'effect'; +import { CronJob } from 'cron'; +import { randomUUID } from 'crypto'; +import { createLogger } from '@codingcode/infra'; +import type { Automation, CreateAutomationInput, UpdateAutomationInput } from './types.js'; +import { readAutomations, writeAutomations } from './store.js'; +import { SessionService } from '../session/store.js'; +import { sendMessage, type AgentEvent } from '../agent/agent.js'; +import { getLLMClient } from '../llm/factory.js'; +import { AgentError } from '../core/error.js'; +import { AppLayer } from '../layer.js'; + +const logger = createLogger(); + +const TIMEOUT_MS = 5 * 60 * 1000; + +export class SchedulerService extends Effect.Service()('Scheduler', { + effect: Effect.gen(function* () { + const session = yield* SessionService; + const jobs = new Map(); + + function scheduleAutomation(auto: Automation): void { + if (!auto.enabled) return; + + const job = new CronJob( + auto.cron, + () => { + runAutomation(auto).catch((e) => logger.error(`Automation ${auto.id} failed:`, e)); + }, + null, + true, + auto.timezone + ); + + jobs.set(auto.id, job); + } + + async function runAutomation(auto: Automation): Promise { + logger.info(`Running automation: ${auto.name} (${auto.id})`); + + const llmResult = await getLLMClient(); + if (!llmResult.ok) { + logger.error(`Failed to get LLM client for automation ${auto.id}:`, llmResult.error); + return; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + + try { + const { stream, sessionId } = await Effect.runPromise( + sendMessage(undefined, auto.description, auto.projectCwd, llmResult.value, { + signal: controller.signal, + approvalOverride: { permissionMode: 'bypass' }, + }).pipe(Effect.provide(AppLayer)) + ); + + let lastContent = ''; + for await (const event of stream) { + if (event._tag === 'Done') { + lastContent = event.content; + } else if (event._tag === 'Error') { + logger.error(`Automation ${auto.id} agent error:`, event.error); + } + } + + const automations = readAutomations(); + const idx = automations.findIndex((a) => a.id === auto.id); + if (idx >= 0) { + const automation = automations[idx]!; + automation.lastRunAt = Date.now(); + automation.lastSessionId = sessionId; + + if (auto.runOnce) { + automations.splice(idx, 1); + jobs.get(auto.id)?.stop(); + jobs.delete(auto.id); + } + + writeAutomations(automations); + } + + logger.info(`Automation ${auto.id} completed. Session: ${sessionId}`); + } catch (e) { + logger.error(`Automation ${auto.id} execution failed:`, e); + } finally { + clearTimeout(timeout); + } + } + + function initialize(): void { + const automations = readAutomations(); + for (const auto of automations) { + scheduleAutomation(auto); + } + logger.info(`Scheduler initialized with ${jobs.size} automations`); + } + + function list(): Automation[] { + return readAutomations(); + } + + function add(input: CreateAutomationInput): Automation { + const automations = readAutomations(); + const now = Date.now(); + const auto: Automation = { + id: randomUUID().slice(0, 8), + name: input.name, + description: input.description, + cron: input.cron, + timezone: input.timezone ?? 'Asia/Shanghai', + sandbox: input.sandbox ?? 'workspace-write', + enabled: true, + projectCwd: input.projectCwd, + runOnce: input.runOnce ?? false, + createdAt: now, + updatedAt: now, + lastRunAt: null, + lastSessionId: null, + }; + + automations.push(auto); + writeAutomations(automations); + scheduleAutomation(auto); + return auto; + } + + function update(id: string, patch: UpdateAutomationInput): Automation | null { + const automations = readAutomations(); + const idx = automations.findIndex((a) => a.id === id); + if (idx < 0) return null; + + const auto = automations[idx]!; + Object.assign(auto, patch, { updatedAt: Date.now() }); + automations[idx] = auto; + writeAutomations(automations); + + jobs.get(id)?.stop(); + jobs.delete(id); + scheduleAutomation(auto); + + return auto; + } + + function remove(id: string): boolean { + const automations = readAutomations(); + const idx = automations.findIndex((a) => a.id === id); + if (idx < 0) return false; + + automations.splice(idx, 1); + writeAutomations(automations); + + jobs.get(id)?.stop(); + jobs.delete(id); + return true; + } + + async function runOnce(id: string): Promise { + const automations = readAutomations(); + const auto = automations.find((a) => a.id === id); + if (!auto) return null; + + const llmResult = await getLLMClient(); + if (!llmResult.ok) { + throw new AgentError('CONFIG_MISSING', 'Failed to get LLM client'); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + + try { + const { stream, sessionId } = await Effect.runPromise( + sendMessage(undefined, auto.description, auto.projectCwd, llmResult.value, { + signal: controller.signal, + approvalOverride: { permissionMode: 'bypass' }, + }).pipe(Effect.provide(AppLayer)) + ); + + for await (const event of stream) { + if (event._tag === 'Error') { + logger.error(`Manual run for ${id} agent error:`, event.error); + } + } + + const allAutomations = readAutomations(); + const idx = allAutomations.findIndex((a) => a.id === id); + if (idx >= 0) { + const automation = allAutomations[idx]!; + automation.lastRunAt = Date.now(); + automation.lastSessionId = sessionId; + writeAutomations(allAutomations); + } + + return sessionId; + } finally { + clearTimeout(timeout); + } + } + + function stopAll(): void { + for (const [id, job] of jobs) { + job.stop(); + } + jobs.clear(); + } + + return { + initialize, + list, + add, + update, + remove, + runOnce, + stopAll, + }; + }), +}) {} diff --git a/packages/codingcode/src/scheduler/store.ts b/packages/codingcode/src/scheduler/store.ts new file mode 100644 index 0000000..6bebb0e --- /dev/null +++ b/packages/codingcode/src/scheduler/store.ts @@ -0,0 +1,33 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { homedir } from 'os'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import type { Automation } from './types.js'; + +interface AutomationsFile { + automations: Automation[]; +} + +function getAutomationsPath(): string { + return resolve(homedir(), '.codingcode', 'automations.yaml'); +} + +export function readAutomations(configPath?: string): Automation[] { + const p = configPath ?? getAutomationsPath(); + if (!existsSync(p)) return []; + try { + const raw = readFileSync(p, 'utf8'); + const parsed = parseYaml(raw) as AutomationsFile; + return parsed.automations ?? []; + } catch { + return []; + } +} + +export function writeAutomations(automations: Automation[], configPath?: string): void { + const p = configPath ?? getAutomationsPath(); + const dir = dirname(p); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const data: AutomationsFile = { automations }; + writeFileSync(p, stringifyYaml(data), 'utf8'); +} diff --git a/packages/codingcode/src/scheduler/types.ts b/packages/codingcode/src/scheduler/types.ts new file mode 100644 index 0000000..cd56c5a --- /dev/null +++ b/packages/codingcode/src/scheduler/types.ts @@ -0,0 +1,35 @@ +export interface Automation { + id: string; + name: string; + description: string; + cron: string; + timezone: string; + sandbox: 'readonly' | 'workspace-write'; + enabled: boolean; + projectCwd: string; + runOnce: boolean; + createdAt: number; + updatedAt: number; + lastRunAt: number | null; + lastSessionId: string | null; +} + +export interface CreateAutomationInput { + name: string; + description: string; + cron: string; + timezone?: string; + sandbox?: 'readonly' | 'workspace-write'; + projectCwd: string; + runOnce?: boolean; +} + +export interface UpdateAutomationInput { + name?: string; + description?: string; + cron?: string; + timezone?: string; + sandbox?: 'readonly' | 'workspace-write'; + enabled?: boolean; + runOnce?: boolean; +} diff --git a/packages/codingcode/src/server/index.ts b/packages/codingcode/src/server/index.ts index 0a8d541..4c6a8a2 100644 --- a/packages/codingcode/src/server/index.ts +++ b/packages/codingcode/src/server/index.ts @@ -6,6 +6,7 @@ import { modelsRouter } from './routes/models.js'; import { approvalRouter } from './routes/approval.js'; import { agentRouter } from './routes/agent.js'; import { settingsRouter } from './routes/settings.js'; +import { automationsRouter } from './routes/automations.js'; import { AgentError, AlreadyExistsError, NotFoundError } from '../core/error.js'; export async function createServer(): Promise { @@ -42,6 +43,7 @@ export async function createServer(): Promise { app.route('/api', approvalRouter); app.route('/api/agent', agentRouter); app.route('/api/settings', settingsRouter); + app.route('/api/automations', automationsRouter); return app; } diff --git a/packages/codingcode/src/server/routes/automations.ts b/packages/codingcode/src/server/routes/automations.ts new file mode 100644 index 0000000..277c7a0 --- /dev/null +++ b/packages/codingcode/src/server/routes/automations.ts @@ -0,0 +1,113 @@ +import { Hono } from 'hono'; +import { Effect } from 'effect'; +import { SchedulerService } from '../../scheduler/service.js'; +import { runWithLayer, errorResponse } from '../util.js'; +import { NotFoundError } from '../../core/error.js'; +import type { CreateAutomationInput, UpdateAutomationInput } from '../../scheduler/types.js'; + +export const automationsRouter = new Hono(); + +automationsRouter.get('/', async (c) => { + const result = await runWithLayer( + Effect.gen(function* () { + const scheduler = yield* SchedulerService; + return scheduler.list(); + }) + ); + + if (!result.ok) { + const { status, body } = errorResponse(result.error); + return c.json(body, status as any); + } + + return c.json(result.value); +}); + +automationsRouter.post('/', async (c) => { + const body = (await c.req.json()) as CreateAutomationInput; + + if (!body.name || !body.description || !body.cron || !body.projectCwd) { + return c.json({ error: 'Missing required fields: name, description, cron, projectCwd' }, 400); + } + + const result = await runWithLayer( + Effect.gen(function* () { + const scheduler = yield* SchedulerService; + return scheduler.add(body); + }) + ); + + if (!result.ok) { + const { status, body: errBody } = errorResponse(result.error); + return c.json(errBody, status as any); + } + + return c.json(result.value, 201); +}); + +automationsRouter.patch('/:id', async (c) => { + const id = c.req.param('id'); + const body = (await c.req.json()) as UpdateAutomationInput; + + const result = await runWithLayer( + Effect.gen(function* () { + const scheduler = yield* SchedulerService; + const updated = scheduler.update(id, body); + if (!updated) { + throw new NotFoundError(`Automation '${id}' not found`); + } + return updated; + }) + ); + + if (!result.ok) { + const { status, body: errBody } = errorResponse(result.error); + return c.json(errBody, status as any); + } + + return c.json(result.value); +}); + +automationsRouter.delete('/:id', async (c) => { + const id = c.req.param('id'); + + const result = await runWithLayer( + Effect.gen(function* () { + const scheduler = yield* SchedulerService; + const removed = scheduler.remove(id); + if (!removed) { + throw new NotFoundError(`Automation '${id}' not found`); + } + return { ok: true }; + }) + ); + + if (!result.ok) { + const { status, body } = errorResponse(result.error); + return c.json(body, status as any); + } + + return c.json(result.value); +}); + +automationsRouter.post('/:id/run', async (c) => { + const id = c.req.param('id'); + + const result = await runWithLayer( + Effect.gen(function* () { + const scheduler = yield* SchedulerService; + const sessionId = yield* Effect.tryPromise({ + try: () => scheduler.runOnce(id), + catch: (e) => new NotFoundError(`Automation '${id}' not found or execution failed`), + }); + return { sessionId }; + }) + ); + + if (!result.ok) { + const { status, body } = errorResponse(result.error); + return c.json(body, status as any); + } + + return c.json(result.value); +}); diff --git a/packages/codingcode/test/scheduler/store.test.ts b/packages/codingcode/test/scheduler/store.test.ts new file mode 100644 index 0000000..108ce0c --- /dev/null +++ b/packages/codingcode/test/scheduler/store.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, rmSync, mkdirSync, writeFileSync } from 'fs'; +import { resolve, join } from 'path'; +import { readAutomations, writeAutomations } from '../../src/scheduler/store.js'; +import type { Automation } from '../../src/scheduler/types.js'; + +const testDir = resolve(process.cwd(), '.test-scheduler-store'); +const testFile = join(testDir, 'automations.yaml'); + +describe('readAutomations', () => { + beforeEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return empty array when no file exists', () => { + const result = readAutomations(join(testDir, 'nonexistent.yaml')); + expect(result).toEqual([]); + }); + + it('should return empty array for invalid YAML', () => { + writeFileSync(testFile, 'invalid: yaml: content: [', 'utf8'); + const result = readAutomations(testFile); + expect(result).toEqual([]); + }); + + it('should read automations from valid YAML', () => { + const yaml = `automations: + - id: "test-1" + name: "Test Automation" + description: "Test description" + cron: "0 9 * * *" + timezone: "Asia/Shanghai" + sandbox: "workspace-write" + enabled: true + projectCwd: "/home/user/project" + runOnce: false + createdAt: 1718000000000 + updatedAt: 1718000000000 + lastRunAt: null + lastSessionId: null +`; + writeFileSync(testFile, yaml, 'utf8'); + const result = readAutomations(testFile); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe('test-1'); + expect(result[0]!.name).toBe('Test Automation'); + expect(result[0]!.cron).toBe('0 9 * * *'); + expect(result[0]!.enabled).toBe(true); + }); +}); + +describe('writeAutomations', () => { + beforeEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it('should create file and write automations', () => { + const automations: Automation[] = [ + { + id: 'test-1', + name: 'Test', + description: 'Test description', + cron: '0 9 * * *', + timezone: 'Asia/Shanghai', + sandbox: 'workspace-write', + enabled: true, + projectCwd: '/home/user/project', + runOnce: false, + createdAt: 1718000000000, + updatedAt: 1718000000000, + lastRunAt: null, + lastSessionId: null, + }, + ]; + + writeAutomations(automations, testFile); + expect(existsSync(testFile)).toBe(true); + + const result = readAutomations(testFile); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe('test-1'); + }); + + it('should overwrite existing automations', () => { + const automations1: Automation[] = [ + { + id: 'test-1', + name: 'First', + description: 'First description', + cron: '0 9 * * *', + timezone: 'Asia/Shanghai', + sandbox: 'workspace-write', + enabled: true, + projectCwd: '/home/user/project', + runOnce: false, + createdAt: 1718000000000, + updatedAt: 1718000000000, + lastRunAt: null, + lastSessionId: null, + }, + ]; + + const automations2: Automation[] = [ + { + id: 'test-2', + name: 'Second', + description: 'Second description', + cron: '0 10 * * *', + timezone: 'Asia/Shanghai', + sandbox: 'readonly', + enabled: false, + projectCwd: '/home/user/other', + runOnce: true, + createdAt: 1718000000001, + updatedAt: 1718000000001, + lastRunAt: 1718000000002, + lastSessionId: 'session-123', + }, + ]; + + writeAutomations(automations1, testFile); + writeAutomations(automations2, testFile); + + const result = readAutomations(testFile); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe('test-2'); + expect(result[0]!.name).toBe('Second'); + expect(result[0]!.lastRunAt).toBe(1718000000002); + expect(result[0]!.lastSessionId).toBe('session-123'); + }); + + it('should handle empty array', () => { + writeAutomations([], testFile); + const result = readAutomations(testFile); + expect(result).toEqual([]); + }); + + it('should create directory if not exists', () => { + const nestedDir = join(testDir, 'nested', 'dir'); + const nestedFile = join(nestedDir, 'automations.yaml'); + + writeAutomations([], nestedFile); + expect(existsSync(nestedFile)).toBe(true); + }); +}); diff --git a/packages/desktop/src/agent/AgentSidebar.tsx b/packages/desktop/src/agent/AgentSidebar.tsx index 1a703c9..f793ee9 100644 --- a/packages/desktop/src/agent/AgentSidebar.tsx +++ b/packages/desktop/src/agent/AgentSidebar.tsx @@ -103,7 +103,11 @@ export default function AgentSidebar() { {/* 功能导航 */}
@@ -183,14 +187,17 @@ function NavItem({ icon, label, shortcut, + onClick, }: { icon: React.ReactNode; label: string; shortcut?: string; + onClick?: () => void; }) { return ( +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setName(e.target.value)} + className={inputClass} + placeholder="每日报告" + /> +
+ +
+ +