@lovelybunch/api 1.0.57 → 1.0.59
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/dist/lib/git.d.ts +13 -0
- package/dist/lib/git.js +137 -5
- package/dist/lib/jobs/global-job-scheduler.d.ts +2 -0
- package/dist/lib/jobs/global-job-scheduler.js +11 -0
- package/dist/lib/jobs/job-runner.d.ts +17 -0
- package/dist/lib/jobs/job-runner.js +167 -0
- package/dist/lib/jobs/job-scheduler.d.ts +39 -0
- package/dist/lib/jobs/job-scheduler.js +309 -0
- package/dist/lib/jobs/job-store.d.ts +16 -0
- package/dist/lib/jobs/job-store.js +211 -0
- package/dist/lib/storage/file-storage.js +7 -5
- package/dist/lib/terminal/terminal-manager.d.ts +2 -0
- package/dist/lib/terminal/terminal-manager.js +65 -0
- package/dist/routes/api/v1/ai/route.d.ts +1 -7
- package/dist/routes/api/v1/ai/route.js +25 -12
- package/dist/routes/api/v1/git/index.js +63 -1
- package/dist/routes/api/v1/jobs/[id]/route.d.ts +133 -0
- package/dist/routes/api/v1/jobs/[id]/route.js +135 -0
- package/dist/routes/api/v1/jobs/[id]/run/route.d.ts +31 -0
- package/dist/routes/api/v1/jobs/[id]/run/route.js +37 -0
- package/dist/routes/api/v1/jobs/index.d.ts +3 -0
- package/dist/routes/api/v1/jobs/index.js +14 -0
- package/dist/routes/api/v1/jobs/route.d.ts +108 -0
- package/dist/routes/api/v1/jobs/route.js +144 -0
- package/dist/routes/api/v1/jobs/status/route.d.ts +23 -0
- package/dist/routes/api/v1/jobs/status/route.js +21 -0
- package/dist/routes/api/v1/resources/[id]/route.d.ts +2 -44
- package/dist/server-with-static.js +5 -0
- package/dist/server.js +5 -0
- package/package.json +4 -4
- package/static/assets/index-CHq6mL1J.css +33 -0
- package/static/assets/index-QHnHUcsV.js +820 -0
- package/static/index.html +2 -2
- package/static/assets/index-CRg4lVi6.js +0 -779
- package/static/assets/index-VqhUTak4.css +0 -33
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { JobStore } from './job-store.js';
|
|
3
|
+
import { JobRunner } from './job-runner.js';
|
|
4
|
+
const DAY_TO_INDEX = {
|
|
5
|
+
sunday: 0,
|
|
6
|
+
monday: 1,
|
|
7
|
+
tuesday: 2,
|
|
8
|
+
wednesday: 3,
|
|
9
|
+
thursday: 4,
|
|
10
|
+
friday: 5,
|
|
11
|
+
saturday: 6
|
|
12
|
+
};
|
|
13
|
+
export class JobScheduler {
|
|
14
|
+
store;
|
|
15
|
+
runner;
|
|
16
|
+
jobs = new Map();
|
|
17
|
+
runningJobs = new Set();
|
|
18
|
+
initialized = false;
|
|
19
|
+
constructor(store = new JobStore(), runner = new JobRunner()) {
|
|
20
|
+
this.store = store;
|
|
21
|
+
this.runner = runner;
|
|
22
|
+
}
|
|
23
|
+
async initialize() {
|
|
24
|
+
if (this.initialized)
|
|
25
|
+
return;
|
|
26
|
+
const jobs = await this.store.listJobs();
|
|
27
|
+
for (const job of jobs) {
|
|
28
|
+
await this.register(job);
|
|
29
|
+
}
|
|
30
|
+
this.initialized = true;
|
|
31
|
+
}
|
|
32
|
+
async register(job) {
|
|
33
|
+
this.clearTimer(job.id);
|
|
34
|
+
this.jobs.set(job.id, { job });
|
|
35
|
+
if (job.status !== 'active') {
|
|
36
|
+
await this.updateJobMetadata(job, undefined);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const nextRun = this.calculateNextRun(job);
|
|
40
|
+
await this.updateJobMetadata(job, nextRun);
|
|
41
|
+
if (!nextRun) {
|
|
42
|
+
console.warn(`No upcoming execution found for job ${job.id}`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const timeUntil = nextRun.getTime() - Date.now();
|
|
46
|
+
if (timeUntil <= 0) {
|
|
47
|
+
// Missed window – run immediately to catch up
|
|
48
|
+
setTimeout(() => {
|
|
49
|
+
this.execute(job.id, 'scheduled').catch((err) => {
|
|
50
|
+
console.error(`Failed to execute scheduled job ${job.id}:`, err);
|
|
51
|
+
});
|
|
52
|
+
}, 10);
|
|
53
|
+
this.jobs.set(job.id, { job: { ...job }, nextRun });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const delay = Math.max(0, timeUntil);
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
this.execute(job.id, 'scheduled').catch((err) => {
|
|
59
|
+
console.error(`Failed to execute scheduled job ${job.id}:`, err);
|
|
60
|
+
});
|
|
61
|
+
}, delay);
|
|
62
|
+
this.jobs.set(job.id, { job: { ...job, metadata: { ...job.metadata } }, timer, nextRun });
|
|
63
|
+
}
|
|
64
|
+
async unregister(jobId) {
|
|
65
|
+
this.clearTimer(jobId);
|
|
66
|
+
this.jobs.delete(jobId);
|
|
67
|
+
}
|
|
68
|
+
async refresh(jobId) {
|
|
69
|
+
const job = await this.store.getJob(jobId);
|
|
70
|
+
if (!job) {
|
|
71
|
+
this.unregister(jobId);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
await this.register(job);
|
|
75
|
+
}
|
|
76
|
+
async runNow(jobId, trigger = 'manual') {
|
|
77
|
+
await this.initialize();
|
|
78
|
+
return this.execute(jobId, trigger);
|
|
79
|
+
}
|
|
80
|
+
clearTimer(jobId) {
|
|
81
|
+
const managed = this.jobs.get(jobId);
|
|
82
|
+
if (managed?.timer) {
|
|
83
|
+
clearTimeout(managed.timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async execute(jobId, trigger) {
|
|
87
|
+
if (this.runningJobs.has(jobId)) {
|
|
88
|
+
console.warn(`Job ${jobId} is already running; skipping concurrent execution`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const job = await this.store.getJob(jobId);
|
|
92
|
+
if (!job) {
|
|
93
|
+
console.warn(`Attempted to run missing job ${jobId}`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
this.runningJobs.add(jobId);
|
|
97
|
+
const runId = `run-${randomUUID()}`;
|
|
98
|
+
const start = new Date();
|
|
99
|
+
const runRecord = {
|
|
100
|
+
id: runId,
|
|
101
|
+
jobId: job.id,
|
|
102
|
+
trigger,
|
|
103
|
+
status: 'running',
|
|
104
|
+
startedAt: start,
|
|
105
|
+
cliCommand: ''
|
|
106
|
+
};
|
|
107
|
+
const existingRuns = [runRecord, ...job.runs];
|
|
108
|
+
job.runs = existingRuns.slice(0, 50);
|
|
109
|
+
job.metadata.lastRunAt = start;
|
|
110
|
+
job.metadata.updatedAt = new Date();
|
|
111
|
+
await this.store.saveJob(job);
|
|
112
|
+
let outcome = null;
|
|
113
|
+
try {
|
|
114
|
+
const result = await this.runner.run(job, runId);
|
|
115
|
+
runRecord.status = result.status;
|
|
116
|
+
runRecord.finishedAt = new Date();
|
|
117
|
+
runRecord.summary = result.summary;
|
|
118
|
+
runRecord.outputPath = result.outputPath;
|
|
119
|
+
runRecord.error = result.error;
|
|
120
|
+
runRecord.cliCommand = result.cliCommand;
|
|
121
|
+
outcome = { ...runRecord };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
runRecord.status = 'failed';
|
|
125
|
+
runRecord.finishedAt = new Date();
|
|
126
|
+
runRecord.error = error?.message || 'Unknown error executing scheduled job';
|
|
127
|
+
runRecord.summary = runRecord.error;
|
|
128
|
+
outcome = { ...runRecord };
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await this.store.saveJob(job);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
console.error(`Failed to persist run state for job ${job.id}:`, error);
|
|
135
|
+
}
|
|
136
|
+
this.runningJobs.delete(jobId);
|
|
137
|
+
// Refresh schedule for next execution
|
|
138
|
+
await this.register(job);
|
|
139
|
+
return outcome;
|
|
140
|
+
}
|
|
141
|
+
async updateJobMetadata(job, nextRun) {
|
|
142
|
+
const managed = this.jobs.get(job.id);
|
|
143
|
+
const updated = {
|
|
144
|
+
...job,
|
|
145
|
+
metadata: {
|
|
146
|
+
...job.metadata,
|
|
147
|
+
nextRunAt: nextRun
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
try {
|
|
151
|
+
await this.store.saveJob(updated);
|
|
152
|
+
this.jobs.set(job.id, { job: updated, timer: managed?.timer, nextRun });
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
console.error(`Failed to update next run for job ${job.id}:`, error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
calculateNextRun(job, from = new Date()) {
|
|
159
|
+
const schedule = job.schedule;
|
|
160
|
+
if (schedule.type === 'interval') {
|
|
161
|
+
return this.calculateNextInterval(schedule, from);
|
|
162
|
+
}
|
|
163
|
+
if (schedule.type === 'cron') {
|
|
164
|
+
return this.calculateNextCron(schedule.expression, from);
|
|
165
|
+
}
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
calculateNextInterval(schedule, from) {
|
|
169
|
+
const hours = Math.max(1, schedule.hours || 1);
|
|
170
|
+
const allowedDays = Array.isArray(schedule.daysOfWeek) && schedule.daysOfWeek.length > 0
|
|
171
|
+
? new Set(schedule.daysOfWeek.map((day) => DAY_TO_INDEX[day.toLowerCase()] ?? -1))
|
|
172
|
+
: new Set([0, 1, 2, 3, 4, 5, 6]);
|
|
173
|
+
let next = new Date(from.getTime());
|
|
174
|
+
next.setSeconds(0, 0);
|
|
175
|
+
next.setMinutes(0);
|
|
176
|
+
// Move forward by one interval window
|
|
177
|
+
next = new Date(next.getTime() + hours * 60 * 60 * 1000);
|
|
178
|
+
next.setMinutes(0, 0, 0);
|
|
179
|
+
let guard = 0;
|
|
180
|
+
while (!allowedDays.has(next.getDay())) {
|
|
181
|
+
next = new Date(next.getTime() + 60 * 60 * 1000);
|
|
182
|
+
next.setMinutes(0, 0, 0);
|
|
183
|
+
guard += 1;
|
|
184
|
+
if (guard > 24 * 14) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (typeof schedule.anchorHour === 'number') {
|
|
189
|
+
next.setHours(schedule.anchorHour, 0, 0, 0);
|
|
190
|
+
if (next <= from) {
|
|
191
|
+
next = new Date(next.getTime() + hours * 60 * 60 * 1000);
|
|
192
|
+
next.setMinutes(0, 0, 0);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return next;
|
|
196
|
+
}
|
|
197
|
+
calculateNextCron(expression, from) {
|
|
198
|
+
const fields = expression.trim().split(/\s+/);
|
|
199
|
+
if (fields.length !== 5) {
|
|
200
|
+
console.warn(`Invalid cron expression "${expression}". Expected 5 fields.`);
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
const minuteField = this.parseCronField(fields[0], 0, 59);
|
|
204
|
+
const hourField = this.parseCronField(fields[1], 0, 23);
|
|
205
|
+
const domField = this.parseCronField(fields[2], 1, 31);
|
|
206
|
+
const monthField = this.parseCronField(fields[3], 1, 12);
|
|
207
|
+
const dowField = this.parseCronField(fields[4], 0, 6, true);
|
|
208
|
+
let cursor = new Date(from.getTime());
|
|
209
|
+
cursor.setSeconds(0, 0);
|
|
210
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
211
|
+
const limit = from.getTime() + 1000 * 60 * 60 * 24 * 366; // search up to 1 year ahead
|
|
212
|
+
while (cursor.getTime() <= limit) {
|
|
213
|
+
const minuteMatch = minuteField.isWildcard || minuteField.values.has(cursor.getMinutes());
|
|
214
|
+
const hourMatch = hourField.isWildcard || hourField.values.has(cursor.getHours());
|
|
215
|
+
const monthMatch = monthField.isWildcard || monthField.values.has(cursor.getMonth() + 1);
|
|
216
|
+
const domMatch = domField.isWildcard || domField.values.has(cursor.getDate());
|
|
217
|
+
const dowMatch = dowField.isWildcard || dowField.values.has(cursor.getDay());
|
|
218
|
+
const dayMatch = this.resolveDayMatch(domField.isWildcard, dowField.isWildcard, domMatch, dowMatch);
|
|
219
|
+
if (minuteMatch && hourMatch && monthMatch && dayMatch) {
|
|
220
|
+
return new Date(cursor.getTime());
|
|
221
|
+
}
|
|
222
|
+
cursor = new Date(cursor.getTime() + 60 * 1000);
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
resolveDayMatch(domWildcard, dowWildcard, domMatch, dowMatch) {
|
|
227
|
+
if (domWildcard && dowWildcard)
|
|
228
|
+
return true;
|
|
229
|
+
if (domWildcard)
|
|
230
|
+
return dowMatch;
|
|
231
|
+
if (dowWildcard)
|
|
232
|
+
return domMatch;
|
|
233
|
+
return domMatch || dowMatch;
|
|
234
|
+
}
|
|
235
|
+
parseCronField(field, min, max, isDayOfWeek = false) {
|
|
236
|
+
const values = new Set();
|
|
237
|
+
const normalized = field.trim();
|
|
238
|
+
if (!normalized || normalized === '*' || normalized === '?') {
|
|
239
|
+
for (let i = min; i <= max; i++) {
|
|
240
|
+
values.add(isDayOfWeek && i === 7 ? 0 : i);
|
|
241
|
+
}
|
|
242
|
+
return { values, isWildcard: true };
|
|
243
|
+
}
|
|
244
|
+
const segments = normalized.split(',');
|
|
245
|
+
for (const segment of segments) {
|
|
246
|
+
this.expandCronSegment(segment.trim(), min, max, values, isDayOfWeek);
|
|
247
|
+
}
|
|
248
|
+
if (values.size === (max - min + 1)) {
|
|
249
|
+
return { values, isWildcard: true };
|
|
250
|
+
}
|
|
251
|
+
return { values, isWildcard: false };
|
|
252
|
+
}
|
|
253
|
+
expandCronSegment(segment, min, max, bucket, isDayOfWeek) {
|
|
254
|
+
if (!segment)
|
|
255
|
+
return;
|
|
256
|
+
let step = 1;
|
|
257
|
+
let rangePart = segment;
|
|
258
|
+
if (segment.includes('/')) {
|
|
259
|
+
const [base, stepPart] = segment.split('/');
|
|
260
|
+
rangePart = base;
|
|
261
|
+
step = Math.max(1, parseInt(stepPart, 10) || 1);
|
|
262
|
+
}
|
|
263
|
+
if (rangePart === '*' || rangePart === '?') {
|
|
264
|
+
for (let value = min; value <= max; value += step) {
|
|
265
|
+
this.storeCronValue(value, bucket, isDayOfWeek);
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (rangePart.includes('-')) {
|
|
270
|
+
const [startRaw, endRaw] = rangePart.split('-');
|
|
271
|
+
const start = Math.max(min, parseInt(startRaw, 10));
|
|
272
|
+
const end = Math.min(max, parseInt(endRaw, 10));
|
|
273
|
+
for (let value = start; value <= end; value += step) {
|
|
274
|
+
this.storeCronValue(value, bucket, isDayOfWeek);
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const parsed = parseInt(rangePart, 10);
|
|
279
|
+
if (!Number.isNaN(parsed)) {
|
|
280
|
+
this.storeCronValue(parsed, bucket, isDayOfWeek);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
storeCronValue(value, bucket, isDayOfWeek) {
|
|
284
|
+
if (isDayOfWeek && value === 7) {
|
|
285
|
+
bucket.add(0);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
bucket.add(value);
|
|
289
|
+
}
|
|
290
|
+
getStatus() {
|
|
291
|
+
const jobs = [];
|
|
292
|
+
for (const [id, managed] of this.jobs.entries()) {
|
|
293
|
+
jobs.push({
|
|
294
|
+
id,
|
|
295
|
+
status: managed.job.status,
|
|
296
|
+
nextRunAt: managed.job.metadata.nextRunAt,
|
|
297
|
+
lastRunAt: managed.job.metadata.lastRunAt,
|
|
298
|
+
timerActive: Boolean(managed.timer),
|
|
299
|
+
running: this.runningJobs.has(id)
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
initialized: this.initialized,
|
|
304
|
+
jobCount: this.jobs.size,
|
|
305
|
+
runningCount: this.runningJobs.size,
|
|
306
|
+
jobs
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ScheduledJob, ScheduledJobRun } from '@lovelybunch/types';
|
|
2
|
+
export declare class JobStore {
|
|
3
|
+
private jobsDirPromise;
|
|
4
|
+
constructor();
|
|
5
|
+
private sanitizeForYAML;
|
|
6
|
+
private resolveJobsDir;
|
|
7
|
+
private normalizeSchedule;
|
|
8
|
+
private getJobFilePath;
|
|
9
|
+
listJobs(): Promise<ScheduledJob[]>;
|
|
10
|
+
getJob(id: string): Promise<ScheduledJob | null>;
|
|
11
|
+
saveJob(job: ScheduledJob, bodyContent?: string): Promise<void>;
|
|
12
|
+
deleteJob(id: string): Promise<boolean>;
|
|
13
|
+
appendRun(jobId: string, run: ScheduledJobRun): Promise<ScheduledJob>;
|
|
14
|
+
private fromFrontmatter;
|
|
15
|
+
private toFrontmatter;
|
|
16
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
import { getProjectRoot } from '../project-paths.js';
|
|
6
|
+
function toDate(value) {
|
|
7
|
+
if (!value)
|
|
8
|
+
return undefined;
|
|
9
|
+
const parsed = new Date(value);
|
|
10
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
11
|
+
}
|
|
12
|
+
function prepareDate(value) {
|
|
13
|
+
if (!value)
|
|
14
|
+
return undefined;
|
|
15
|
+
return value.toISOString();
|
|
16
|
+
}
|
|
17
|
+
export class JobStore {
|
|
18
|
+
jobsDirPromise;
|
|
19
|
+
constructor() {
|
|
20
|
+
this.jobsDirPromise = this.resolveJobsDir();
|
|
21
|
+
}
|
|
22
|
+
sanitizeForYAML(value) {
|
|
23
|
+
if (value === undefined)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (value === null)
|
|
26
|
+
return null;
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
const sanitizedArray = value
|
|
29
|
+
.map((item) => this.sanitizeForYAML(item))
|
|
30
|
+
.filter((item) => item !== undefined);
|
|
31
|
+
return sanitizedArray;
|
|
32
|
+
}
|
|
33
|
+
if (value instanceof Date) {
|
|
34
|
+
return value.toISOString();
|
|
35
|
+
}
|
|
36
|
+
if (typeof value === 'object') {
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const [key, val] of Object.entries(value)) {
|
|
39
|
+
const sanitized = this.sanitizeForYAML(val);
|
|
40
|
+
if (sanitized !== undefined) {
|
|
41
|
+
out[key] = sanitized;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
async resolveJobsDir() {
|
|
49
|
+
const projectRoot = await getProjectRoot();
|
|
50
|
+
const dir = path.join(projectRoot, '.nut', 'jobs');
|
|
51
|
+
await fs.mkdir(dir, { recursive: true });
|
|
52
|
+
return dir;
|
|
53
|
+
}
|
|
54
|
+
normalizeSchedule(schedule) {
|
|
55
|
+
if (schedule.type === 'interval') {
|
|
56
|
+
const order = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
|
57
|
+
const uniqueDays = Array.from(new Set(schedule.daysOfWeek))
|
|
58
|
+
.filter((day) => order.includes(day))
|
|
59
|
+
.sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
|
60
|
+
return {
|
|
61
|
+
...schedule,
|
|
62
|
+
daysOfWeek: uniqueDays
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return schedule;
|
|
66
|
+
}
|
|
67
|
+
async getJobFilePath(id) {
|
|
68
|
+
const dir = await this.jobsDirPromise;
|
|
69
|
+
return path.join(dir, `${id}.md`);
|
|
70
|
+
}
|
|
71
|
+
async listJobs() {
|
|
72
|
+
const dir = await this.jobsDirPromise;
|
|
73
|
+
const entries = await fs.readdir(dir).catch(() => []);
|
|
74
|
+
const jobs = [];
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (!entry.endsWith('.md'))
|
|
77
|
+
continue;
|
|
78
|
+
const id = entry.replace(/\.md$/, '');
|
|
79
|
+
const job = await this.getJob(id);
|
|
80
|
+
if (job)
|
|
81
|
+
jobs.push(job);
|
|
82
|
+
}
|
|
83
|
+
return jobs.sort((a, b) => {
|
|
84
|
+
const aTime = a.metadata.updatedAt?.getTime?.() ?? 0;
|
|
85
|
+
const bTime = b.metadata.updatedAt?.getTime?.() ?? 0;
|
|
86
|
+
return bTime - aTime;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async getJob(id) {
|
|
90
|
+
try {
|
|
91
|
+
const filePath = await this.getJobFilePath(id);
|
|
92
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
93
|
+
const { data, content: body } = matter(content);
|
|
94
|
+
return this.fromFrontmatter(data, body);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error?.code === 'ENOENT')
|
|
98
|
+
return null;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async saveJob(job, bodyContent = '') {
|
|
103
|
+
const filePath = await this.getJobFilePath(job.id);
|
|
104
|
+
const normalizedJob = {
|
|
105
|
+
...job,
|
|
106
|
+
schedule: this.normalizeSchedule(job.schedule)
|
|
107
|
+
};
|
|
108
|
+
const payload = this.sanitizeForYAML(this.toFrontmatter(normalizedJob));
|
|
109
|
+
const markdown = matter.stringify(bodyContent || job.prompt || '', payload);
|
|
110
|
+
await fs.writeFile(filePath, `${markdown.trim()}\n`, 'utf-8');
|
|
111
|
+
}
|
|
112
|
+
async deleteJob(id) {
|
|
113
|
+
try {
|
|
114
|
+
const filePath = await this.getJobFilePath(id);
|
|
115
|
+
await fs.unlink(filePath);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error?.code === 'ENOENT')
|
|
120
|
+
return false;
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async appendRun(jobId, run) {
|
|
125
|
+
const job = await this.getJob(jobId);
|
|
126
|
+
if (!job) {
|
|
127
|
+
throw new Error(`Job ${jobId} not found`);
|
|
128
|
+
}
|
|
129
|
+
const updatedRuns = [run, ...job.runs];
|
|
130
|
+
const trimmedRuns = updatedRuns.slice(0, 50);
|
|
131
|
+
job.runs = trimmedRuns;
|
|
132
|
+
job.metadata.updatedAt = new Date();
|
|
133
|
+
job.metadata.lastRunAt = run.startedAt;
|
|
134
|
+
await this.saveJob(job);
|
|
135
|
+
return job;
|
|
136
|
+
}
|
|
137
|
+
fromFrontmatter(data, body) {
|
|
138
|
+
if (!data?.id) {
|
|
139
|
+
throw new Error('Scheduled job is missing required id field');
|
|
140
|
+
}
|
|
141
|
+
const createdAt = toDate(data.metadata?.createdAt) ?? new Date();
|
|
142
|
+
const updatedAt = toDate(data.metadata?.updatedAt) ?? createdAt;
|
|
143
|
+
const runs = Array.isArray(data.runs)
|
|
144
|
+
? data.runs.map((run) => ({
|
|
145
|
+
id: run.id ?? `run-${randomUUID()}`,
|
|
146
|
+
jobId: data.id,
|
|
147
|
+
trigger: run.trigger,
|
|
148
|
+
status: run.status,
|
|
149
|
+
startedAt: toDate(run.startedAt) ?? new Date(),
|
|
150
|
+
finishedAt: toDate(run.finishedAt),
|
|
151
|
+
outputPath: run.outputPath,
|
|
152
|
+
summary: run.summary,
|
|
153
|
+
error: run.error,
|
|
154
|
+
cliCommand: run.cliCommand,
|
|
155
|
+
}))
|
|
156
|
+
: [];
|
|
157
|
+
return {
|
|
158
|
+
id: data.id,
|
|
159
|
+
name: data.name || data.id,
|
|
160
|
+
description: data.description,
|
|
161
|
+
prompt: data.prompt || body.trim(),
|
|
162
|
+
model: data.model || 'anthropic/claude-sonnet-4',
|
|
163
|
+
status: data.status || 'paused',
|
|
164
|
+
schedule: data.schedule ?? {
|
|
165
|
+
type: 'interval',
|
|
166
|
+
hours: 6,
|
|
167
|
+
daysOfWeek: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']
|
|
168
|
+
},
|
|
169
|
+
metadata: {
|
|
170
|
+
createdAt,
|
|
171
|
+
updatedAt,
|
|
172
|
+
lastRunAt: toDate(data.metadata?.lastRunAt),
|
|
173
|
+
nextRunAt: toDate(data.metadata?.nextRunAt),
|
|
174
|
+
},
|
|
175
|
+
runs,
|
|
176
|
+
tags: data.tags ?? [],
|
|
177
|
+
contextPaths: data.contextPaths ?? [],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
toFrontmatter(job) {
|
|
181
|
+
return {
|
|
182
|
+
id: job.id,
|
|
183
|
+
name: job.name,
|
|
184
|
+
description: job.description,
|
|
185
|
+
prompt: job.prompt,
|
|
186
|
+
model: job.model,
|
|
187
|
+
status: job.status,
|
|
188
|
+
schedule: job.schedule,
|
|
189
|
+
metadata: {
|
|
190
|
+
createdAt: prepareDate(job.metadata.createdAt),
|
|
191
|
+
updatedAt: prepareDate(job.metadata.updatedAt),
|
|
192
|
+
lastRunAt: prepareDate(job.metadata.lastRunAt),
|
|
193
|
+
nextRunAt: prepareDate(job.metadata.nextRunAt),
|
|
194
|
+
},
|
|
195
|
+
runs: job.runs.map((run) => ({
|
|
196
|
+
id: run.id,
|
|
197
|
+
jobId: run.jobId,
|
|
198
|
+
trigger: run.trigger,
|
|
199
|
+
status: run.status,
|
|
200
|
+
startedAt: prepareDate(run.startedAt),
|
|
201
|
+
finishedAt: prepareDate(run.finishedAt),
|
|
202
|
+
outputPath: run.outputPath,
|
|
203
|
+
summary: run.summary,
|
|
204
|
+
error: run.error,
|
|
205
|
+
cliCommand: run.cliCommand,
|
|
206
|
+
})),
|
|
207
|
+
tags: job.tags ?? [],
|
|
208
|
+
contextPaths: job.contextPaths ?? [],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -49,7 +49,7 @@ export class FileStorageAdapter {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
async ensureDirectories() {
|
|
52
|
-
const dirs = ['proposals', 'specs', 'flags', 'experiments', 'templates'];
|
|
52
|
+
const dirs = ['proposals', 'specs', 'flags', 'experiments', 'templates', 'jobs'];
|
|
53
53
|
for (const dir of dirs) {
|
|
54
54
|
await fs.mkdir(path.join(this.basePath, dir), { recursive: true });
|
|
55
55
|
}
|
|
@@ -119,19 +119,21 @@ export class FileStorageAdapter {
|
|
|
119
119
|
const existing = await this.getCP(id);
|
|
120
120
|
if (!existing)
|
|
121
121
|
throw new Error(`CP ${id} not found`);
|
|
122
|
+
// Never allow id to be updated to prevent overwriting other proposals
|
|
123
|
+
const { id: _, ...safeUpdates } = updates;
|
|
122
124
|
// Handle comments separately as they're stored at root level in file format
|
|
123
125
|
let updated = {
|
|
124
126
|
...existing,
|
|
125
|
-
...
|
|
127
|
+
...safeUpdates,
|
|
126
128
|
metadata: {
|
|
127
129
|
...existing.metadata,
|
|
128
|
-
...
|
|
130
|
+
...safeUpdates.metadata,
|
|
129
131
|
updatedAt: new Date()
|
|
130
132
|
}
|
|
131
133
|
};
|
|
132
134
|
// If comments are being updated, store them at the root level for the file format
|
|
133
|
-
if (
|
|
134
|
-
updated.comments =
|
|
135
|
+
if (safeUpdates.comments) {
|
|
136
|
+
updated.comments = safeUpdates.comments;
|
|
135
137
|
}
|
|
136
138
|
await this.createCP(updated);
|
|
137
139
|
}
|
|
@@ -13,6 +13,8 @@ export interface TerminalSession {
|
|
|
13
13
|
previewSockets?: Set<WebSocket>;
|
|
14
14
|
previewBuffer?: string;
|
|
15
15
|
previewFlushTimer?: NodeJS.Timeout | null;
|
|
16
|
+
pingInterval?: NodeJS.Timeout;
|
|
17
|
+
previewPingIntervals?: Map<WebSocket, NodeJS.Timeout>;
|
|
16
18
|
}
|
|
17
19
|
export declare class TerminalManager {
|
|
18
20
|
private sessions;
|