bopodev-api 0.1.29 → 0.1.31
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/package.json +4 -4
- package/src/app.ts +2 -0
- package/src/lib/ceo-bootstrap-prompt.ts +1 -0
- package/src/lib/instance-paths.ts +5 -0
- package/src/middleware/cors-config.ts +1 -1
- package/src/realtime/office-space.ts +1 -0
- package/src/routes/agents.ts +87 -37
- package/src/routes/companies.ts +2 -0
- package/src/routes/issues.ts +3 -0
- package/src/routes/loops.ts +360 -0
- package/src/routes/observability.ts +123 -1
- package/src/scripts/onboard-seed.ts +13 -1
- package/src/services/agent-operating-file-service.ts +116 -0
- package/src/services/governance-service.ts +6 -13
- package/src/services/heartbeat-service/heartbeat-run.ts +25 -3
- package/src/services/heartbeat-service/types.ts +1 -0
- package/src/services/memory-file-service.ts +35 -1
- package/src/services/template-apply-service.ts +39 -0
- package/src/services/template-catalog.ts +37 -3
- package/src/services/work-loop-service/index.ts +2 -0
- package/src/services/work-loop-service/loop-cron.ts +197 -0
- package/src/services/work-loop-service/work-loop-service.ts +665 -0
- package/src/validation/issue-routes.ts +2 -1
- package/src/worker/scheduler.ts +26 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
const WEEKDAY_INDEX: Record<string, number> = {
|
|
2
|
+
Sun: 0,
|
|
3
|
+
Mon: 1,
|
|
4
|
+
Tue: 2,
|
|
5
|
+
Wed: 3,
|
|
6
|
+
Thu: 4,
|
|
7
|
+
Fri: 5,
|
|
8
|
+
Sat: 6
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function assertValidTimeZone(timeZone: string) {
|
|
12
|
+
try {
|
|
13
|
+
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date());
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error(`Invalid timezone: ${timeZone}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function floorToUtcMinute(date: Date) {
|
|
20
|
+
const d = new Date(date.getTime());
|
|
21
|
+
d.setUTCSeconds(0, 0);
|
|
22
|
+
return d;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getZonedCalendarParts(date: Date, timeZone: string) {
|
|
26
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
27
|
+
timeZone,
|
|
28
|
+
hour12: false,
|
|
29
|
+
year: "numeric",
|
|
30
|
+
month: "numeric",
|
|
31
|
+
day: "numeric",
|
|
32
|
+
hour: "numeric",
|
|
33
|
+
minute: "numeric",
|
|
34
|
+
weekday: "short"
|
|
35
|
+
});
|
|
36
|
+
const parts = formatter.formatToParts(date);
|
|
37
|
+
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
|
38
|
+
const weekday = WEEKDAY_INDEX[map.weekday ?? ""];
|
|
39
|
+
if (weekday == null) {
|
|
40
|
+
throw new Error(`Unable to resolve weekday for timezone ${timeZone}`);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
year: Number(map.year),
|
|
44
|
+
month: Number(map.month),
|
|
45
|
+
day: Number(map.day),
|
|
46
|
+
hour: Number(map.hour),
|
|
47
|
+
minute: Number(map.minute),
|
|
48
|
+
weekday
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function matchesCronField(field: string, value: number, min: number, max: number) {
|
|
53
|
+
return field.split(",").some((part) => matchesCronPart(part.trim(), value, min, max));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function matchesCronPart(part: string, value: number, min: number, max: number): boolean {
|
|
57
|
+
if (part === "*") {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
const stepMatch = part.match(/^\*\/(\d+)$/);
|
|
61
|
+
if (stepMatch) {
|
|
62
|
+
const step = Number(stepMatch[1]);
|
|
63
|
+
return Number.isInteger(step) && step > 0 ? (value - min) % step === 0 : false;
|
|
64
|
+
}
|
|
65
|
+
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
|
66
|
+
if (rangeMatch) {
|
|
67
|
+
const start = Number(rangeMatch[1]);
|
|
68
|
+
const end = Number(rangeMatch[2]);
|
|
69
|
+
return start <= value && value <= end;
|
|
70
|
+
}
|
|
71
|
+
const exact = Number(part);
|
|
72
|
+
return Number.isInteger(exact) && exact >= min && exact <= max && exact === value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type ParsedCron = {
|
|
76
|
+
minutes: number[];
|
|
77
|
+
hours: number[];
|
|
78
|
+
daysOfMonth: number[];
|
|
79
|
+
months: number[];
|
|
80
|
+
daysOfWeek: number[];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function expandField(field: string, min: number, max: number): number[] {
|
|
84
|
+
const out = new Set<number>();
|
|
85
|
+
for (const part of field.split(",")) {
|
|
86
|
+
const p = part.trim();
|
|
87
|
+
if (p === "*") {
|
|
88
|
+
for (let v = min; v <= max; v += 1) {
|
|
89
|
+
out.add(v);
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const stepMatch = p.match(/^\*\/(\d+)$/);
|
|
94
|
+
if (stepMatch) {
|
|
95
|
+
const step = Number(stepMatch[1]);
|
|
96
|
+
if (Number.isInteger(step) && step > 0) {
|
|
97
|
+
for (let v = min; v <= max; v += 1) {
|
|
98
|
+
if ((v - min) % step === 0) {
|
|
99
|
+
out.add(v);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const rangeMatch = p.match(/^(\d+)-(\d+)$/);
|
|
106
|
+
if (rangeMatch) {
|
|
107
|
+
const start = Number(rangeMatch[1]);
|
|
108
|
+
const end = Number(rangeMatch[2]);
|
|
109
|
+
for (let v = start; v <= end; v += 1) {
|
|
110
|
+
if (v >= min && v <= max) {
|
|
111
|
+
out.add(v);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const exact = Number(p);
|
|
117
|
+
if (Number.isInteger(exact) && exact >= min && exact <= max) {
|
|
118
|
+
out.add(exact);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return [...out].sort((a, b) => a - b);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function parseCronExpression(expression: string): ParsedCron | null {
|
|
125
|
+
const parts = expression.trim().split(/\s+/);
|
|
126
|
+
if (parts.length !== 5) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [string, string, string, string, string];
|
|
130
|
+
return {
|
|
131
|
+
minutes: expandField(minute, 0, 59),
|
|
132
|
+
hours: expandField(hour, 0, 23),
|
|
133
|
+
daysOfMonth: expandField(dayOfMonth, 1, 31),
|
|
134
|
+
months: expandField(month, 1, 12),
|
|
135
|
+
daysOfWeek: expandField(dayOfWeek, 0, 6)
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function matchesCronInTimeZone(expression: string, timeZone: string, date: Date) {
|
|
140
|
+
const cron = parseCronExpression(expression);
|
|
141
|
+
if (!cron) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const z = getZonedCalendarParts(date, timeZone);
|
|
145
|
+
return (
|
|
146
|
+
cron.minutes.includes(z.minute) &&
|
|
147
|
+
cron.hours.includes(z.hour) &&
|
|
148
|
+
cron.daysOfMonth.includes(z.day) &&
|
|
149
|
+
cron.months.includes(z.month) &&
|
|
150
|
+
cron.daysOfWeek.includes(z.weekday)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** First minute strictly after `after` (UTC floored) where the cron matches in `timeZone`. */
|
|
155
|
+
export function nextCronFireAfter(expression: string, timeZone: string, after: Date): Date | null {
|
|
156
|
+
const trimmed = expression.trim();
|
|
157
|
+
assertValidTimeZone(timeZone);
|
|
158
|
+
if (!parseCronExpression(trimmed)) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const cursor = new Date(floorToUtcMinute(after).getTime() + 60_000);
|
|
162
|
+
const limit = 366 * 24 * 60 + 10;
|
|
163
|
+
for (let i = 0; i < limit; i += 1) {
|
|
164
|
+
if (matchesCronInTimeZone(trimmed, timeZone, cursor)) {
|
|
165
|
+
return new Date(cursor.getTime());
|
|
166
|
+
}
|
|
167
|
+
cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function validateCronExpression(expression: string): string | null {
|
|
173
|
+
const trimmed = expression.trim();
|
|
174
|
+
if (!parseCronExpression(trimmed)) {
|
|
175
|
+
return "Cron must have five space-separated fields (minute hour day-of-month month day-of-week).";
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Build cron for "every day at HH:MM" in the given timezone (wall-clock). */
|
|
181
|
+
export function dailyCronAtLocalTime(hour24: number, minute: number) {
|
|
182
|
+
if (!Number.isInteger(hour24) || hour24 < 0 || hour24 > 23) {
|
|
183
|
+
throw new Error("hour must be 0–23");
|
|
184
|
+
}
|
|
185
|
+
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
|
186
|
+
throw new Error("minute must be 0–59");
|
|
187
|
+
}
|
|
188
|
+
return `${minute} ${hour24} * * *`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Build cron for "weekly on weekday (0=Sun..6=Sat) at HH:MM". */
|
|
192
|
+
export function weeklyCronAtLocalTime(dayOfWeek: number, hour24: number, minute: number) {
|
|
193
|
+
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) {
|
|
194
|
+
throw new Error("dayOfWeek must be 0–6 (Sun–Sat)");
|
|
195
|
+
}
|
|
196
|
+
return `${minute} ${hour24} * * ${dayOfWeek}`;
|
|
197
|
+
}
|