bopodev-api 0.1.30 → 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.
@@ -1,5 +1,5 @@
1
1
  import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
- import { join, relative, resolve } from "node:path";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
3
  import type { AgentMemoryContext } from "bopodev-agent-sdk";
4
4
  import {
5
5
  isInsidePath,
@@ -227,6 +227,40 @@ export async function readAgentMemoryFile(input: {
227
227
  };
228
228
  }
229
229
 
230
+ export async function writeAgentMemoryFile(input: {
231
+ companyId: string;
232
+ agentId: string;
233
+ relativePath: string;
234
+ content: string;
235
+ }) {
236
+ const root = resolveAgentMemoryRootPath(input.companyId, input.agentId);
237
+ await mkdir(root, { recursive: true });
238
+ const normalizedRel = input.relativePath.trim();
239
+ if (!normalizedRel || normalizedRel.includes("..")) {
240
+ throw new Error("Invalid relative path.");
241
+ }
242
+ const candidate = resolve(root, normalizedRel);
243
+ if (!isInsidePath(root, candidate)) {
244
+ throw new Error("Requested memory path is outside of memory root.");
245
+ }
246
+ const bytes = Buffer.byteLength(input.content, "utf8");
247
+ if (bytes > MAX_OBSERVABILITY_FILE_BYTES) {
248
+ throw new Error("Content exceeds size limit.");
249
+ }
250
+ const parent = dirname(candidate);
251
+ if (!isInsidePath(root, parent)) {
252
+ throw new Error("Invalid parent directory.");
253
+ }
254
+ await mkdir(parent, { recursive: true });
255
+ await writeFile(candidate, input.content, { encoding: "utf8" });
256
+ const info = await stat(candidate);
257
+ return {
258
+ path: candidate,
259
+ relativePath: relative(root, candidate),
260
+ sizeBytes: info.size
261
+ };
262
+ }
263
+
230
264
  function collapseWhitespace(value: string) {
231
265
  return value.replace(/\s+/g, " ").trim();
232
266
  }
@@ -9,6 +9,7 @@ import {
9
9
  updatePluginConfig
10
10
  } from "bopodev-db";
11
11
  import { interpolateTemplateManifest, buildTemplatePreview } from "./template-preview-service";
12
+ import { addWorkLoopTrigger, createWorkLoop } from "./work-loop-service";
12
13
 
13
14
  export class TemplateApplyError extends Error {
14
15
  constructor(message: string) {
@@ -123,6 +124,38 @@ export async function applyTemplateManifest(
123
124
  });
124
125
  }
125
126
 
127
+ const firstProjectId =
128
+ renderedManifest.projects.length > 0
129
+ ? projectIdByKey.get(renderedManifest.projects[0]!.key) ?? null
130
+ : Array.from(projectIdByKey.values())[0] ?? null;
131
+ for (const job of renderedManifest.recurrence) {
132
+ if (job.targetType !== "agent") {
133
+ continue;
134
+ }
135
+ const assigneeAgentId = agentIdByKey.get(job.targetKey) ?? null;
136
+ if (!assigneeAgentId || !firstProjectId) {
137
+ continue;
138
+ }
139
+ const title =
140
+ job.instruction?.trim() && job.instruction.trim().length > 0
141
+ ? job.instruction.trim()
142
+ : `Recurring work: ${job.targetKey}`;
143
+ const loop = await createWorkLoop(db, {
144
+ companyId: input.companyId,
145
+ projectId: firstProjectId,
146
+ title,
147
+ description: job.instruction?.trim() || null,
148
+ assigneeAgentId
149
+ });
150
+ if (loop) {
151
+ await addWorkLoopTrigger(db, {
152
+ companyId: input.companyId,
153
+ workLoopId: loop.id,
154
+ cronExpression: job.cron
155
+ });
156
+ }
157
+ }
158
+
126
159
  const install = await createTemplateInstall(db, {
127
160
  companyId: input.companyId,
128
161
  templateId: input.templateId,
@@ -0,0 +1,2 @@
1
+ export * from "./loop-cron";
2
+ export * from "./work-loop-service";
@@ -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
+ }