@zhin.js/core 1.0.32 → 1.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/lib/ai/agent.d.ts.map +1 -1
- package/lib/ai/agent.js +15 -2
- package/lib/ai/agent.js.map +1 -1
- package/lib/ai/bootstrap.d.ts +11 -2
- package/lib/ai/bootstrap.d.ts.map +1 -1
- package/lib/ai/bootstrap.js +46 -2
- package/lib/ai/bootstrap.js.map +1 -1
- package/lib/ai/builtin-tools.d.ts +28 -6
- package/lib/ai/builtin-tools.d.ts.map +1 -1
- package/lib/ai/builtin-tools.js +265 -76
- package/lib/ai/builtin-tools.js.map +1 -1
- package/lib/ai/index.d.ts +9 -1
- package/lib/ai/index.d.ts.map +1 -1
- package/lib/ai/index.js +8 -0
- package/lib/ai/index.js.map +1 -1
- package/lib/ai/init.d.ts.map +1 -1
- package/lib/ai/init.js +84 -3
- package/lib/ai/init.js.map +1 -1
- package/lib/ai/providers/anthropic.d.ts +7 -0
- package/lib/ai/providers/anthropic.d.ts.map +1 -1
- package/lib/ai/providers/anthropic.js +3 -0
- package/lib/ai/providers/anthropic.js.map +1 -1
- package/lib/ai/providers/ollama.d.ts +10 -0
- package/lib/ai/providers/ollama.d.ts.map +1 -1
- package/lib/ai/providers/ollama.js +11 -3
- package/lib/ai/providers/ollama.js.map +1 -1
- package/lib/ai/providers/openai.d.ts +7 -0
- package/lib/ai/providers/openai.d.ts.map +1 -1
- package/lib/ai/providers/openai.js +3 -0
- package/lib/ai/providers/openai.js.map +1 -1
- package/lib/ai/service.d.ts +4 -0
- package/lib/ai/service.d.ts.map +1 -1
- package/lib/ai/service.js +7 -0
- package/lib/ai/service.js.map +1 -1
- package/lib/ai/subagent.d.ts +50 -0
- package/lib/ai/subagent.d.ts.map +1 -0
- package/lib/ai/subagent.js +144 -0
- package/lib/ai/subagent.js.map +1 -0
- package/lib/ai/types.d.ts +25 -5
- package/lib/ai/types.d.ts.map +1 -1
- package/lib/ai/zhin-agent-builtin-tools.d.ts +17 -0
- package/lib/ai/zhin-agent-builtin-tools.d.ts.map +1 -0
- package/lib/ai/zhin-agent-builtin-tools.js +220 -0
- package/lib/ai/zhin-agent-builtin-tools.js.map +1 -0
- package/lib/ai/zhin-agent-config.d.ts +54 -0
- package/lib/ai/zhin-agent-config.d.ts.map +1 -0
- package/lib/ai/zhin-agent-config.js +76 -0
- package/lib/ai/zhin-agent-config.js.map +1 -0
- package/lib/ai/zhin-agent-exec-policy.d.ts +20 -0
- package/lib/ai/zhin-agent-exec-policy.d.ts.map +1 -0
- package/lib/ai/zhin-agent-exec-policy.js +71 -0
- package/lib/ai/zhin-agent-exec-policy.js.map +1 -0
- package/lib/ai/zhin-agent-prompt.d.ts +21 -0
- package/lib/ai/zhin-agent-prompt.d.ts.map +1 -0
- package/lib/ai/zhin-agent-prompt.js +116 -0
- package/lib/ai/zhin-agent-prompt.js.map +1 -0
- package/lib/ai/zhin-agent-tool-collector.d.ts +22 -0
- package/lib/ai/zhin-agent-tool-collector.d.ts.map +1 -0
- package/lib/ai/zhin-agent-tool-collector.js +218 -0
- package/lib/ai/zhin-agent-tool-collector.js.map +1 -0
- package/lib/ai/zhin-agent.d.ts +11 -155
- package/lib/ai/zhin-agent.d.ts.map +1 -1
- package/lib/ai/zhin-agent.js +84 -684
- package/lib/ai/zhin-agent.js.map +1 -1
- package/lib/component.d.ts.map +1 -1
- package/lib/component.js +19 -19
- package/lib/component.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/scheduler/index.d.ts +10 -0
- package/lib/scheduler/index.d.ts.map +1 -0
- package/lib/scheduler/index.js +12 -0
- package/lib/scheduler/index.js.map +1 -0
- package/lib/scheduler/scheduler.d.ts +49 -0
- package/lib/scheduler/scheduler.d.ts.map +1 -0
- package/lib/scheduler/scheduler.js +352 -0
- package/lib/scheduler/scheduler.js.map +1 -0
- package/lib/scheduler/types.d.ts +71 -0
- package/lib/scheduler/types.d.ts.map +1 -0
- package/lib/scheduler/types.js +8 -0
- package/lib/scheduler/types.js.map +1 -0
- package/lib/tool-zod.d.ts +28 -0
- package/lib/tool-zod.d.ts.map +1 -0
- package/lib/tool-zod.js +98 -0
- package/lib/tool-zod.js.map +1 -0
- package/package.json +9 -4
- package/src/ai/agent.ts +15 -2
- package/src/ai/bootstrap.ts +48 -2
- package/src/ai/builtin-tools.ts +283 -75
- package/src/ai/index.ts +19 -1
- package/src/ai/init.ts +85 -3
- package/src/ai/providers/anthropic.ts +3 -0
- package/src/ai/providers/ollama.ts +13 -3
- package/src/ai/providers/openai.ts +3 -0
- package/src/ai/service.ts +8 -0
- package/src/ai/subagent.ts +209 -0
- package/src/ai/types.ts +29 -2
- package/src/ai/zhin-agent-builtin-tools.ts +247 -0
- package/src/ai/zhin-agent-config.ts +113 -0
- package/src/ai/zhin-agent-exec-policy.ts +78 -0
- package/src/ai/zhin-agent-prompt.ts +136 -0
- package/src/ai/zhin-agent-tool-collector.ts +243 -0
- package/src/ai/zhin-agent.ts +113 -791
- package/src/component.ts +29 -28
- package/src/index.ts +1 -0
- package/src/scheduler/index.ts +28 -0
- package/src/scheduler/scheduler.ts +372 -0
- package/src/scheduler/types.ts +74 -0
- package/src/tool-zod.ts +115 -0
- package/tests/ai/subagent.test.ts +270 -0
package/src/component.ts
CHANGED
|
@@ -470,34 +470,38 @@ export async function renderComponent<P = any>(component: Component<P>, template
|
|
|
470
470
|
const props = getProps(component, template, context);
|
|
471
471
|
return component(props, context);
|
|
472
472
|
}
|
|
473
|
-
// 渲染函数 -
|
|
473
|
+
// 渲染函数 - 支持新的组件系统;无组件时仍对内容执行 ${...} 模板编译,与有组件时行为一致
|
|
474
474
|
export async function renderComponents(
|
|
475
475
|
componentMap: Map<string, Component>,
|
|
476
476
|
options: SendOptions,
|
|
477
477
|
customContext?: ComponentContext
|
|
478
478
|
): Promise<SendOptions> {
|
|
479
|
-
|
|
479
|
+
const template = typeof options.content === 'string'
|
|
480
|
+
? options.content
|
|
481
|
+
: segment.toString(options.content as MessageElement);
|
|
480
482
|
|
|
481
|
-
const components = [...Array.from(componentMap.values()), Fetch, Fragment];
|
|
482
|
-
|
|
483
|
-
// 创建根上下文
|
|
484
483
|
const rootContext = customContext || createComponentContext(
|
|
485
484
|
options,
|
|
486
485
|
undefined,
|
|
487
|
-
|
|
486
|
+
template
|
|
488
487
|
);
|
|
489
488
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
489
|
+
if (!componentMap.size) {
|
|
490
|
+
const compiled = rootContext.compile(template);
|
|
491
|
+
return {
|
|
492
|
+
...options,
|
|
493
|
+
content: typeof compiled === 'string' ? segment.from(compiled) : (compiled as MessageElement[]),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const components = [...Array.from(componentMap.values()), Fetch, Fragment];
|
|
498
|
+
|
|
499
|
+
const renderWithContext = async (tpl: string, context: ComponentContext): Promise<SendContent> => {
|
|
500
|
+
let result = context.compile(tpl);
|
|
493
501
|
let hasChanges = true;
|
|
494
502
|
let iterations = 0;
|
|
495
|
-
const maxIterations = 10;
|
|
503
|
+
const maxIterations = 10;
|
|
496
504
|
|
|
497
|
-
// 编译模板
|
|
498
|
-
result = context.compile(result);
|
|
499
|
-
|
|
500
|
-
// 递归处理所有组件,直到没有更多组件需要渲染
|
|
501
505
|
while (hasChanges && iterations < maxIterations) {
|
|
502
506
|
hasChanges = false;
|
|
503
507
|
iterations++;
|
|
@@ -505,22 +509,21 @@ export async function renderComponents(
|
|
|
505
509
|
for (const comp of components) {
|
|
506
510
|
const match = matchComponent(comp, result);
|
|
507
511
|
if (match) {
|
|
508
|
-
// 创建组件特定的上下文
|
|
509
512
|
const componentContext = createComponentContext(
|
|
510
513
|
context.props,
|
|
511
514
|
context,
|
|
512
515
|
result
|
|
513
516
|
);
|
|
514
517
|
let SendContent;
|
|
515
|
-
try{
|
|
518
|
+
try {
|
|
516
519
|
SendContent = await renderComponent(comp, match, componentContext);
|
|
517
|
-
}catch(error){
|
|
518
|
-
SendContent = `[${comp.name} Error: ${(error as Error)?.message||String(error)}]
|
|
520
|
+
} catch (error) {
|
|
521
|
+
SendContent = `[${comp.name} Error: ${(error as Error)?.message || String(error)}]`;
|
|
519
522
|
}
|
|
520
523
|
const renderedString = typeof SendContent === 'string' ? SendContent : segment.toString(SendContent as MessageElement);
|
|
521
524
|
result = result.replace(match, renderedString);
|
|
522
525
|
hasChanges = true;
|
|
523
|
-
break;
|
|
526
|
+
break;
|
|
524
527
|
}
|
|
525
528
|
}
|
|
526
529
|
}
|
|
@@ -528,20 +531,18 @@ export async function renderComponents(
|
|
|
528
531
|
return result;
|
|
529
532
|
};
|
|
530
533
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
return await renderWithContext(template, rootContext);
|
|
534
|
+
rootContext.render = async (tpl: string, context?: Partial<ComponentContext>) => {
|
|
535
|
+
return await renderWithContext(tpl, rootContext);
|
|
534
536
|
};
|
|
535
537
|
|
|
536
|
-
// 渲染模板
|
|
537
538
|
const output = await renderWithContext(rootContext.root, rootContext);
|
|
538
539
|
const content = typeof output === 'string' ? segment.from(output) : output as MessageElement[];
|
|
539
540
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
541
|
+
return {
|
|
542
|
+
...options,
|
|
543
|
+
content,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
545
546
|
|
|
546
547
|
// 内置组件
|
|
547
548
|
export const Fragment = defineComponent(async (props: { children?: SendContent }, context: ComponentContext) => {
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ export * from './types.js'
|
|
|
33
33
|
export * from './utils.js'
|
|
34
34
|
export * from './errors.js' // 导出错误处理系统
|
|
35
35
|
export * from './cron.js'
|
|
36
|
+
export * from './scheduler/index.js'
|
|
36
37
|
export * from '@zhin.js/database'
|
|
37
38
|
export * from '@zhin.js/logger'
|
|
38
39
|
// 只导出 Schema 类,避免与 utils.js 的 isEmpty 冲突
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler module — at / every / cron + heartbeat
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
Schedule,
|
|
7
|
+
JobPayload,
|
|
8
|
+
JobState,
|
|
9
|
+
ScheduledJob,
|
|
10
|
+
JobStore,
|
|
11
|
+
JobCallback,
|
|
12
|
+
AddJobOptions,
|
|
13
|
+
IScheduler,
|
|
14
|
+
} from './types.js';
|
|
15
|
+
export { Scheduler } from './scheduler.js';
|
|
16
|
+
export type { SchedulerOptions } from './scheduler.js';
|
|
17
|
+
|
|
18
|
+
import type { Scheduler } from './scheduler.js';
|
|
19
|
+
|
|
20
|
+
let schedulerInstance: Scheduler | null = null;
|
|
21
|
+
|
|
22
|
+
export function getScheduler(): Scheduler | null {
|
|
23
|
+
return schedulerInstance;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function setScheduler(s: Scheduler | null): void {
|
|
27
|
+
schedulerInstance = s;
|
|
28
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified scheduler — at / every / cron + heartbeat
|
|
3
|
+
*
|
|
4
|
+
* 持久化到 data/scheduler-jobs.json,支持单次 at、间隔 every、cron 表达式,
|
|
5
|
+
* 以及可选的 HEARTBEAT.md 周期检查。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { randomUUID } from 'crypto';
|
|
11
|
+
import { Cron as Croner } from 'croner';
|
|
12
|
+
import type {
|
|
13
|
+
Schedule,
|
|
14
|
+
JobPayload,
|
|
15
|
+
ScheduledJob,
|
|
16
|
+
JobStore,
|
|
17
|
+
JobCallback,
|
|
18
|
+
AddJobOptions,
|
|
19
|
+
IScheduler,
|
|
20
|
+
} from './types.js';
|
|
21
|
+
import { Logger } from '@zhin.js/logger';
|
|
22
|
+
|
|
23
|
+
const logger = new Logger(null, 'scheduler');
|
|
24
|
+
|
|
25
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
const HEARTBEAT_PROMPT = `Read HEARTBEAT.md in your workspace (if it exists).
|
|
28
|
+
Follow any instructions or tasks listed there.
|
|
29
|
+
If nothing needs attention, reply with just: HEARTBEAT_OK`;
|
|
30
|
+
|
|
31
|
+
function nowMs(): number {
|
|
32
|
+
return Date.now();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function computeNextRun(schedule: Schedule, currentMs: number): number | undefined {
|
|
36
|
+
if (schedule.kind === 'at') {
|
|
37
|
+
return schedule.atMs != null && schedule.atMs > currentMs ? schedule.atMs : undefined;
|
|
38
|
+
}
|
|
39
|
+
if (schedule.kind === 'every') {
|
|
40
|
+
if (schedule.everyMs == null || schedule.everyMs <= 0) return undefined;
|
|
41
|
+
return currentMs + schedule.everyMs;
|
|
42
|
+
}
|
|
43
|
+
if (schedule.kind === 'cron' && schedule.expr) {
|
|
44
|
+
try {
|
|
45
|
+
const job = new Croner(schedule.expr, { paused: true, timezone: schedule.tz });
|
|
46
|
+
const next = job.nextRun();
|
|
47
|
+
job.stop();
|
|
48
|
+
return next ? next.getTime() : undefined;
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createStore(): JobStore {
|
|
57
|
+
return { version: 1, jobs: [] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isHeartbeatEmpty(content: string | null): boolean {
|
|
61
|
+
if (!content) return true;
|
|
62
|
+
const skipPatterns = new Set(['- [ ]', '* [ ]', '- [x]', '* [x]']);
|
|
63
|
+
for (const line of content.split('\n')) {
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('<!--') || skipPatterns.has(trimmed)) continue;
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SchedulerOptions {
|
|
72
|
+
storePath: string;
|
|
73
|
+
workspace: string;
|
|
74
|
+
onJob?: JobCallback;
|
|
75
|
+
heartbeatEnabled?: boolean;
|
|
76
|
+
heartbeatIntervalMs?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class Scheduler implements IScheduler {
|
|
80
|
+
private storePath: string;
|
|
81
|
+
private workspace: string;
|
|
82
|
+
private onJob: JobCallback | null = null;
|
|
83
|
+
private store: JobStore | null = null;
|
|
84
|
+
private timerTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
+
private _running = false;
|
|
86
|
+
private heartbeatEnabled: boolean;
|
|
87
|
+
private heartbeatIntervalMs: number;
|
|
88
|
+
private heartbeatJobId: string | null = null;
|
|
89
|
+
|
|
90
|
+
constructor(options: SchedulerOptions) {
|
|
91
|
+
this.storePath = options.storePath;
|
|
92
|
+
this.workspace = options.workspace;
|
|
93
|
+
this.onJob = options.onJob ?? null;
|
|
94
|
+
this.heartbeatEnabled = options.heartbeatEnabled ?? true;
|
|
95
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private loadStore(): JobStore {
|
|
99
|
+
if (this.store) return this.store;
|
|
100
|
+
if (fs.existsSync(this.storePath)) {
|
|
101
|
+
try {
|
|
102
|
+
const data = JSON.parse(fs.readFileSync(this.storePath, 'utf-8'));
|
|
103
|
+
const jobs: ScheduledJob[] = (data.jobs || []).map((j: any) => ({
|
|
104
|
+
id: j.id,
|
|
105
|
+
name: j.name,
|
|
106
|
+
enabled: j.enabled ?? true,
|
|
107
|
+
schedule: {
|
|
108
|
+
kind: j.schedule?.kind ?? 'cron',
|
|
109
|
+
atMs: j.schedule?.atMs,
|
|
110
|
+
everyMs: j.schedule?.everyMs,
|
|
111
|
+
expr: j.schedule?.expr,
|
|
112
|
+
tz: j.schedule?.tz,
|
|
113
|
+
},
|
|
114
|
+
payload: {
|
|
115
|
+
kind: j.payload?.kind ?? 'agent_turn',
|
|
116
|
+
message: j.payload?.message ?? '',
|
|
117
|
+
deliver: j.payload?.deliver ?? false,
|
|
118
|
+
channel: j.payload?.channel,
|
|
119
|
+
to: j.payload?.to,
|
|
120
|
+
},
|
|
121
|
+
state: {
|
|
122
|
+
nextRunAtMs: j.state?.nextRunAtMs,
|
|
123
|
+
lastRunAtMs: j.state?.lastRunAtMs,
|
|
124
|
+
lastStatus: j.state?.lastStatus,
|
|
125
|
+
lastError: j.state?.lastError,
|
|
126
|
+
},
|
|
127
|
+
createdAtMs: j.createdAtMs ?? 0,
|
|
128
|
+
updatedAtMs: j.updatedAtMs ?? 0,
|
|
129
|
+
deleteAfterRun: j.deleteAfterRun ?? false,
|
|
130
|
+
}));
|
|
131
|
+
this.store = { version: data.version ?? 1, jobs };
|
|
132
|
+
} catch (e) {
|
|
133
|
+
logger.warn('Failed to load scheduler store', e);
|
|
134
|
+
this.store = createStore();
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
this.store = createStore();
|
|
138
|
+
}
|
|
139
|
+
return this.store;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private saveStore(): void {
|
|
143
|
+
if (!this.store) return;
|
|
144
|
+
const dir = path.dirname(this.storePath);
|
|
145
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
146
|
+
const persistJobs = this.store.jobs.filter(j => j.id !== this.heartbeatJobId);
|
|
147
|
+
const data = {
|
|
148
|
+
version: this.store.version,
|
|
149
|
+
jobs: persistJobs.map(j => ({
|
|
150
|
+
id: j.id,
|
|
151
|
+
name: j.name,
|
|
152
|
+
enabled: j.enabled,
|
|
153
|
+
schedule: j.schedule,
|
|
154
|
+
payload: j.payload,
|
|
155
|
+
state: j.state,
|
|
156
|
+
createdAtMs: j.createdAtMs,
|
|
157
|
+
updatedAtMs: j.updatedAtMs,
|
|
158
|
+
deleteAfterRun: j.deleteAfterRun,
|
|
159
|
+
})),
|
|
160
|
+
};
|
|
161
|
+
fs.writeFileSync(this.storePath, JSON.stringify(data, null, 2));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async start(): Promise<void> {
|
|
165
|
+
this._running = true;
|
|
166
|
+
this.loadStore();
|
|
167
|
+
if (this.heartbeatEnabled) this.addHeartbeatJob();
|
|
168
|
+
this.recomputeNextRuns();
|
|
169
|
+
this.saveStore();
|
|
170
|
+
this.armTimer();
|
|
171
|
+
logger.info({ jobs: this.store?.jobs.length ?? 0 }, 'Scheduler started');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
stop(): void {
|
|
175
|
+
this._running = false;
|
|
176
|
+
if (this.timerTimeout) {
|
|
177
|
+
clearTimeout(this.timerTimeout);
|
|
178
|
+
this.timerTimeout = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private addHeartbeatJob(): void {
|
|
183
|
+
if (!this.store) return;
|
|
184
|
+
const existing = this.store.jobs.find(j => j.payload.kind === 'heartbeat');
|
|
185
|
+
if (existing) {
|
|
186
|
+
this.heartbeatJobId = existing.id;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const now = nowMs();
|
|
190
|
+
const job: ScheduledJob = {
|
|
191
|
+
id: `heartbeat-${randomUUID().slice(0, 8)}`,
|
|
192
|
+
name: 'Heartbeat',
|
|
193
|
+
enabled: true,
|
|
194
|
+
schedule: { kind: 'every', everyMs: this.heartbeatIntervalMs },
|
|
195
|
+
payload: { kind: 'heartbeat', message: HEARTBEAT_PROMPT, deliver: false },
|
|
196
|
+
state: { nextRunAtMs: now + this.heartbeatIntervalMs },
|
|
197
|
+
createdAtMs: now,
|
|
198
|
+
updatedAtMs: now,
|
|
199
|
+
deleteAfterRun: false,
|
|
200
|
+
};
|
|
201
|
+
this.heartbeatJobId = job.id;
|
|
202
|
+
this.store.jobs.push(job);
|
|
203
|
+
logger.info({ intervalMs: this.heartbeatIntervalMs }, 'Heartbeat job added');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private recomputeNextRuns(): void {
|
|
207
|
+
if (!this.store) return;
|
|
208
|
+
const now = nowMs();
|
|
209
|
+
for (const job of this.store.jobs) {
|
|
210
|
+
if (job.enabled) job.state.nextRunAtMs = computeNextRun(job.schedule, now);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private getNextWakeMs(): number | undefined {
|
|
215
|
+
if (!this.store) return undefined;
|
|
216
|
+
const times = this.store.jobs
|
|
217
|
+
.filter(j => j.enabled && j.state.nextRunAtMs != null)
|
|
218
|
+
.map(j => j.state.nextRunAtMs!);
|
|
219
|
+
return times.length > 0 ? Math.min(...times) : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private armTimer(): void {
|
|
223
|
+
if (this.timerTimeout) {
|
|
224
|
+
clearTimeout(this.timerTimeout);
|
|
225
|
+
this.timerTimeout = null;
|
|
226
|
+
}
|
|
227
|
+
const nextWake = this.getNextWakeMs();
|
|
228
|
+
if (nextWake == null || !this._running) return;
|
|
229
|
+
const delayMs = Math.max(0, nextWake - nowMs());
|
|
230
|
+
this.timerTimeout = setTimeout(async () => {
|
|
231
|
+
if (this._running) await this.onTimer();
|
|
232
|
+
}, delayMs);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private async onTimer(): Promise<void> {
|
|
236
|
+
if (!this.store) return;
|
|
237
|
+
const now = nowMs();
|
|
238
|
+
const dueJobs = this.store.jobs.filter(
|
|
239
|
+
j => j.enabled && j.state.nextRunAtMs != null && now >= j.state.nextRunAtMs!
|
|
240
|
+
);
|
|
241
|
+
for (const job of dueJobs) await this.executeJob(job);
|
|
242
|
+
this.saveStore();
|
|
243
|
+
this.armTimer();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async executeJob(job: ScheduledJob): Promise<void> {
|
|
247
|
+
const startMs = nowMs();
|
|
248
|
+
if (job.payload.kind === 'heartbeat') {
|
|
249
|
+
const shouldRun = this.checkHeartbeatFile();
|
|
250
|
+
if (!shouldRun) {
|
|
251
|
+
job.state.lastStatus = 'skipped';
|
|
252
|
+
job.state.lastRunAtMs = startMs;
|
|
253
|
+
job.updatedAtMs = nowMs();
|
|
254
|
+
job.state.nextRunAtMs = computeNextRun(job.schedule, nowMs());
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
logger.info({ jobId: job.id, name: job.name }, 'Scheduler: executing job');
|
|
259
|
+
try {
|
|
260
|
+
if (this.onJob) await this.onJob(job);
|
|
261
|
+
job.state.lastStatus = 'ok';
|
|
262
|
+
job.state.lastError = undefined;
|
|
263
|
+
logger.info({ jobId: job.id, name: job.name }, 'Scheduler: job completed');
|
|
264
|
+
} catch (error) {
|
|
265
|
+
job.state.lastStatus = 'error';
|
|
266
|
+
job.state.lastError = String(error);
|
|
267
|
+
logger.error({ jobId: job.id, name: job.name, lastError: String(error) }, 'Scheduler: job failed');
|
|
268
|
+
}
|
|
269
|
+
job.state.lastRunAtMs = startMs;
|
|
270
|
+
job.updatedAtMs = nowMs();
|
|
271
|
+
if (job.schedule.kind === 'at') {
|
|
272
|
+
if (job.deleteAfterRun && this.store) {
|
|
273
|
+
this.store.jobs = this.store.jobs.filter(j => j.id !== job.id);
|
|
274
|
+
} else {
|
|
275
|
+
job.enabled = false;
|
|
276
|
+
job.state.nextRunAtMs = undefined;
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
job.state.nextRunAtMs = computeNextRun(job.schedule, nowMs());
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private checkHeartbeatFile(): boolean {
|
|
284
|
+
const heartbeatPath = path.join(this.workspace, 'HEARTBEAT.md');
|
|
285
|
+
if (!fs.existsSync(heartbeatPath)) return false;
|
|
286
|
+
try {
|
|
287
|
+
const content = fs.readFileSync(heartbeatPath, 'utf-8');
|
|
288
|
+
return !isHeartbeatEmpty(content);
|
|
289
|
+
} catch {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
listJobs(): ScheduledJob[] {
|
|
295
|
+
const store = this.loadStore();
|
|
296
|
+
return store.jobs
|
|
297
|
+
.filter(j => j.id !== this.heartbeatJobId)
|
|
298
|
+
.sort((a, b) => (a.state.nextRunAtMs ?? Infinity) - (b.state.nextRunAtMs ?? Infinity));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
addJob(options: AddJobOptions): ScheduledJob {
|
|
302
|
+
const store = this.loadStore();
|
|
303
|
+
const now = nowMs();
|
|
304
|
+
const job: ScheduledJob = {
|
|
305
|
+
id: randomUUID().slice(0, 8),
|
|
306
|
+
name: options.name,
|
|
307
|
+
enabled: options.enabled ?? true,
|
|
308
|
+
schedule: options.schedule,
|
|
309
|
+
payload: options.payload,
|
|
310
|
+
state: { nextRunAtMs: computeNextRun(options.schedule, now) },
|
|
311
|
+
createdAtMs: now,
|
|
312
|
+
updatedAtMs: now,
|
|
313
|
+
deleteAfterRun: options.deleteAfterRun ?? false,
|
|
314
|
+
};
|
|
315
|
+
store.jobs.push(job);
|
|
316
|
+
this.saveStore();
|
|
317
|
+
this.armTimer();
|
|
318
|
+
logger.info({ jobId: job.id, name: job.name }, 'Scheduler: added job');
|
|
319
|
+
return job;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
removeJob(jobId: string): boolean {
|
|
323
|
+
const store = this.loadStore();
|
|
324
|
+
const before = store.jobs.length;
|
|
325
|
+
store.jobs = store.jobs.filter(j => j.id !== jobId);
|
|
326
|
+
const removed = store.jobs.length < before;
|
|
327
|
+
if (removed) {
|
|
328
|
+
this.saveStore();
|
|
329
|
+
this.armTimer();
|
|
330
|
+
logger.info({ jobId }, 'Scheduler: removed job');
|
|
331
|
+
}
|
|
332
|
+
return removed;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
enableJob(jobId: string, enabled: boolean = true): boolean {
|
|
336
|
+
const store = this.loadStore();
|
|
337
|
+
const job = store.jobs.find(j => j.id === jobId);
|
|
338
|
+
if (!job) return false;
|
|
339
|
+
job.enabled = enabled;
|
|
340
|
+
job.updatedAtMs = nowMs();
|
|
341
|
+
job.state.nextRunAtMs = enabled ? computeNextRun(job.schedule, nowMs()) : undefined;
|
|
342
|
+
this.saveStore();
|
|
343
|
+
this.armTimer();
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async runJob(jobId: string): Promise<void> {
|
|
348
|
+
const store = this.loadStore();
|
|
349
|
+
const job = store.jobs.find(j => j.id === jobId);
|
|
350
|
+
if (job) {
|
|
351
|
+
await this.executeJob(job);
|
|
352
|
+
this.saveStore();
|
|
353
|
+
this.armTimer();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
status(): { running: boolean; jobCount: number; nextWakeAt?: number } {
|
|
358
|
+
const store = this.loadStore();
|
|
359
|
+
return {
|
|
360
|
+
running: this._running,
|
|
361
|
+
jobCount: store.jobs.filter(j => j.id !== this.heartbeatJobId).length,
|
|
362
|
+
nextWakeAt: this.getNextWakeMs(),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async triggerHeartbeat(): Promise<void> {
|
|
367
|
+
if (this.heartbeatJobId && this.store) {
|
|
368
|
+
const job = this.store.jobs.find(j => j.id === this.heartbeatJobId);
|
|
369
|
+
if (job && this.onJob) await this.onJob(job);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler types
|
|
3
|
+
*
|
|
4
|
+
* 支持三种调度:at(单次指定时间)、every(固定间隔)、cron(表达式)
|
|
5
|
+
* Payload 支持 agent_turn(到点执行 prompt)、heartbeat(读 HEARTBEAT.md)、system_event
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface Schedule {
|
|
9
|
+
kind: 'at' | 'every' | 'cron';
|
|
10
|
+
/** 单次执行时间戳(kind=at) */
|
|
11
|
+
atMs?: number;
|
|
12
|
+
/** 间隔毫秒(kind=every) */
|
|
13
|
+
everyMs?: number;
|
|
14
|
+
/** Cron 表达式(kind=cron) */
|
|
15
|
+
expr?: string;
|
|
16
|
+
/** 时区(kind=cron 可选) */
|
|
17
|
+
tz?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface JobPayload {
|
|
21
|
+
kind: 'system_event' | 'agent_turn' | 'heartbeat';
|
|
22
|
+
/** 触发时发给 AI 的 prompt(agent_turn)或 heartbeat 说明 */
|
|
23
|
+
message: string;
|
|
24
|
+
/** 是否投递到指定 channel/user */
|
|
25
|
+
deliver: boolean;
|
|
26
|
+
channel?: string;
|
|
27
|
+
to?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface JobState {
|
|
31
|
+
nextRunAtMs?: number;
|
|
32
|
+
lastRunAtMs?: number;
|
|
33
|
+
lastStatus?: 'ok' | 'error' | 'skipped';
|
|
34
|
+
lastError?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ScheduledJob {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
schedule: Schedule;
|
|
42
|
+
payload: JobPayload;
|
|
43
|
+
state: JobState;
|
|
44
|
+
createdAtMs: number;
|
|
45
|
+
updatedAtMs: number;
|
|
46
|
+
/** 单次任务执行后是否删除 */
|
|
47
|
+
deleteAfterRun: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface JobStore {
|
|
51
|
+
version: number;
|
|
52
|
+
jobs: ScheduledJob[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type JobCallback = (job: ScheduledJob) => Promise<void>;
|
|
56
|
+
|
|
57
|
+
export interface AddJobOptions {
|
|
58
|
+
name: string;
|
|
59
|
+
schedule: Schedule;
|
|
60
|
+
payload: JobPayload;
|
|
61
|
+
enabled?: boolean;
|
|
62
|
+
deleteAfterRun?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface IScheduler {
|
|
66
|
+
start(): Promise<void>;
|
|
67
|
+
stop(): void;
|
|
68
|
+
addJob(options: AddJobOptions): ScheduledJob;
|
|
69
|
+
removeJob(jobId: string): boolean;
|
|
70
|
+
enableJob(jobId: string, enabled: boolean): boolean;
|
|
71
|
+
runJob(jobId: string): Promise<void>;
|
|
72
|
+
listJobs(): ScheduledJob[];
|
|
73
|
+
status(): { running: boolean; jobCount: number; nextWakeAt?: number };
|
|
74
|
+
}
|