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.
@@ -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. A run missed while the server was
14
- // down fires once when it's back, then the schedule moves to the next
15
- // future slot (no burst of catch-up runs).
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
- /** The first anchored slot strictly after `nowMs`. */
37
- function nextAnchoredSlot(anchorMs, intervalMs, nowMs) {
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
- return anchorMs + (Math.floor((nowMs - anchorMs) / intervalMs) + 1) * intervalMs;
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 = Date.parse(input.schedule.run_at);
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 intervalMs = parseEvery(input.schedule.every);
122
- nextRunMs = nowMs + intervalMs;
123
- schedule = { every: input.schedule.every.trim(), anchor: new Date(nextRunMs).toISOString() };
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
- throw new ScheduleError('schedule must be { "run_at": "<ISO timestamp>" } or { "every": "<interval, e.g. 1h>" }');
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
- return nextAnchoredSlot(anchorMs, intervalMs, nowMs);
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;