loopengine 0.1.92 → 0.1.94
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/README.md +14 -5
- package/dist/core/agent-config.d.ts +19 -7
- package/dist/core/email-senders.d.ts +26 -0
- package/dist/core/email-senders.js +108 -0
- package/dist/core/http-notifier.js +6 -6
- package/dist/core/http-notify-triggers/email.d.ts +14 -8
- package/dist/core/http-notify-triggers/email.js +70 -28
- package/dist/core/notifier-file.d.ts +11 -3
- package/dist/core/notifier-file.js +37 -5
- package/dist/core/scheduler.d.ts +22 -0
- package/dist/core/scheduler.js +111 -12
- package/dist/core/system-agents/templates.d.ts +43 -0
- package/dist/core/system-agents/templates.js +290 -48
- package/dist/core/system-tools/schedule.js +16 -6
- package/dist/core/time-zone.d.ts +40 -0
- package/dist/core/time-zone.js +138 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.js +5 -4
- package/dist/web/agents-config-page.js +49 -11
- package/dist/web/global-config-page.js +65 -0
- package/dist/web/global-config.d.ts +24 -0
- package/dist/web/global-config.js +30 -0
- package/dist/web/notifier-admin.js +11 -2
- package/dist/web/templates-admin.d.ts +82 -7
- package/dist/web/templates-admin.js +207 -16
- package/dist/web/templates-page.js +31 -6
- package/package.json +5 -1
package/dist/core/scheduler.js
CHANGED
|
@@ -10,13 +10,21 @@
|
|
|
10
10
|
// next tick.
|
|
11
11
|
//
|
|
12
12
|
// `every` is anchored: an hourly task created at 10:07 keeps firing at
|
|
13
|
-
// :07, however long each run takes.
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// :07, however long each run takes. `start_at` sets the anchor instead,
|
|
14
|
+
// for "daily at 9:00" ("1d" from a 9:00 start) or "Mondays at 9:00" ("7d"
|
|
15
|
+
// from a Monday), or `at` ("09:00", plus a `weekday` for "7d") works the
|
|
16
|
+
// start out in the workspace's time zone (core/time-zone.ts). Intervals
|
|
17
|
+
// in days are calendar days in the time zone the task was made in, so a
|
|
18
|
+
// daily 9:00 task stays at 9:00 when the clocks change; shorter ones are
|
|
19
|
+
// exact durations. Timestamps without an offset are read in the
|
|
20
|
+
// workspace's time zone too. A run missed while the server was down fires
|
|
21
|
+
// once when it's back, then the schedule moves to the next future slot
|
|
22
|
+
// (no burst of catch-up runs).
|
|
16
23
|
import { randomUUID } from 'node:crypto';
|
|
17
24
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
25
|
import path from 'node:path';
|
|
19
26
|
import { KeyedMutex } from './session-store.js';
|
|
27
|
+
import { addCalendarDays, instantOf, nextTimeOfDay, parseTimeOfDay, parseTimestamp, wallTimeAt, workspaceTimeZone } from './time-zone.js';
|
|
20
28
|
const HISTORY_LIMIT = 20;
|
|
21
29
|
const EVERY_PATTERN = /^(\d+)\s*(s|m|h|d)$/;
|
|
22
30
|
const EVERY_UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
@@ -33,11 +41,38 @@ export function parseEvery(every) {
|
|
|
33
41
|
throw new ScheduleError(`"${every}" is too short — the shortest interval is 30s`);
|
|
34
42
|
return ms;
|
|
35
43
|
}
|
|
36
|
-
/**
|
|
37
|
-
function
|
|
44
|
+
/** Whole days in an interval ("2d" → 2), or undefined for one in s/m/h. */
|
|
45
|
+
function everyDays(every) {
|
|
46
|
+
const match = EVERY_PATTERN.exec(every.trim());
|
|
47
|
+
return match && match[2] === 'd' ? Number(match[1]) : undefined;
|
|
48
|
+
}
|
|
49
|
+
/** The first anchored slot strictly after `nowMs`: exact steps of
|
|
50
|
+
* `intervalMs`, or with `days` and `timeZone`, steps of that many
|
|
51
|
+
* calendar days there. */
|
|
52
|
+
function nextAnchoredSlot(anchorMs, intervalMs, nowMs, calendar) {
|
|
38
53
|
if (nowMs < anchorMs)
|
|
39
54
|
return anchorMs;
|
|
40
|
-
|
|
55
|
+
let k = Math.floor((nowMs - anchorMs) / intervalMs) + 1;
|
|
56
|
+
if (!calendar)
|
|
57
|
+
return anchorMs + k * intervalMs;
|
|
58
|
+
// A day is 23 to 25 hours, so the estimate can be one step off.
|
|
59
|
+
const slot = (n) => addCalendarDays(anchorMs, n * calendar.days, calendar.timeZone);
|
|
60
|
+
while (k > 1 && slot(k - 1) > nowMs)
|
|
61
|
+
k--;
|
|
62
|
+
while (slot(k) <= nowMs)
|
|
63
|
+
k++;
|
|
64
|
+
return slot(k);
|
|
65
|
+
}
|
|
66
|
+
const WEEKDAY_NAMES = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
67
|
+
function parseWeekday(value) {
|
|
68
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 6)
|
|
69
|
+
return value;
|
|
70
|
+
if (typeof value === 'string') {
|
|
71
|
+
const i = WEEKDAY_NAMES.findIndex((d) => d.startsWith(value.trim().toLowerCase()) && value.trim().length >= 3);
|
|
72
|
+
if (i >= 0)
|
|
73
|
+
return i;
|
|
74
|
+
}
|
|
75
|
+
throw new ScheduleError(`weekday "${String(value)}" isn't a day: use monday to sunday, or 0 (Sunday) to 6`);
|
|
41
76
|
}
|
|
42
77
|
/** Human-readable schedule, for lists and messages. */
|
|
43
78
|
export function describeSchedule(schedule) {
|
|
@@ -110,20 +145,53 @@ export async function createScheduledTask(input, nowMs = Date.now()) {
|
|
|
110
145
|
throw new ScheduleError('message is required');
|
|
111
146
|
let schedule;
|
|
112
147
|
let nextRunMs;
|
|
148
|
+
const timeZone = workspaceTimeZone();
|
|
113
149
|
if (typeof input.schedule?.run_at === 'string' && input.schedule.run_at) {
|
|
114
|
-
const runAtMs =
|
|
150
|
+
const runAtMs = parseTimestamp(input.schedule.run_at, timeZone);
|
|
115
151
|
if (Number.isNaN(runAtMs))
|
|
116
152
|
throw new ScheduleError(`"${input.schedule.run_at}" is not a valid ISO 8601 timestamp`);
|
|
117
153
|
schedule = { run_at: new Date(runAtMs).toISOString() };
|
|
118
154
|
nextRunMs = runAtMs;
|
|
119
155
|
}
|
|
120
156
|
else if (typeof input.schedule?.every === 'string' && input.schedule.every) {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
157
|
+
const every = input.schedule.every.trim();
|
|
158
|
+
const intervalMs = parseEvery(every);
|
|
159
|
+
const days = everyDays(every);
|
|
160
|
+
const calendar = days ? { days, timeZone } : undefined;
|
|
161
|
+
const { start_at: startAt, at, weekday } = input.schedule;
|
|
162
|
+
const given = (v) => v !== undefined && v !== null && v !== '';
|
|
163
|
+
let startMs;
|
|
164
|
+
if (given(at)) {
|
|
165
|
+
if (given(startAt))
|
|
166
|
+
throw new ScheduleError('give either start_at or at, not both');
|
|
167
|
+
const time = typeof at === 'string' ? parseTimeOfDay(at) : undefined;
|
|
168
|
+
if (!time)
|
|
169
|
+
throw new ScheduleError(`at "${String(at)}" isn't a time of day: use 24-hour "HH:MM", e.g. "09:00"`);
|
|
170
|
+
startMs = nextTimeOfDay(time, nowMs, timeZone, given(weekday) ? parseWeekday(weekday) : undefined);
|
|
171
|
+
}
|
|
172
|
+
else if (given(weekday)) {
|
|
173
|
+
throw new ScheduleError('weekday goes with at, e.g. { "every": "7d", "at": "09:00", "weekday": "monday" }');
|
|
174
|
+
}
|
|
175
|
+
else if (given(startAt)) {
|
|
176
|
+
startMs = typeof startAt === 'string' ? parseTimestamp(startAt, timeZone) : Number.NaN;
|
|
177
|
+
if (Number.isNaN(startMs))
|
|
178
|
+
throw new ScheduleError(`start_at "${String(startAt)}" is not a valid ISO 8601 timestamp`);
|
|
179
|
+
}
|
|
180
|
+
if (startMs !== undefined) {
|
|
181
|
+
// The anchor is the start itself, so every run lands on its time of
|
|
182
|
+
// day (or weekday, for "7d").
|
|
183
|
+
nextRunMs = nextAnchoredSlot(startMs, intervalMs, nowMs, calendar);
|
|
184
|
+
schedule = { every, anchor: new Date(startMs).toISOString(), ...(calendar ? { time_zone: timeZone } : {}) };
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
nextRunMs = calendar ? addCalendarDays(nowMs, calendar.days, timeZone) : nowMs + intervalMs;
|
|
188
|
+
schedule = { every, anchor: new Date(nextRunMs).toISOString(), ...(calendar ? { time_zone: timeZone } : {}) };
|
|
189
|
+
}
|
|
124
190
|
}
|
|
125
191
|
else {
|
|
126
|
-
|
|
192
|
+
if (input.schedule?.start_at || input.schedule?.at)
|
|
193
|
+
throw new ScheduleError('start_at and at go with "every" (a repeating task); a one-off task uses run_at');
|
|
194
|
+
throw new ScheduleError('schedule must be { "run_at": "<ISO timestamp>" } or { "every": "<interval, e.g. 1h>", "start_at"?: "<ISO timestamp>" } or { "every": "1d", "at": "09:00" }');
|
|
127
195
|
}
|
|
128
196
|
const task = {
|
|
129
197
|
task_id: randomUUID(),
|
|
@@ -179,6 +247,36 @@ export async function runScheduledTaskNow(taskId, nowMs = Date.now()) {
|
|
|
179
247
|
export async function deleteScheduledTask(taskId) {
|
|
180
248
|
await getSchedulerStore().remove(taskId);
|
|
181
249
|
}
|
|
250
|
+
/** Moves every daily and weekly (whole-day) task to `toTimeZone`,
|
|
251
|
+
* keeping its time of day: a 9:00 task made while the workspace was on
|
|
252
|
+
* UTC runs at 9:00 in the new zone. A task from before tasks kept their
|
|
253
|
+
* zone is taken to be in `fromTimeZone`. One-off and shorter repeating
|
|
254
|
+
* tasks are left alone: they're a moment, not a time of day. Returns
|
|
255
|
+
* how many moved. */
|
|
256
|
+
export async function moveScheduledTasksToTimeZone(fromTimeZone, toTimeZone, nowMs = Date.now()) {
|
|
257
|
+
let moved = 0;
|
|
258
|
+
for (const listed of await getSchedulerStore().list()) {
|
|
259
|
+
if (listed.status === 'done' || listed.status === 'cancelled' || !('every' in listed.schedule) || !everyDays(listed.schedule.every))
|
|
260
|
+
continue;
|
|
261
|
+
await getSchedulerStore().update(listed.task_id, (t) => {
|
|
262
|
+
if (!t || !('every' in t.schedule) || !t.schedule.anchor)
|
|
263
|
+
return t;
|
|
264
|
+
const days = everyDays(t.schedule.every);
|
|
265
|
+
const from = t.schedule.time_zone ?? fromTimeZone;
|
|
266
|
+
if (!days || from === toTimeZone)
|
|
267
|
+
return t;
|
|
268
|
+
const anchorMs = instantOf(wallTimeAt(Date.parse(t.schedule.anchor), from), toTimeZone);
|
|
269
|
+
const schedule = { ...t.schedule, anchor: new Date(anchorMs).toISOString(), time_zone: toTimeZone };
|
|
270
|
+
moved++;
|
|
271
|
+
// A run that's due or under way keeps its slot; the next one moves.
|
|
272
|
+
if (t.status !== 'scheduled' || !t.next_run_at || Date.parse(t.next_run_at) <= nowMs)
|
|
273
|
+
return { ...t, schedule };
|
|
274
|
+
const next = nextAnchoredSlot(anchorMs, parseEvery(t.schedule.every), nowMs, { days, timeZone: toTimeZone });
|
|
275
|
+
return { ...t, schedule, next_run_at: new Date(next).toISOString() };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return moved;
|
|
279
|
+
}
|
|
182
280
|
/** When the next task is due — for a host that stops idle servers and
|
|
183
281
|
* needs to wake this one in time (GET /internal/activity). */
|
|
184
282
|
export async function nextScheduledWakeAt() {
|
|
@@ -190,7 +288,8 @@ function nextRunAfter(task, nowMs) {
|
|
|
190
288
|
return undefined;
|
|
191
289
|
const intervalMs = parseEvery(task.schedule.every);
|
|
192
290
|
const anchorMs = task.schedule.anchor ? Date.parse(task.schedule.anchor) : Date.parse(task.created_at) + intervalMs;
|
|
193
|
-
|
|
291
|
+
const days = everyDays(task.schedule.every);
|
|
292
|
+
return nextAnchoredSlot(anchorMs, intervalMs, nowMs, days && task.schedule.time_zone ? { days, timeZone: task.schedule.time_zone } : undefined);
|
|
194
293
|
}
|
|
195
294
|
/** Moves the old lp-task-scheduler's task files (`<dir>/.tasks/*.json`)
|
|
196
295
|
* into this store once, renaming each to `.json.migrated`. Returns how
|
|
@@ -1,10 +1,53 @@
|
|
|
1
1
|
import type { AgentCandidate } from './interviewer.js';
|
|
2
|
+
export interface TemplateRule {
|
|
3
|
+
name: string;
|
|
4
|
+
tool: string;
|
|
5
|
+
decision: 'allow' | 'ask' | 'deny';
|
|
6
|
+
}
|
|
7
|
+
export interface TemplateSkill {
|
|
8
|
+
/** Folder name under agents/<name>/skills/: lowercase-hyphenated. */
|
|
9
|
+
id: string;
|
|
10
|
+
description: string;
|
|
11
|
+
body: string;
|
|
12
|
+
}
|
|
13
|
+
export interface TemplateSchedule {
|
|
14
|
+
/** How often, in the scheduler's terms: "10m", "1h", "7d". */
|
|
15
|
+
every: string;
|
|
16
|
+
/** Optional first run, as a time of day in the workspace's time zone
|
|
17
|
+
* ("09:00"), and for weekly schedules a weekday (1 = Monday). Without
|
|
18
|
+
* it, the first run is one interval after the schedule is turned on. */
|
|
19
|
+
at?: string;
|
|
20
|
+
weekday?: number;
|
|
21
|
+
name: string;
|
|
22
|
+
/** What the agent receives on each run. */
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
export interface TemplateSetup {
|
|
26
|
+
/** npm names of the abilities to install. */
|
|
27
|
+
abilities: string[];
|
|
28
|
+
/** Keys the agent needs before it can work, from its abilities' declared
|
|
29
|
+
* settings: a name, or a list where any one of them will do. */
|
|
30
|
+
keys: (string | string[])[];
|
|
31
|
+
/** Non-secret settings filled in for this agent. */
|
|
32
|
+
env?: Record<string, string>;
|
|
33
|
+
skills?: TemplateSkill[];
|
|
34
|
+
rules: TemplateRule[];
|
|
35
|
+
schedule?: TemplateSchedule;
|
|
36
|
+
/** Whether a person must be reachable for approvals (the Messaging tab). */
|
|
37
|
+
needsApprovals: boolean;
|
|
38
|
+
/** Extra setup steps, in plain words, for the checklist. */
|
|
39
|
+
notes?: string[];
|
|
40
|
+
}
|
|
2
41
|
export interface StarterTemplate {
|
|
3
42
|
/** Stable id, used in URLs: lowercase-hyphenated. */
|
|
4
43
|
id: string;
|
|
5
44
|
/** Groups templates on the page: "Customer service", "Operations"… */
|
|
6
45
|
category: string;
|
|
7
46
|
candidate: AgentCandidate;
|
|
47
|
+
setup: TemplateSetup;
|
|
8
48
|
}
|
|
9
49
|
export declare const STARTER_TEMPLATES: readonly StarterTemplate[];
|
|
10
50
|
export declare function starterTemplate(id: string): StarterTemplate | undefined;
|
|
51
|
+
/** The first run of a template's schedule: its time of day (and weekday)
|
|
52
|
+
* next, in the workspace's time zone; undefined when it has none. */
|
|
53
|
+
export declare function templateScheduleStart(schedule: TemplateSchedule, nowMs?: number, timeZone?: string): string | undefined;
|