Skip to content

Commit 2c42cf9

Browse files
authored
Merge pull request #85 from phantom5099/scheduler
Add scheduled task feature
2 parents 45bdab1 + fadbb3c commit 2c42cf9

16 files changed

Lines changed: 1390 additions & 3 deletions

File tree

packages/codingcode/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@ai-sdk/provider": "^3.0.0",
1515
"@ai-sdk/provider-utils": "^4.0.0",
1616
"ai": "^6.0.180",
17+
"cron": "^3.5.0",
1718
"effect": "^3.21.2",
1819
"hono": "^4.12.19",
1920
"zod": "^4.4.3",

packages/codingcode/src/layer.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { CheckpointService } from './checkpoint/checkpoint-service';
1212
import { ToolSearchService } from './tools/tool-search-service';
1313
import { SubagentRegistry } from './subagent/registry';
1414
import { ProjectRuntimeService } from './runtime/project-runtime';
15+
import { SchedulerService } from './scheduler/service';
1516

1617
export const AgentLayer = AgentService.Default;
1718
export const SessionLayer = SessionService.Default;
@@ -41,6 +42,11 @@ export const CheckpointLayer = CheckpointService.Default.pipe(Layer.provide(Chec
4142

4243
export const ToolSearchLayer = ToolSearchService.Default;
4344

45+
/** Scheduler depends on SessionService. */
46+
export const SchedulerLayer = SchedulerService.Default.pipe(
47+
Layer.provide(SessionLayer)
48+
);
49+
4450
/** Agent depends on ToolExecutor + ContextService + SessionService + CheckpointService + ToolSearchService + HookLayer + ProjectRuntime. */
4551
const AgentDeps = Layer.mergeAll(
4652
ExecutorLayer,
@@ -67,5 +73,6 @@ export const AppLayer = Layer.mergeAll(
6773
CheckpointLayer,
6874
ToolSearchLayer,
6975
SubagentRegistryLayer,
70-
ProjectRuntimeLayer
76+
ProjectRuntimeLayer,
77+
SchedulerLayer
7178
);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './types.js';
2+
export { readAutomations, writeAutomations } from './store.js';
3+
export { SchedulerService } from './service.js';
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { Effect } from 'effect';
2+
import { CronJob } from 'cron';
3+
import { randomUUID } from 'crypto';
4+
import { createLogger } from '@codingcode/infra';
5+
import type { Automation, CreateAutomationInput, UpdateAutomationInput } from './types.js';
6+
import { readAutomations, writeAutomations } from './store.js';
7+
import { SessionService } from '../session/store.js';
8+
import { sendMessage, type AgentEvent } from '../agent/agent.js';
9+
import { getLLMClient } from '../llm/factory.js';
10+
import { AgentError } from '../core/error.js';
11+
import { AppLayer } from '../layer.js';
12+
13+
const logger = createLogger();
14+
15+
const TIMEOUT_MS = 5 * 60 * 1000;
16+
17+
export class SchedulerService extends Effect.Service<SchedulerService>()('Scheduler', {
18+
effect: Effect.gen(function* () {
19+
const session = yield* SessionService;
20+
const jobs = new Map<string, CronJob>();
21+
22+
function scheduleAutomation(auto: Automation): void {
23+
if (!auto.enabled) return;
24+
25+
const job = new CronJob(
26+
auto.cron,
27+
() => {
28+
runAutomation(auto).catch((e) => logger.error(`Automation ${auto.id} failed:`, e));
29+
},
30+
null,
31+
true,
32+
auto.timezone
33+
);
34+
35+
jobs.set(auto.id, job);
36+
}
37+
38+
async function runAutomation(auto: Automation): Promise<void> {
39+
logger.info(`Running automation: ${auto.name} (${auto.id})`);
40+
41+
const llmResult = await getLLMClient();
42+
if (!llmResult.ok) {
43+
logger.error(`Failed to get LLM client for automation ${auto.id}:`, llmResult.error);
44+
return;
45+
}
46+
47+
const controller = new AbortController();
48+
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
49+
50+
try {
51+
const { stream, sessionId } = await Effect.runPromise(
52+
sendMessage(undefined, auto.description, auto.projectCwd, llmResult.value, {
53+
signal: controller.signal,
54+
approvalOverride: { permissionMode: 'bypass' },
55+
}).pipe(Effect.provide(AppLayer))
56+
);
57+
58+
let lastContent = '';
59+
for await (const event of stream) {
60+
if (event._tag === 'Done') {
61+
lastContent = event.content;
62+
} else if (event._tag === 'Error') {
63+
logger.error(`Automation ${auto.id} agent error:`, event.error);
64+
}
65+
}
66+
67+
const automations = readAutomations();
68+
const idx = automations.findIndex((a) => a.id === auto.id);
69+
if (idx >= 0) {
70+
const automation = automations[idx]!;
71+
automation.lastRunAt = Date.now();
72+
automation.lastSessionId = sessionId;
73+
74+
if (auto.runOnce) {
75+
automations.splice(idx, 1);
76+
jobs.get(auto.id)?.stop();
77+
jobs.delete(auto.id);
78+
}
79+
80+
writeAutomations(automations);
81+
}
82+
83+
logger.info(`Automation ${auto.id} completed. Session: ${sessionId}`);
84+
} catch (e) {
85+
logger.error(`Automation ${auto.id} execution failed:`, e);
86+
} finally {
87+
clearTimeout(timeout);
88+
}
89+
}
90+
91+
function initialize(): void {
92+
const automations = readAutomations();
93+
for (const auto of automations) {
94+
scheduleAutomation(auto);
95+
}
96+
logger.info(`Scheduler initialized with ${jobs.size} automations`);
97+
}
98+
99+
function list(): Automation[] {
100+
return readAutomations();
101+
}
102+
103+
function add(input: CreateAutomationInput): Automation {
104+
const automations = readAutomations();
105+
const now = Date.now();
106+
const auto: Automation = {
107+
id: randomUUID().slice(0, 8),
108+
name: input.name,
109+
description: input.description,
110+
cron: input.cron,
111+
timezone: input.timezone ?? 'Asia/Shanghai',
112+
sandbox: input.sandbox ?? 'workspace-write',
113+
enabled: true,
114+
projectCwd: input.projectCwd,
115+
runOnce: input.runOnce ?? false,
116+
createdAt: now,
117+
updatedAt: now,
118+
lastRunAt: null,
119+
lastSessionId: null,
120+
};
121+
122+
automations.push(auto);
123+
writeAutomations(automations);
124+
scheduleAutomation(auto);
125+
return auto;
126+
}
127+
128+
function update(id: string, patch: UpdateAutomationInput): Automation | null {
129+
const automations = readAutomations();
130+
const idx = automations.findIndex((a) => a.id === id);
131+
if (idx < 0) return null;
132+
133+
const auto = automations[idx]!;
134+
Object.assign(auto, patch, { updatedAt: Date.now() });
135+
automations[idx] = auto;
136+
writeAutomations(automations);
137+
138+
jobs.get(id)?.stop();
139+
jobs.delete(id);
140+
scheduleAutomation(auto);
141+
142+
return auto;
143+
}
144+
145+
function remove(id: string): boolean {
146+
const automations = readAutomations();
147+
const idx = automations.findIndex((a) => a.id === id);
148+
if (idx < 0) return false;
149+
150+
automations.splice(idx, 1);
151+
writeAutomations(automations);
152+
153+
jobs.get(id)?.stop();
154+
jobs.delete(id);
155+
return true;
156+
}
157+
158+
async function runOnce(id: string): Promise<string | null> {
159+
const automations = readAutomations();
160+
const auto = automations.find((a) => a.id === id);
161+
if (!auto) return null;
162+
163+
const llmResult = await getLLMClient();
164+
if (!llmResult.ok) {
165+
throw new AgentError('CONFIG_MISSING', 'Failed to get LLM client');
166+
}
167+
168+
const controller = new AbortController();
169+
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
170+
171+
try {
172+
const { stream, sessionId } = await Effect.runPromise(
173+
sendMessage(undefined, auto.description, auto.projectCwd, llmResult.value, {
174+
signal: controller.signal,
175+
approvalOverride: { permissionMode: 'bypass' },
176+
}).pipe(Effect.provide(AppLayer))
177+
);
178+
179+
for await (const event of stream) {
180+
if (event._tag === 'Error') {
181+
logger.error(`Manual run for ${id} agent error:`, event.error);
182+
}
183+
}
184+
185+
const allAutomations = readAutomations();
186+
const idx = allAutomations.findIndex((a) => a.id === id);
187+
if (idx >= 0) {
188+
const automation = allAutomations[idx]!;
189+
automation.lastRunAt = Date.now();
190+
automation.lastSessionId = sessionId;
191+
writeAutomations(allAutomations);
192+
}
193+
194+
return sessionId;
195+
} finally {
196+
clearTimeout(timeout);
197+
}
198+
}
199+
200+
function stopAll(): void {
201+
for (const [id, job] of jobs) {
202+
job.stop();
203+
}
204+
jobs.clear();
205+
}
206+
207+
return {
208+
initialize,
209+
list,
210+
add,
211+
update,
212+
remove,
213+
runOnce,
214+
stopAll,
215+
};
216+
}),
217+
}) {}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2+
import { resolve, dirname } from 'path';
3+
import { homedir } from 'os';
4+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
5+
import type { Automation } from './types.js';
6+
7+
interface AutomationsFile {
8+
automations: Automation[];
9+
}
10+
11+
function getAutomationsPath(): string {
12+
return resolve(homedir(), '.codingcode', 'automations.yaml');
13+
}
14+
15+
export function readAutomations(configPath?: string): Automation[] {
16+
const p = configPath ?? getAutomationsPath();
17+
if (!existsSync(p)) return [];
18+
try {
19+
const raw = readFileSync(p, 'utf8');
20+
const parsed = parseYaml(raw) as AutomationsFile;
21+
return parsed.automations ?? [];
22+
} catch {
23+
return [];
24+
}
25+
}
26+
27+
export function writeAutomations(automations: Automation[], configPath?: string): void {
28+
const p = configPath ?? getAutomationsPath();
29+
const dir = dirname(p);
30+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
31+
const data: AutomationsFile = { automations };
32+
writeFileSync(p, stringifyYaml(data), 'utf8');
33+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
export interface Automation {
2+
id: string;
3+
name: string;
4+
description: string;
5+
cron: string;
6+
timezone: string;
7+
sandbox: 'readonly' | 'workspace-write';
8+
enabled: boolean;
9+
projectCwd: string;
10+
runOnce: boolean;
11+
createdAt: number;
12+
updatedAt: number;
13+
lastRunAt: number | null;
14+
lastSessionId: string | null;
15+
}
16+
17+
export interface CreateAutomationInput {
18+
name: string;
19+
description: string;
20+
cron: string;
21+
timezone?: string;
22+
sandbox?: 'readonly' | 'workspace-write';
23+
projectCwd: string;
24+
runOnce?: boolean;
25+
}
26+
27+
export interface UpdateAutomationInput {
28+
name?: string;
29+
description?: string;
30+
cron?: string;
31+
timezone?: string;
32+
sandbox?: 'readonly' | 'workspace-write';
33+
enabled?: boolean;
34+
runOnce?: boolean;
35+
}

packages/codingcode/src/server/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { modelsRouter } from './routes/models.js';
66
import { approvalRouter } from './routes/approval.js';
77
import { agentRouter } from './routes/agent.js';
88
import { settingsRouter } from './routes/settings.js';
9+
import { automationsRouter } from './routes/automations.js';
910
import { AgentError, AlreadyExistsError, NotFoundError } from '../core/error.js';
1011

1112
export async function createServer(): Promise<Hono> {
@@ -42,6 +43,7 @@ export async function createServer(): Promise<Hono> {
4243
app.route('/api', approvalRouter);
4344
app.route('/api/agent', agentRouter);
4445
app.route('/api/settings', settingsRouter);
46+
app.route('/api/automations', automationsRouter);
4547

4648
return app;
4749
}

0 commit comments

Comments
 (0)