Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/codingcode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion packages/codingcode/src/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -67,5 +73,6 @@ export const AppLayer = Layer.mergeAll(
CheckpointLayer,
ToolSearchLayer,
SubagentRegistryLayer,
ProjectRuntimeLayer
ProjectRuntimeLayer,
SchedulerLayer
);
3 changes: 3 additions & 0 deletions packages/codingcode/src/scheduler/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './types.js';
export { readAutomations, writeAutomations } from './store.js';
export { SchedulerService } from './service.js';
217 changes: 217 additions & 0 deletions packages/codingcode/src/scheduler/service.ts
Original file line number Diff line number Diff line change
@@ -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<SchedulerService>()('Scheduler', {
effect: Effect.gen(function* () {
const session = yield* SessionService;
const jobs = new Map<string, CronJob>();

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<void> {
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<string | null> {
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,
};
}),
}) {}
33 changes: 33 additions & 0 deletions packages/codingcode/src/scheduler/store.ts
Original file line number Diff line number Diff line change
@@ -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');
}
35 changes: 35 additions & 0 deletions packages/codingcode/src/scheduler/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions packages/codingcode/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hono> {
Expand Down Expand Up @@ -42,6 +43,7 @@ export async function createServer(): Promise<Hono> {
app.route('/api', approvalRouter);
app.route('/api/agent', agentRouter);
app.route('/api/settings', settingsRouter);
app.route('/api/automations', automationsRouter);

return app;
}
Loading
Loading