instar 0.1.0
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/.claude/settings.local.json +7 -0
- package/.claude/skills/setup-wizard/skill.md +343 -0
- package/.github/workflows/ci.yml +78 -0
- package/CLAUDE.md +82 -0
- package/README.md +194 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +141 -0
- package/dist/commands/init.d.ts +40 -0
- package/dist/commands/init.js +568 -0
- package/dist/commands/job.d.ts +20 -0
- package/dist/commands/job.js +84 -0
- package/dist/commands/server.d.ts +19 -0
- package/dist/commands/server.js +273 -0
- package/dist/commands/setup.d.ts +24 -0
- package/dist/commands/setup.js +865 -0
- package/dist/commands/status.d.ts +11 -0
- package/dist/commands/status.js +114 -0
- package/dist/commands/user.d.ts +17 -0
- package/dist/commands/user.js +53 -0
- package/dist/core/Config.d.ts +16 -0
- package/dist/core/Config.js +144 -0
- package/dist/core/Prerequisites.d.ts +28 -0
- package/dist/core/Prerequisites.js +159 -0
- package/dist/core/RelationshipManager.d.ts +73 -0
- package/dist/core/RelationshipManager.js +318 -0
- package/dist/core/SessionManager.d.ts +89 -0
- package/dist/core/SessionManager.js +326 -0
- package/dist/core/StateManager.d.ts +28 -0
- package/dist/core/StateManager.js +96 -0
- package/dist/core/types.d.ts +279 -0
- package/dist/core/types.js +8 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +23 -0
- package/dist/messaging/TelegramAdapter.d.ts +73 -0
- package/dist/messaging/TelegramAdapter.js +288 -0
- package/dist/monitoring/HealthChecker.d.ts +38 -0
- package/dist/monitoring/HealthChecker.js +148 -0
- package/dist/scaffold/bootstrap.d.ts +21 -0
- package/dist/scaffold/bootstrap.js +110 -0
- package/dist/scaffold/templates.d.ts +34 -0
- package/dist/scaffold/templates.js +187 -0
- package/dist/scheduler/JobLoader.d.ts +18 -0
- package/dist/scheduler/JobLoader.js +70 -0
- package/dist/scheduler/JobScheduler.d.ts +111 -0
- package/dist/scheduler/JobScheduler.js +402 -0
- package/dist/server/AgentServer.d.ts +40 -0
- package/dist/server/AgentServer.js +73 -0
- package/dist/server/middleware.d.ts +12 -0
- package/dist/server/middleware.js +50 -0
- package/dist/server/routes.d.ts +25 -0
- package/dist/server/routes.js +224 -0
- package/dist/users/UserManager.d.ts +45 -0
- package/dist/users/UserManager.js +113 -0
- package/docs/dawn-audit-report.md +412 -0
- package/docs/positioning-vs-openclaw.md +246 -0
- package/package.json +52 -0
- package/src/cli.ts +169 -0
- package/src/commands/init.ts +654 -0
- package/src/commands/job.ts +110 -0
- package/src/commands/server.ts +325 -0
- package/src/commands/setup.ts +958 -0
- package/src/commands/status.ts +125 -0
- package/src/commands/user.ts +71 -0
- package/src/core/Config.ts +161 -0
- package/src/core/Prerequisites.ts +187 -0
- package/src/core/RelationshipManager.ts +366 -0
- package/src/core/SessionManager.ts +385 -0
- package/src/core/StateManager.ts +121 -0
- package/src/core/types.ts +320 -0
- package/src/index.ts +58 -0
- package/src/messaging/TelegramAdapter.ts +365 -0
- package/src/monitoring/HealthChecker.ts +172 -0
- package/src/scaffold/bootstrap.ts +122 -0
- package/src/scaffold/templates.ts +204 -0
- package/src/scheduler/JobLoader.ts +85 -0
- package/src/scheduler/JobScheduler.ts +476 -0
- package/src/server/AgentServer.ts +93 -0
- package/src/server/middleware.ts +58 -0
- package/src/server/routes.ts +278 -0
- package/src/templates/default-jobs.json +47 -0
- package/src/templates/hooks/compaction-recovery.sh +23 -0
- package/src/templates/hooks/dangerous-command-guard.sh +35 -0
- package/src/templates/hooks/grounding-before-messaging.sh +22 -0
- package/src/templates/hooks/session-start.sh +37 -0
- package/src/templates/hooks/settings-template.json +45 -0
- package/src/templates/scripts/health-watchdog.sh +63 -0
- package/src/templates/scripts/telegram-reply.sh +54 -0
- package/src/users/UserManager.ts +129 -0
- package/tests/e2e/lifecycle.test.ts +376 -0
- package/tests/fixtures/test-repo/CLAUDE.md +3 -0
- package/tests/fixtures/test-repo/README.md +1 -0
- package/tests/helpers/setup.ts +209 -0
- package/tests/integration/fresh-install.test.ts +218 -0
- package/tests/integration/scheduler-basic.test.ts +109 -0
- package/tests/integration/server-full.test.ts +284 -0
- package/tests/integration/session-lifecycle.test.ts +181 -0
- package/tests/unit/Config.test.ts +22 -0
- package/tests/unit/HealthChecker.test.ts +168 -0
- package/tests/unit/JobLoader.test.ts +151 -0
- package/tests/unit/JobScheduler.test.ts +267 -0
- package/tests/unit/Prerequisites.test.ts +59 -0
- package/tests/unit/RelationshipManager.test.ts +345 -0
- package/tests/unit/StateManager.test.ts +143 -0
- package/tests/unit/TelegramAdapter.test.ts +165 -0
- package/tests/unit/UserManager.test.ts +131 -0
- package/tests/unit/bootstrap.test.ts +28 -0
- package/tests/unit/commands.test.ts +138 -0
- package/tests/unit/middleware.test.ts +92 -0
- package/tests/unit/relationship-routes.test.ts +131 -0
- package/tests/unit/scaffold-templates.test.ts +132 -0
- package/tests/unit/server.test.ts +163 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +9 -0
- package/vitest.e2e.config.ts +9 -0
- package/vitest.integration.config.ts +9 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job Scheduler — cron-based job execution engine.
|
|
3
|
+
*
|
|
4
|
+
* Schedules jobs via croner, respects session limits and quota,
|
|
5
|
+
* queues jobs when at capacity, and drains when slots open.
|
|
6
|
+
*
|
|
7
|
+
* Simplified from Dawn's 1400-line scheduler — serial queue,
|
|
8
|
+
* no JSONL discovery, no machine coordination.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Cron } from 'croner';
|
|
12
|
+
import { loadJobs } from './JobLoader.js';
|
|
13
|
+
import type { SessionManager } from '../core/SessionManager.js';
|
|
14
|
+
import type { StateManager } from '../core/StateManager.js';
|
|
15
|
+
import type { MessagingAdapter } from '../core/types.js';
|
|
16
|
+
import type { JobDefinition, JobSchedulerConfig, JobState, JobPriority } from '../core/types.js';
|
|
17
|
+
import type { TelegramAdapter } from '../messaging/TelegramAdapter.js';
|
|
18
|
+
|
|
19
|
+
interface QueuedJob {
|
|
20
|
+
slug: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
queuedAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SchedulerStatus {
|
|
26
|
+
running: boolean;
|
|
27
|
+
paused: boolean;
|
|
28
|
+
jobCount: number;
|
|
29
|
+
enabledJobs: number;
|
|
30
|
+
queueLength: number;
|
|
31
|
+
activeJobSessions: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PRIORITY_ORDER: Record<JobPriority, number> = {
|
|
35
|
+
critical: 0,
|
|
36
|
+
high: 1,
|
|
37
|
+
medium: 2,
|
|
38
|
+
low: 3,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export class JobScheduler {
|
|
42
|
+
private config: JobSchedulerConfig;
|
|
43
|
+
private sessionManager: SessionManager;
|
|
44
|
+
private state: StateManager;
|
|
45
|
+
private jobs: JobDefinition[] = [];
|
|
46
|
+
private cronTasks: Map<string, Cron> = new Map();
|
|
47
|
+
private queue: QueuedJob[] = [];
|
|
48
|
+
private running = false;
|
|
49
|
+
private paused = false;
|
|
50
|
+
|
|
51
|
+
/** Callback to check if quota allows running a job at the given priority */
|
|
52
|
+
canRunJob: (priority: JobPriority) => boolean = () => true;
|
|
53
|
+
|
|
54
|
+
/** Optional messenger for sending job notifications */
|
|
55
|
+
private messenger: MessagingAdapter | null = null;
|
|
56
|
+
|
|
57
|
+
/** Optional Telegram adapter for job-topic coupling */
|
|
58
|
+
private telegram: TelegramAdapter | null = null;
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
config: JobSchedulerConfig,
|
|
62
|
+
sessionManager: SessionManager,
|
|
63
|
+
state: StateManager,
|
|
64
|
+
) {
|
|
65
|
+
this.config = config;
|
|
66
|
+
this.sessionManager = sessionManager;
|
|
67
|
+
this.state = state;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Set a messaging adapter for job completion notifications.
|
|
72
|
+
*/
|
|
73
|
+
setMessenger(adapter: MessagingAdapter): void {
|
|
74
|
+
this.messenger = adapter;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Set the Telegram adapter for job-topic coupling.
|
|
79
|
+
* Every job gets its own topic — the user's window into the job.
|
|
80
|
+
*/
|
|
81
|
+
setTelegram(adapter: TelegramAdapter): void {
|
|
82
|
+
this.telegram = adapter;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Start the scheduler — load jobs, set up cron tasks, check for missed jobs.
|
|
87
|
+
*/
|
|
88
|
+
start(): void {
|
|
89
|
+
if (this.running) return;
|
|
90
|
+
|
|
91
|
+
this.jobs = loadJobs(this.config.jobsFile);
|
|
92
|
+
this.running = true;
|
|
93
|
+
|
|
94
|
+
const enabledJobs = this.jobs.filter(j => j.enabled);
|
|
95
|
+
for (const job of enabledJobs) {
|
|
96
|
+
const task = new Cron(job.schedule, () => {
|
|
97
|
+
this.triggerJob(job.slug, 'scheduled');
|
|
98
|
+
});
|
|
99
|
+
this.cronTasks.set(job.slug, task);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check for missed jobs — any enabled job overdue by >1.5x its interval
|
|
103
|
+
this.checkMissedJobs(enabledJobs);
|
|
104
|
+
|
|
105
|
+
// Ensure every job has a Telegram topic (job-topic coupling)
|
|
106
|
+
if (this.telegram) {
|
|
107
|
+
this.ensureJobTopics(enabledJobs).catch(err => {
|
|
108
|
+
console.error(`[scheduler] Failed to ensure job topics: ${err}`);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.state.appendEvent({
|
|
113
|
+
type: 'scheduler_start',
|
|
114
|
+
summary: `Scheduler started with ${enabledJobs.length} enabled jobs`,
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Stop the scheduler — cancel all cron tasks.
|
|
121
|
+
*/
|
|
122
|
+
stop(): void {
|
|
123
|
+
if (!this.running) return;
|
|
124
|
+
|
|
125
|
+
for (const [, task] of this.cronTasks) {
|
|
126
|
+
task.stop();
|
|
127
|
+
}
|
|
128
|
+
this.cronTasks.clear();
|
|
129
|
+
this.queue = [];
|
|
130
|
+
this.running = false;
|
|
131
|
+
|
|
132
|
+
this.state.appendEvent({
|
|
133
|
+
type: 'scheduler_stop',
|
|
134
|
+
summary: 'Scheduler stopped',
|
|
135
|
+
timestamp: new Date().toISOString(),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Trigger a job by slug. Checks quota, session limits, queues if at capacity.
|
|
141
|
+
*/
|
|
142
|
+
triggerJob(slug: string, reason: string): 'triggered' | 'queued' | 'skipped' {
|
|
143
|
+
const job = this.jobs.find(j => j.slug === slug);
|
|
144
|
+
if (!job) {
|
|
145
|
+
throw new Error(`Unknown job: ${slug}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (this.paused) {
|
|
149
|
+
return 'skipped';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!this.canRunJob(job.priority)) {
|
|
153
|
+
this.state.appendEvent({
|
|
154
|
+
type: 'job_skipped',
|
|
155
|
+
summary: `Job "${slug}" skipped — quota check failed`,
|
|
156
|
+
timestamp: new Date().toISOString(),
|
|
157
|
+
metadata: { slug, reason, priority: job.priority },
|
|
158
|
+
});
|
|
159
|
+
return 'skipped';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check session capacity
|
|
163
|
+
const runningSessions = this.sessionManager.listRunningSessions();
|
|
164
|
+
const jobSessions = runningSessions.filter(s => s.jobSlug);
|
|
165
|
+
if (jobSessions.length >= this.config.maxParallelJobs) {
|
|
166
|
+
this.enqueue(slug, reason);
|
|
167
|
+
return 'queued';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.spawnJobSession(job, reason);
|
|
171
|
+
return 'triggered';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Process the queue — dequeue and run next job if a slot is available.
|
|
176
|
+
*/
|
|
177
|
+
processQueue(): void {
|
|
178
|
+
if (this.paused || this.queue.length === 0) return;
|
|
179
|
+
|
|
180
|
+
const runningSessions = this.sessionManager.listRunningSessions();
|
|
181
|
+
const jobSessions = runningSessions.filter(s => s.jobSlug);
|
|
182
|
+
if (jobSessions.length >= this.config.maxParallelJobs) return;
|
|
183
|
+
|
|
184
|
+
const next = this.queue.shift();
|
|
185
|
+
if (!next) return;
|
|
186
|
+
|
|
187
|
+
const job = this.jobs.find(j => j.slug === next.slug);
|
|
188
|
+
if (!job) return;
|
|
189
|
+
|
|
190
|
+
if (!this.canRunJob(job.priority)) return;
|
|
191
|
+
|
|
192
|
+
this.spawnJobSession(job, `queued:${next.reason}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Pause — cron tasks keep ticking but triggers are skipped.
|
|
197
|
+
*/
|
|
198
|
+
pause(): void {
|
|
199
|
+
this.paused = true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Resume — triggers start executing again.
|
|
204
|
+
*/
|
|
205
|
+
resume(): void {
|
|
206
|
+
this.paused = false;
|
|
207
|
+
this.processQueue();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get scheduler status for the /status endpoint.
|
|
212
|
+
*/
|
|
213
|
+
getStatus(): SchedulerStatus {
|
|
214
|
+
const runningSessions = this.sessionManager.listRunningSessions();
|
|
215
|
+
return {
|
|
216
|
+
running: this.running,
|
|
217
|
+
paused: this.paused,
|
|
218
|
+
jobCount: this.jobs.length,
|
|
219
|
+
enabledJobs: this.jobs.filter(j => j.enabled).length,
|
|
220
|
+
queueLength: this.queue.length,
|
|
221
|
+
activeJobSessions: runningSessions.filter(s => s.jobSlug).length,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get loaded job definitions (for /jobs endpoint).
|
|
227
|
+
*/
|
|
228
|
+
getJobs(): JobDefinition[] {
|
|
229
|
+
return this.jobs;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get the current queue.
|
|
234
|
+
*/
|
|
235
|
+
getQueue(): QueuedJob[] {
|
|
236
|
+
return [...this.queue];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private enqueue(slug: string, reason: string): void {
|
|
240
|
+
// Don't queue duplicates
|
|
241
|
+
if (this.queue.some(q => q.slug === slug)) return;
|
|
242
|
+
|
|
243
|
+
this.queue.push({ slug, reason, queuedAt: new Date().toISOString() });
|
|
244
|
+
|
|
245
|
+
// Sort by priority — critical first
|
|
246
|
+
this.queue.sort((a, b) => {
|
|
247
|
+
const jobA = this.jobs.find(j => j.slug === a.slug);
|
|
248
|
+
const jobB = this.jobs.find(j => j.slug === b.slug);
|
|
249
|
+
return (PRIORITY_ORDER[jobA?.priority ?? 'low']) - (PRIORITY_ORDER[jobB?.priority ?? 'low']);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private spawnJobSession(job: JobDefinition, reason: string): void {
|
|
254
|
+
const prompt = this.buildPrompt(job);
|
|
255
|
+
const sessionName = `job-${job.slug}-${Date.now().toString(36)}`;
|
|
256
|
+
|
|
257
|
+
this.sessionManager.spawnSession({
|
|
258
|
+
name: sessionName,
|
|
259
|
+
prompt,
|
|
260
|
+
model: job.model,
|
|
261
|
+
jobSlug: job.slug,
|
|
262
|
+
triggeredBy: `scheduler:${reason}`,
|
|
263
|
+
}).then(() => {
|
|
264
|
+
// Update job state on success
|
|
265
|
+
const jobState: JobState = {
|
|
266
|
+
slug: job.slug,
|
|
267
|
+
lastRun: new Date().toISOString(),
|
|
268
|
+
consecutiveFailures: 0,
|
|
269
|
+
nextScheduled: this.getNextRun(job.slug),
|
|
270
|
+
};
|
|
271
|
+
this.state.saveJobState(jobState);
|
|
272
|
+
|
|
273
|
+
this.state.appendEvent({
|
|
274
|
+
type: 'job_triggered',
|
|
275
|
+
summary: `Job "${job.slug}" triggered (${reason})`,
|
|
276
|
+
sessionId: sessionName,
|
|
277
|
+
timestamp: new Date().toISOString(),
|
|
278
|
+
metadata: { slug: job.slug, reason, model: job.model },
|
|
279
|
+
});
|
|
280
|
+
}).catch((err) => {
|
|
281
|
+
// Track failure
|
|
282
|
+
const failures = this.getConsecutiveFailures(job.slug) + 1;
|
|
283
|
+
const jobState: JobState = {
|
|
284
|
+
slug: job.slug,
|
|
285
|
+
lastRun: new Date().toISOString(),
|
|
286
|
+
lastResult: 'failure',
|
|
287
|
+
consecutiveFailures: failures,
|
|
288
|
+
nextScheduled: this.getNextRun(job.slug),
|
|
289
|
+
};
|
|
290
|
+
this.state.saveJobState(jobState);
|
|
291
|
+
|
|
292
|
+
this.state.appendEvent({
|
|
293
|
+
type: 'job_error',
|
|
294
|
+
summary: `Job "${job.slug}" failed to spawn: ${err}`,
|
|
295
|
+
timestamp: new Date().toISOString(),
|
|
296
|
+
metadata: { slug: job.slug, consecutiveFailures: failures },
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private buildPrompt(job: JobDefinition): string {
|
|
302
|
+
switch (job.execute.type) {
|
|
303
|
+
case 'skill':
|
|
304
|
+
return `/${job.execute.value}${job.execute.args ? ' ' + job.execute.args : ''}`;
|
|
305
|
+
case 'prompt':
|
|
306
|
+
return job.execute.value;
|
|
307
|
+
case 'script':
|
|
308
|
+
return `Run this script: ${job.execute.value}${job.execute.args ? ' ' + job.execute.args : ''}`;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private getConsecutiveFailures(slug: string): number {
|
|
313
|
+
return this.state.getJobState(slug)?.consecutiveFailures ?? 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private getNextRun(slug: string): string | undefined {
|
|
317
|
+
const task = this.cronTasks.get(slug);
|
|
318
|
+
if (!task) return undefined;
|
|
319
|
+
const next = task.nextRun();
|
|
320
|
+
return next ? next.toISOString() : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Called when a job's session completes. Captures output and notifies via messenger.
|
|
325
|
+
*/
|
|
326
|
+
async notifyJobComplete(sessionName: string, tmuxSession: string): Promise<void> {
|
|
327
|
+
if (!this.messenger) return;
|
|
328
|
+
|
|
329
|
+
// Find which job this session belongs to by looking up session state
|
|
330
|
+
const session = this.state.getSession(sessionName);
|
|
331
|
+
if (!session?.jobSlug) return;
|
|
332
|
+
|
|
333
|
+
const job = this.jobs.find(j => j.slug === session.jobSlug);
|
|
334
|
+
if (!job) return;
|
|
335
|
+
|
|
336
|
+
// Capture the last output from the tmux session
|
|
337
|
+
let output = '';
|
|
338
|
+
try {
|
|
339
|
+
output = this.sessionManager.captureOutput(tmuxSession) ?? '';
|
|
340
|
+
} catch {
|
|
341
|
+
// Session may already be dead — that's fine
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Build a summary message
|
|
345
|
+
const duration = session.startedAt
|
|
346
|
+
? Math.round((Date.now() - new Date(session.startedAt).getTime()) / 1000)
|
|
347
|
+
: 0;
|
|
348
|
+
|
|
349
|
+
const durationStr = duration > 60
|
|
350
|
+
? `${Math.floor(duration / 60)}m ${duration % 60}s`
|
|
351
|
+
: `${duration}s`;
|
|
352
|
+
|
|
353
|
+
let summary = `*Job Complete: ${job.name}*\n`;
|
|
354
|
+
summary += `Status: ${session.status === 'failed' ? 'Failed' : 'Done'}\n`;
|
|
355
|
+
if (duration > 0) summary += `Duration: ${durationStr}\n`;
|
|
356
|
+
|
|
357
|
+
if (output) {
|
|
358
|
+
// Trim to last ~500 chars to keep the message readable
|
|
359
|
+
const trimmed = output.length > 500
|
|
360
|
+
? '...' + output.slice(-500)
|
|
361
|
+
: output;
|
|
362
|
+
summary += `\n\`\`\`\n${trimmed}\n\`\`\``;
|
|
363
|
+
} else {
|
|
364
|
+
summary += '\n_No output captured (session already closed)_';
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Send to the job's dedicated topic if available, otherwise fall back to generic messenger
|
|
368
|
+
if (this.telegram && job.topicId) {
|
|
369
|
+
try {
|
|
370
|
+
await this.telegram.sendToTopic(job.topicId, summary);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
console.error(`[scheduler] Failed to send to job topic ${job.topicId}: ${err}`);
|
|
373
|
+
// Topic may have been deleted — try to recreate
|
|
374
|
+
try {
|
|
375
|
+
const newTopic = await this.telegram.createForumTopic(
|
|
376
|
+
`Job: ${job.name}`,
|
|
377
|
+
7322096, // Blue for jobs
|
|
378
|
+
);
|
|
379
|
+
job.topicId = newTopic.topicId;
|
|
380
|
+
this.saveJobTopicMapping(job.slug, newTopic.topicId);
|
|
381
|
+
await this.telegram.sendToTopic(newTopic.topicId, summary);
|
|
382
|
+
} catch (recreateErr) {
|
|
383
|
+
console.error(`[scheduler] Failed to recreate topic for ${job.slug}: ${recreateErr}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} else if (this.messenger) {
|
|
387
|
+
try {
|
|
388
|
+
await this.messenger.send({
|
|
389
|
+
userId: 'system',
|
|
390
|
+
content: summary,
|
|
391
|
+
});
|
|
392
|
+
} catch (err) {
|
|
393
|
+
console.error(`[scheduler] Failed to send job notification: ${err}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Ensure every enabled job has a Telegram topic.
|
|
400
|
+
* Creates topics for jobs that don't have one.
|
|
401
|
+
* This is the "job-topic coupling" — every job lives in a topic.
|
|
402
|
+
*/
|
|
403
|
+
private async ensureJobTopics(enabledJobs: JobDefinition[]): Promise<void> {
|
|
404
|
+
if (!this.telegram) return;
|
|
405
|
+
|
|
406
|
+
// Load existing topic mappings
|
|
407
|
+
const mappings = this.state.get<Record<string, number>>('job-topic-mappings') ?? {};
|
|
408
|
+
|
|
409
|
+
for (const job of enabledJobs) {
|
|
410
|
+
// If job already has a topicId (from jobs.json or previous mapping), use it
|
|
411
|
+
if (job.topicId) {
|
|
412
|
+
mappings[job.slug] = job.topicId;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Check if we have a saved mapping
|
|
417
|
+
if (mappings[job.slug]) {
|
|
418
|
+
job.topicId = mappings[job.slug];
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Create a new topic for this job
|
|
423
|
+
try {
|
|
424
|
+
const topic = await this.telegram.createForumTopic(
|
|
425
|
+
`Job: ${job.name}`,
|
|
426
|
+
7322096, // Blue for automated jobs
|
|
427
|
+
);
|
|
428
|
+
job.topicId = topic.topicId;
|
|
429
|
+
mappings[job.slug] = topic.topicId;
|
|
430
|
+
|
|
431
|
+
await this.telegram.sendToTopic(topic.topicId,
|
|
432
|
+
`*${job.name}*\n${job.description}\n\nSchedule: \`${job.schedule}\`\nPriority: ${job.priority}\n\nThis topic is the home for this job. Reports, status updates, and errors will appear here.`
|
|
433
|
+
);
|
|
434
|
+
} catch (err) {
|
|
435
|
+
console.error(`[scheduler] Failed to create topic for job ${job.slug}: ${err}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
this.state.set('job-topic-mappings', mappings);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Save a job-topic mapping (used when recreating a deleted topic).
|
|
444
|
+
*/
|
|
445
|
+
private saveJobTopicMapping(slug: string, topicId: number): void {
|
|
446
|
+
const mappings = this.state.get<Record<string, number>>('job-topic-mappings') ?? {};
|
|
447
|
+
mappings[slug] = topicId;
|
|
448
|
+
this.state.set('job-topic-mappings', mappings);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private checkMissedJobs(enabledJobs: JobDefinition[]): void {
|
|
452
|
+
const now = Date.now();
|
|
453
|
+
|
|
454
|
+
for (const job of enabledJobs) {
|
|
455
|
+
const jobState = this.state.getJobState(job.slug);
|
|
456
|
+
if (!jobState?.lastRun) continue;
|
|
457
|
+
|
|
458
|
+
const lastRun = new Date(jobState.lastRun).getTime();
|
|
459
|
+
const task = this.cronTasks.get(job.slug);
|
|
460
|
+
if (!task) continue;
|
|
461
|
+
|
|
462
|
+
// Get expected interval from next two runs
|
|
463
|
+
const nextRun = task.nextRun();
|
|
464
|
+
const nextNextRun = task.nextRuns(2)[1];
|
|
465
|
+
if (!nextRun || !nextNextRun) continue;
|
|
466
|
+
|
|
467
|
+
const intervalMs = nextNextRun.getTime() - nextRun.getTime();
|
|
468
|
+
const timeSinceLastRun = now - lastRun;
|
|
469
|
+
|
|
470
|
+
// If overdue by more than 1.5x the interval, trigger immediately
|
|
471
|
+
if (timeSinceLastRun > intervalMs * 1.5) {
|
|
472
|
+
this.triggerJob(job.slug, 'missed');
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Server — HTTP server wrapping Express.
|
|
3
|
+
*
|
|
4
|
+
* Provides health checks, session management, job triggering,
|
|
5
|
+
* and event querying over a simple REST API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import express, { type Express } from 'express';
|
|
9
|
+
import type { Server } from 'node:http';
|
|
10
|
+
import type { SessionManager } from '../core/SessionManager.js';
|
|
11
|
+
import type { StateManager } from '../core/StateManager.js';
|
|
12
|
+
import type { JobScheduler } from '../scheduler/JobScheduler.js';
|
|
13
|
+
import type { TelegramAdapter } from '../messaging/TelegramAdapter.js';
|
|
14
|
+
import type { AgentKitConfig } from '../core/types.js';
|
|
15
|
+
import type { RelationshipManager } from '../core/RelationshipManager.js';
|
|
16
|
+
import { createRoutes } from './routes.js';
|
|
17
|
+
import { corsMiddleware, authMiddleware, errorHandler } from './middleware.js';
|
|
18
|
+
|
|
19
|
+
export class AgentServer {
|
|
20
|
+
private app: Express;
|
|
21
|
+
private server: Server | null = null;
|
|
22
|
+
private config: AgentKitConfig;
|
|
23
|
+
private startTime: Date;
|
|
24
|
+
|
|
25
|
+
constructor(options: {
|
|
26
|
+
config: AgentKitConfig;
|
|
27
|
+
sessionManager: SessionManager;
|
|
28
|
+
state: StateManager;
|
|
29
|
+
scheduler?: JobScheduler;
|
|
30
|
+
telegram?: TelegramAdapter;
|
|
31
|
+
relationships?: RelationshipManager;
|
|
32
|
+
}) {
|
|
33
|
+
this.config = options.config;
|
|
34
|
+
this.startTime = new Date();
|
|
35
|
+
this.app = express();
|
|
36
|
+
|
|
37
|
+
// Middleware
|
|
38
|
+
this.app.use(express.json());
|
|
39
|
+
this.app.use(corsMiddleware);
|
|
40
|
+
this.app.use(authMiddleware(options.config.authToken));
|
|
41
|
+
|
|
42
|
+
// Routes
|
|
43
|
+
const routes = createRoutes({
|
|
44
|
+
config: options.config,
|
|
45
|
+
sessionManager: options.sessionManager,
|
|
46
|
+
state: options.state,
|
|
47
|
+
scheduler: options.scheduler ?? null,
|
|
48
|
+
telegram: options.telegram ?? null,
|
|
49
|
+
relationships: options.relationships ?? null,
|
|
50
|
+
startTime: this.startTime,
|
|
51
|
+
});
|
|
52
|
+
this.app.use(routes);
|
|
53
|
+
|
|
54
|
+
// Error handler (must be last)
|
|
55
|
+
this.app.use(errorHandler);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Start the HTTP server.
|
|
60
|
+
*/
|
|
61
|
+
async start(): Promise<void> {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
this.server = this.app.listen(this.config.port, () => {
|
|
64
|
+
console.log(`[instar] Server listening on port ${this.config.port}`);
|
|
65
|
+
resolve();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Stop the HTTP server gracefully.
|
|
72
|
+
*/
|
|
73
|
+
async stop(): Promise<void> {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
if (!this.server) {
|
|
76
|
+
resolve();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.server.close((err) => {
|
|
80
|
+
if (err) reject(err);
|
|
81
|
+
else resolve();
|
|
82
|
+
this.server = null;
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Expose the Express app for testing with supertest.
|
|
89
|
+
*/
|
|
90
|
+
getApp(): Express {
|
|
91
|
+
return this.app;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express middleware — JSON parsing, CORS, auth, error handling.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
6
|
+
|
|
7
|
+
export function corsMiddleware(req: Request, res: Response, next: NextFunction): void {
|
|
8
|
+
res.header('Access-Control-Allow-Origin', 'http://localhost:*');
|
|
9
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
10
|
+
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
11
|
+
if (req.method === 'OPTIONS') {
|
|
12
|
+
res.sendStatus(204);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
next();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Auth middleware — enforces Bearer token on API endpoints.
|
|
20
|
+
* Health endpoint is exempt (used for external monitoring).
|
|
21
|
+
*/
|
|
22
|
+
export function authMiddleware(authToken?: string) {
|
|
23
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
24
|
+
// Skip auth if no token configured
|
|
25
|
+
if (!authToken) {
|
|
26
|
+
next();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Health endpoint is always public
|
|
31
|
+
if (req.path === '/health') {
|
|
32
|
+
next();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const header = req.headers.authorization;
|
|
37
|
+
if (!header || !header.startsWith('Bearer ')) {
|
|
38
|
+
res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const token = header.slice(7);
|
|
43
|
+
if (token !== authToken) {
|
|
44
|
+
res.status(403).json({ error: 'Invalid auth token' });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
next();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
|
|
53
|
+
console.error(`[server] Error: ${err.message}`);
|
|
54
|
+
res.status(500).json({
|
|
55
|
+
error: err.message,
|
|
56
|
+
timestamp: new Date().toISOString(),
|
|
57
|
+
});
|
|
58
|
+
}
|