clay-server 4.1.0-beta.4 → 4.1.0-beta.6

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.
Files changed (91) hide show
  1. package/lib/autonomous-run-record.js +32 -0
  2. package/lib/autonomous-run-scheduler.js +92 -0
  3. package/lib/durable-scheduler-record.js +61 -0
  4. package/lib/durable-scheduler-store.js +185 -0
  5. package/lib/durable-scheduler.js +495 -0
  6. package/lib/loop-cron.js +47 -0
  7. package/lib/loop-durable-schedule.js +159 -0
  8. package/lib/loop-guidance.js +35 -0
  9. package/lib/loop-registry-occurrence.js +76 -0
  10. package/lib/project-autonomous-run.js +498 -0
  11. package/lib/project-connection.js +4 -1
  12. package/lib/project-full-access.js +86 -0
  13. package/lib/project-http.js +3 -1
  14. package/lib/project-loop-interview.js +163 -0
  15. package/lib/project-loop.js +150 -64
  16. package/lib/project-pair-autonomous-stop.js +16 -0
  17. package/lib/project-pair-owned-stop.js +18 -0
  18. package/lib/project-pair-task-control.js +14 -1
  19. package/lib/project-scheduled-messages.js +316 -0
  20. package/lib/project-scheduled-task-execution.js +236 -0
  21. package/lib/project-scheduled-tasks.js +483 -0
  22. package/lib/project-session-pair.js +16 -19
  23. package/lib/project-sessions.js +60 -28
  24. package/lib/project-user-message.js +24 -18
  25. package/lib/project.js +260 -52
  26. package/lib/public/app.js +60 -23
  27. package/lib/public/css/home-hub.css +15 -2
  28. package/lib/public/css/home-sidebar.css +50 -2
  29. package/lib/public/css/input.css +71 -0
  30. package/lib/public/css/mates.css +0 -1
  31. package/lib/public/css/pane.css +58 -7
  32. package/lib/public/css/rewind.css +11 -2
  33. package/lib/public/css/scheduled-tasks.css +123 -0
  34. package/lib/public/css/sticky-notes.css +24 -4
  35. package/lib/public/index.html +19 -134
  36. package/lib/public/modules/app-loop-ui.js +4 -8
  37. package/lib/public/modules/app-messages.js +22 -6
  38. package/lib/public/modules/app-projects.js +2 -0
  39. package/lib/public/modules/app-rate-limit.js +8 -5
  40. package/lib/public/modules/autonomous-run.js +285 -0
  41. package/lib/public/modules/home-sidebar.js +16 -8
  42. package/lib/public/modules/home-surface.js +5 -0
  43. package/lib/public/modules/input.js +5 -4
  44. package/lib/public/modules/loop-interview.js +151 -0
  45. package/lib/public/modules/project-logs.js +2 -2
  46. package/lib/public/modules/scheduled-message-state.js +46 -0
  47. package/lib/public/modules/scheduled-task-edit-state.js +7 -0
  48. package/lib/public/modules/scheduled-task-engine.js +98 -0
  49. package/lib/public/modules/scheduled-task-form.js +122 -0
  50. package/lib/public/modules/scheduled-task-recurrence.js +46 -0
  51. package/lib/public/modules/scheduled-task-schedule-rule.js +115 -0
  52. package/lib/public/modules/scheduled-tasks.js +468 -0
  53. package/lib/public/modules/scheduler-config.js +6 -52
  54. package/lib/public/modules/scheduler.js +9 -21
  55. package/lib/public/modules/sidebar.js +0 -11
  56. package/lib/public/modules/split-view.js +2 -0
  57. package/lib/public/modules/sticky-notes-browser.js +1 -1
  58. package/lib/public/modules/sticky-notes.js +117 -4
  59. package/lib/public/modules/tool-palette-order.js +2 -3
  60. package/lib/public/modules/tools.js +5 -2
  61. package/lib/public/style.css +1 -0
  62. package/lib/record-revision.js +11 -0
  63. package/lib/schedule-rule.js +151 -0
  64. package/lib/schedule-validation.js +124 -0
  65. package/lib/scheduled-run-state.js +87 -0
  66. package/lib/scheduled-task-records.js +127 -0
  67. package/lib/scheduler.js +179 -196
  68. package/lib/sdk-bridge.js +123 -11
  69. package/lib/sdk-message-processor.js +3 -0
  70. package/lib/server-durable-scheduler.js +26 -0
  71. package/lib/server.js +22 -0
  72. package/lib/session-pair-factory.js +42 -4
  73. package/lib/session-pair-query-start.js +22 -0
  74. package/lib/session-query-tool-bridge.js +52 -0
  75. package/lib/session-split-groups.js +35 -1
  76. package/lib/sessions.js +51 -3
  77. package/lib/users-home-surface-preferences.js +2 -1
  78. package/lib/users-scheduled-task-preferences.js +44 -0
  79. package/lib/users.js +6 -0
  80. package/lib/ws-schema.js +25 -0
  81. package/lib/yoke/adapters/antigravity.js +1 -0
  82. package/lib/yoke/adapters/claude-worker.js +7 -4
  83. package/lib/yoke/adapters/claude.js +31 -1
  84. package/lib/yoke/adapters/codex.js +3 -0
  85. package/lib/yoke/claude-user-input.js +2 -0
  86. package/lib/yoke/index.js +2 -0
  87. package/lib/yoke/mcp-bridge-server.js +7 -1
  88. package/lib/yoke/session-tool-transport.js +53 -0
  89. package/lib/yoke/user-input.js +21 -7
  90. package/package.json +1 -1
  91. package/lib/public/modules/app-loop-wizard.js +0 -439
package/lib/scheduler.js CHANGED
@@ -1,137 +1,46 @@
1
- /**
2
- * Loop Registry — unified storage for all Ralph Loops (one-off + scheduled).
3
- *
4
- * Stores loop records in ~/.clay/loops/{encodedCwd}.jsonl
5
- * Each record represents a job defined via clay-ralph (PROMPT.md + JUDGE.md).
6
- * Records with a `cron` field are checked every 30s and auto-triggered.
7
- * Records without cron are one-off (standard Ralph Loop behavior).
8
- */
9
-
10
1
  var fs = require("fs");
11
2
  var path = require("path");
3
+ var crypto = require("crypto");
12
4
  var { CONFIG_DIR } = require("./config");
13
5
  var { encodeCwd } = require("./utils");
14
-
15
- // --- Cron parser (5-field: minute hour day-of-month month day-of-week) ---
16
-
17
- function parseCronField(field, min, max) {
18
- var values = [];
19
- var parts = field.split(",");
20
- for (var i = 0; i < parts.length; i++) {
21
- var part = parts[i].trim();
22
-
23
- // wildcard with step: */N
24
- if (part.indexOf("/") !== -1) {
25
- var slashParts = part.split("/");
26
- var step = parseInt(slashParts[1], 10);
27
- var rangeStr = slashParts[0];
28
- var rangeMin = min;
29
- var rangeMax = max;
30
- if (rangeStr !== "*") {
31
- var rp = rangeStr.split("-");
32
- rangeMin = parseInt(rp[0], 10);
33
- rangeMax = rp.length > 1 ? parseInt(rp[1], 10) : rangeMin;
34
- }
35
- for (var v = rangeMin; v <= rangeMax; v += step) {
36
- values.push(v);
37
- }
38
- continue;
39
- }
40
-
41
- // wildcard
42
- if (part === "*") {
43
- for (var v = min; v <= max; v++) {
44
- values.push(v);
45
- }
46
- continue;
47
- }
48
-
49
- // range: N-M
50
- if (part.indexOf("-") !== -1) {
51
- var rangeParts = part.split("-");
52
- var from = parseInt(rangeParts[0], 10);
53
- var to = parseInt(rangeParts[1], 10);
54
- for (var v = from; v <= to; v++) {
55
- values.push(v);
56
- }
57
- continue;
58
- }
59
-
60
- // single value
61
- values.push(parseInt(part, 10));
62
- }
63
- return values;
64
- }
65
-
66
- function parseCron(expr) {
67
- var fields = expr.trim().split(/\s+/);
68
- if (fields.length !== 5) return null;
69
- return {
70
- minutes: parseCronField(fields[0], 0, 59),
71
- hours: parseCronField(fields[1], 0, 23),
72
- daysOfMonth: parseCronField(fields[2], 1, 31),
73
- months: parseCronField(fields[3], 1, 12),
74
- daysOfWeek: parseCronField(fields[4], 0, 6),
75
- };
76
- }
77
-
78
- function cronMatches(parsed, date) {
79
- var minute = date.getMinutes();
80
- var hour = date.getHours();
81
- var dayOfMonth = date.getDate();
82
- var month = date.getMonth() + 1;
83
- var dayOfWeek = date.getDay();
84
-
85
- return (
86
- parsed.minutes.indexOf(minute) !== -1 &&
87
- parsed.hours.indexOf(hour) !== -1 &&
88
- parsed.daysOfMonth.indexOf(dayOfMonth) !== -1 &&
89
- parsed.months.indexOf(month) !== -1 &&
90
- parsed.daysOfWeek.indexOf(dayOfWeek) !== -1
91
- );
6
+ var scheduleRule = require("./schedule-rule");
7
+ var recordRevision = require("./record-revision");
8
+ var createScheduledRunState = require("./scheduled-run-state").createScheduledRunState;
9
+ var attachLoopRegistryOccurrence = require("./loop-registry-occurrence").attachLoopRegistryOccurrence;
10
+ var nextRunTime = require("./loop-cron").nextRunTime;
11
+
12
+ function nextRecordRunTime(record, after) {
13
+ return record.scheduleRule ? scheduleRule.nextScheduleRuleTime(record.scheduleRule, after, record.runs, { startCount: record.scheduleStartCount, dateStartCounts: record.scheduleDateStartCounts }) : (record.cron ? nextRunTime(record.cron, after) : null);
92
14
  }
93
15
 
94
- /**
95
- * Calculate next run time from a cron expression after a given date.
96
- * Brute-force: check each minute for up to 366 days.
97
- */
98
- function nextRunTime(cronExpr, after) {
99
- var parsed = parseCron(cronExpr);
100
- if (!parsed) return null;
101
-
102
- var d = new Date(after || Date.now());
103
- d.setSeconds(0, 0);
104
- d.setMinutes(d.getMinutes() + 1);
105
-
106
- var limit = 366 * 24 * 60;
107
- for (var i = 0; i < limit; i++) {
108
- if (cronMatches(parsed, d)) {
109
- return d.getTime();
110
- }
111
- d.setMinutes(d.getMinutes() + 1);
112
- }
113
- return null;
16
+ function oneOffRunTime(record) {
17
+ var date = record.date.split("-");
18
+ var time = record.time.split(":");
19
+ return new Date(Number(date[0]), Number(date[1]) - 1, Number(date[2]), Number(time[0]), Number(time[1]), 0).getTime();
114
20
  }
115
21
 
116
- // --- Loop Registry factory ---
117
-
118
22
  function createLoopRegistry(opts) {
119
23
  var cwd = opts.cwd;
120
- var onTrigger = opts.onTrigger;
121
- var onChange = opts.onChange;
24
+ var onTrigger = opts.onTrigger, onChange = opts.onChange;
25
+ var canTrigger = opts.canTrigger, prepareTrigger = opts.prepareTrigger;
122
26
 
123
27
  var encoded = encodeCwd(cwd);
124
- var registryDir = path.join(CONFIG_DIR, "loops");
125
- var registryPath = path.join(registryDir, encoded + ".jsonl");
28
+ var registryPath = opts.registryPath || path.join(CONFIG_DIR, "loops", encoded + ".jsonl");
29
+ var registryDir = path.dirname(registryPath);
126
30
 
127
31
  var records = [];
128
32
  var timerId = null;
129
33
  var CHECK_INTERVAL = 30 * 1000;
130
34
  var lastTriggeredMinute = {};
131
-
132
- // --- Persistence (JSONL) ---
35
+ var lastSaveError = null;
36
+ var scheduledRuns = createScheduledRunState({
37
+ getRecord: getById, save: save,
38
+ onChange: function () { if (onChange) onChange(records); },
39
+ });
40
+ var durableOccurrence;
133
41
 
134
42
  function load() {
43
+ var recoveredRun = false;
135
44
  try {
136
45
  var raw = fs.readFileSync(registryPath, "utf8");
137
46
  var lines = raw.trim().split("\n");
@@ -140,21 +49,24 @@ function createLoopRegistry(opts) {
140
49
  if (!lines[i].trim()) continue;
141
50
  try {
142
51
  var rec = JSON.parse(lines[i]);
143
- // Recalculate nextRunAt for scheduled records
144
- if (rec.cron && rec.enabled) {
145
- rec.nextRunAt = nextRunTime(rec.cron);
146
- } else if (!rec.cron && rec.enabled && rec.date && rec.time && rec.source === "schedule") {
147
- // One-off: recalculate from date+time
148
- var dtP = rec.date.split("-");
149
- var tmP = rec.time.split(":");
150
- var runD = new Date(parseInt(dtP[0], 10), parseInt(dtP[1], 10) - 1, parseInt(dtP[2], 10), parseInt(tmP[0], 10), parseInt(tmP[1], 10), 0);
151
- rec.nextRunAt = runD.getTime();
52
+ if (scheduledRuns.recover(rec)) recoveredRun = true;
53
+ if ((rec.cron || rec.scheduleRule || (rec.source === "schedule" && rec.date && rec.time)) && !rec.scheduleIdentity) {
54
+ rec.scheduleIdentity = crypto.randomUUID(); rec.scheduleEpoch = 1; recoveredRun = true;
55
+ } else if (rec.scheduleIdentity && (!Number.isInteger(rec.scheduleEpoch) || rec.scheduleEpoch < 1)) {
56
+ rec.scheduleEpoch = 1; recoveredRun = true;
57
+ }
58
+ if ((rec.cron || rec.scheduleRule) && rec.enabled) {
59
+ if (!Number.isFinite(rec.nextRunAt)) rec.nextRunAt = nextRecordRunTime(rec);
60
+ if (rec.scheduleRule && !rec.nextRunAt) rec.enabled = false;
61
+ } else if (!rec.cron && !rec.scheduleRule && rec.enabled && rec.date && rec.time && rec.source === "schedule") {
62
+ rec.nextRunAt = oneOffRunTime(rec);
152
63
  }
153
64
  records.push(rec);
154
65
  } catch (e) {
155
66
  // skip malformed line
156
67
  }
157
68
  }
69
+ if (recoveredRun) save();
158
70
  } catch (e) {
159
71
  records = [];
160
72
  }
@@ -170,12 +82,28 @@ function createLoopRegistry(opts) {
170
82
  var tmpPath = registryPath + ".tmp";
171
83
  fs.writeFileSync(tmpPath, lines.join("\n") + "\n");
172
84
  fs.renameSync(tmpPath, registryPath);
85
+ lastSaveError = null;
86
+ return true;
173
87
  } catch (e) {
88
+ lastSaveError = e.message || String(e);
174
89
  console.error("[loop-registry] Failed to save:", e.message);
90
+ return false;
175
91
  }
176
92
  }
177
93
 
178
- // --- Timer (scheduled loops only) ---
94
+ function notifyOrRollback(rollback) {
95
+ if (!onChange) return true;
96
+ try { onChange(records); return true; }
97
+ catch (error) {
98
+ rollback();
99
+ var syncMessage = error && (error.message || String(error)) || "unknown durable scheduler error";
100
+ var rollbackSaved = save();
101
+ var rollbackMessage = rollbackSaved ? "" : " Registry rollback could not be stored: " + (lastSaveError || "unknown storage error") + ".";
102
+ lastSaveError = "Schedule metadata was rolled back because durable synchronization failed: " + syncMessage + "." + rollbackMessage;
103
+ console.error("[loop-registry] " + lastSaveError);
104
+ return false;
105
+ }
106
+ }
179
107
 
180
108
  function startTimer() {
181
109
  if (timerId) return;
@@ -201,13 +129,24 @@ function createLoopRegistry(opts) {
201
129
  if (!rec.enabled) continue;
202
130
  if (!rec.nextRunAt) continue;
203
131
  if (rec.nextRunAt > now) continue;
132
+ if (canTrigger && canTrigger(rec) === false) continue;
133
+
134
+ if (rec.scheduleRule) {
135
+ var duePrevious = JSON.parse(JSON.stringify(rec));
136
+ var currentDue = nextRecordRunTime(rec, now - 60000);
137
+ if (currentDue !== rec.nextRunAt) {
138
+ rec.nextRunAt = currentDue;
139
+ if (!currentDue) rec.enabled = false;
140
+ if (!save()) recordRevision.restoreRecord(rec, duePrevious);
141
+ if (onChange) onChange(records);
142
+ continue;
143
+ }
144
+ }
204
145
 
205
- // Avoid double-trigger within same minute
206
146
  var triggerKey = rec.id + "_" + nowMinuteKey;
207
147
  if (lastTriggeredMinute[triggerKey]) continue;
208
148
  lastTriggeredMinute[triggerKey] = true;
209
149
 
210
- // Clean old trigger keys
211
150
  var keys = Object.keys(lastTriggeredMinute);
212
151
  for (var k = 0; k < keys.length; k++) {
213
152
  var keyParts = keys[k].split("_");
@@ -217,8 +156,7 @@ function createLoopRegistry(opts) {
217
156
  }
218
157
  }
219
158
 
220
- // Check recurrenceEnd conditions before triggering
221
- if (rec.recurrenceEnd) {
159
+ if (!rec.scheduleRule && rec.recurrenceEnd) {
222
160
  var shouldDisable = false;
223
161
  if (rec.recurrenceEnd.type === "until" && rec.recurrenceEnd.date) {
224
162
  var reParts = rec.recurrenceEnd.date.split("-");
@@ -240,11 +178,9 @@ function createLoopRegistry(opts) {
240
178
  }
241
179
  }
242
180
 
243
- // Check intervalEnd conditions before triggering
244
- if (rec.intervalEnd) {
181
+ if (!rec.scheduleRule && rec.intervalEnd) {
245
182
  var skipTrigger = false;
246
183
  if (rec.intervalEnd.type === "until" && rec.intervalEnd.time) {
247
- // Stop at a specific time of day
248
184
  var nowDate = new Date(now);
249
185
  var ieParts = rec.intervalEnd.time.split(":");
250
186
  var stopH = parseInt(ieParts[0], 10);
@@ -254,7 +190,6 @@ function createLoopRegistry(opts) {
254
190
  skipTrigger = true;
255
191
  }
256
192
  } else if (rec.intervalEnd.type === "after" && rec.intervalEnd.count > 0) {
257
- // Stop after N runs today
258
193
  var todayStart = new Date(now);
259
194
  todayStart.setHours(0, 0, 0, 0);
260
195
  var todayStartMs = todayStart.getTime();
@@ -264,7 +199,6 @@ function createLoopRegistry(opts) {
264
199
  }
265
200
  }
266
201
  if (skipTrigger) {
267
- // Advance nextRunAt without triggering
268
202
  if (rec.cron) {
269
203
  rec.nextRunAt = nextRunTime(rec.cron, now);
270
204
  }
@@ -273,36 +207,42 @@ function createLoopRegistry(opts) {
273
207
  }
274
208
  }
275
209
 
276
- // Update nextRunAt
210
+ var previous = JSON.parse(JSON.stringify(rec));
211
+ var prepared = prepareTrigger ? prepareTrigger(rec) : null;
212
+ if (prepared && prepared.ok === false) { delete lastTriggeredMinute[triggerKey]; continue; }
213
+ scheduledRuns.prepareRecord(rec, prepared);
277
214
  rec.lastRunAt = now;
278
- if (rec.cron) {
279
- rec.nextRunAt = nextRunTime(rec.cron, now);
215
+ if (rec.scheduleRule) scheduleRule.recordScheduleStartState(rec, now);
216
+ if (rec.cron || rec.scheduleRule) {
217
+ rec.nextRunAt = nextRecordRunTime(rec, now);
218
+ if (rec.scheduleRule && !rec.nextRunAt) rec.enabled = false;
280
219
  } else {
281
- // One-off schedule: disable after firing
282
220
  rec.nextRunAt = null;
283
221
  rec.enabled = false;
284
222
  }
285
- save();
223
+ if (!save()) {
224
+ recordRevision.restoreRecord(rec, previous);
225
+ delete lastTriggeredMinute[triggerKey];
226
+ continue;
227
+ }
286
228
  if (onChange) onChange(records);
287
229
 
288
230
  console.log("[loop-registry] Triggering scheduled loop: " + rec.name + " (" + rec.id + ")");
289
231
  if (onTrigger) {
290
- try { onTrigger(rec); } catch (e) {
232
+ try { onTrigger(rec, prepared); } catch (e) {
291
233
  console.error("[loop-registry] Trigger error:", e.message);
292
234
  }
293
235
  }
294
236
  }
295
237
  }
296
238
 
297
- // --- CRUD ---
298
-
299
239
  function register(data) {
300
240
  var rec = {
301
241
  id: data.id || ("loop_" + Date.now() + "_" + require("crypto").randomBytes(3).toString("hex")),
302
242
  name: data.name || "Untitled",
303
243
  task: data.task || "",
304
244
  cron: data.cron || null,
305
- enabled: data.cron ? (data.enabled !== false) : false,
245
+ enabled: (data.cron || data.scheduleRule) ? (data.enabled !== false) : false,
306
246
  maxIterations: (data.maxIterations >= 1) ? data.maxIterations : 20,
307
247
  createdAt: Date.now(),
308
248
  updatedAt: Date.now(),
@@ -320,29 +260,41 @@ function createLoopRegistry(opts) {
320
260
  recurrenceEnd: data.recurrenceEnd || null,
321
261
  mode: data.mode || "loop",
322
262
  prompt: data.prompt || null,
263
+ ownerId: data.ownerId || null,
264
+ ownerName: data.ownerName || null,
265
+ sessionId: data.sessionId || null,
266
+ createdViaScheduledTasks: data.createdViaScheduledTasks === true,
323
267
  skipIfRunning: data.skipIfRunning !== undefined ? data.skipIfRunning : true,
324
268
  intervalEnd: data.intervalEnd || null,
269
+ scheduleRule: data.scheduleRule || null,
270
+ scheduleStartCount: Number(data.scheduleStartCount || 0),
271
+ scheduleDateStartCounts: data.scheduleDateStartCounts && typeof data.scheduleDateStartCounts === "object" ? Object.assign({}, data.scheduleDateStartCounts) : {},
272
+ scheduleIdentity: data.scheduleIdentity || crypto.randomUUID(),
273
+ scheduleEpoch: Number.isInteger(data.scheduleEpoch) && data.scheduleEpoch > 0 ? data.scheduleEpoch : 1,
274
+ execution: data.execution || null,
275
+ activeRun: data.activeRun || null,
325
276
  runs: [],
326
277
  };
327
- if (rec.cron && rec.enabled) {
328
- rec.nextRunAt = nextRunTime(rec.cron);
329
- } else if (!rec.cron && rec.date && rec.time && rec.source === "schedule") {
330
- // One-off schedule: compute nextRunAt from date + time
331
- var dtParts = rec.date.split("-");
332
- var tmParts = rec.time.split(":");
333
- var runDate = new Date(parseInt(dtParts[0], 10), parseInt(dtParts[1], 10) - 1, parseInt(dtParts[2], 10), parseInt(tmParts[0], 10), parseInt(tmParts[1], 10), 0);
334
- rec.nextRunAt = runDate.getTime();
278
+ if (rec.createdViaScheduledTasks && data.maxIterations === undefined) delete rec.maxIterations;
279
+ if ((rec.cron || rec.scheduleRule) && rec.enabled) {
280
+ rec.nextRunAt = nextRecordRunTime(rec);
281
+ } else if (!rec.cron && !rec.scheduleRule && rec.date && rec.time && rec.source === "schedule") {
282
+ rec.nextRunAt = oneOffRunTime(rec);
335
283
  rec.enabled = true;
336
284
  }
337
285
  records.push(rec);
338
- save();
339
- if (onChange) onChange(records);
286
+ if (!save()) {
287
+ records.pop();
288
+ return null;
289
+ }
290
+ if (!notifyOrRollback(function () { records.pop(); })) return null;
340
291
  return rec;
341
292
  }
342
293
 
343
294
  function update(id, data) {
344
295
  var rec = getById(id);
345
296
  if (!rec) return null;
297
+ var previous = Object.assign({}, rec);
346
298
 
347
299
  if (data.name !== undefined) rec.name = data.name;
348
300
  if (data.cron !== undefined) rec.cron = data.cron;
@@ -353,39 +305,54 @@ function createLoopRegistry(opts) {
353
305
  if (data.recurrenceEnd !== undefined) rec.recurrenceEnd = data.recurrenceEnd;
354
306
  if (data.mode !== undefined) rec.mode = data.mode;
355
307
  if (data.prompt !== undefined) rec.prompt = data.prompt;
308
+ if (data.task !== undefined) rec.task = data.task;
309
+ if (data.ownerId !== undefined) rec.ownerId = data.ownerId;
310
+ if (data.ownerName !== undefined) rec.ownerName = data.ownerName;
311
+ if (data.sessionId !== undefined) rec.sessionId = data.sessionId;
356
312
  if (data.description !== undefined) rec.description = data.description;
357
313
  if (data.color !== undefined) rec.color = data.color;
358
314
  if (data.allDay !== undefined) rec.allDay = data.allDay;
359
315
  if (data.linkedTaskId !== undefined) rec.linkedTaskId = data.linkedTaskId;
316
+ if (data.source !== undefined) rec.source = data.source;
360
317
  if (data.skipIfRunning !== undefined) rec.skipIfRunning = data.skipIfRunning;
361
318
  if (data.intervalEnd !== undefined) rec.intervalEnd = data.intervalEnd;
362
- rec.updatedAt = Date.now();
363
- if (rec.cron && rec.enabled) {
364
- rec.nextRunAt = nextRunTime(rec.cron);
365
- } else if (!rec.cron && rec.date && rec.time && rec.source === "schedule") {
366
- var dtP2 = rec.date.split("-");
367
- var tmP2 = rec.time.split(":");
368
- var runD2 = new Date(parseInt(dtP2[0], 10), parseInt(dtP2[1], 10) - 1, parseInt(dtP2[2], 10), parseInt(tmP2[0], 10), parseInt(tmP2[1], 10), 0);
369
- rec.nextRunAt = runD2.getTime();
370
- rec.enabled = true;
319
+ if (data.scheduleRule !== undefined) rec.scheduleRule = data.scheduleRule;
320
+ if (data.execution !== undefined) rec.execution = data.execution;
321
+ rec.updatedAt = recordRevision.nextRevision(rec);
322
+ if ((rec.cron || rec.scheduleRule) && rec.enabled) {
323
+ rec.nextRunAt = nextRecordRunTime(rec);
324
+ } else if (!rec.cron && !rec.scheduleRule && rec.enabled && rec.date && rec.time && rec.source === "schedule") {
325
+ rec.nextRunAt = oneOffRunTime(rec);
371
326
  } else {
372
327
  rec.nextRunAt = null;
373
328
  }
374
-
375
- save();
376
- if (onChange) onChange(records);
329
+ var sameTiming = previous.cron === rec.cron && previous.date === rec.date && previous.time === rec.time && JSON.stringify(previous.scheduleRule || null) === JSON.stringify(rec.scheduleRule || null);
330
+ if (!sameTiming || previous.enabled !== rec.enabled) rec.scheduleEpoch = Math.max(1, Number(previous.scheduleEpoch) || 1) + 1;
331
+ if (previous.enabled && rec.enabled && previous.nextRunAt && sameTiming) rec.nextRunAt = previous.nextRunAt;
332
+ if (data.ownerId) { delete rec.needsOwner; delete rec.setupRequired; }
333
+
334
+ if (!save()) {
335
+ recordRevision.restoreRecord(rec, previous);
336
+ return null;
337
+ }
338
+ if (!notifyOrRollback(function () { recordRevision.restoreRecord(rec, previous); })) return null;
377
339
  return rec;
378
340
  }
379
341
 
380
342
  function updateRecord(id, data) {
381
343
  var rec = getById(id);
382
344
  if (!rec) return null;
345
+ var previous = Object.assign({}, rec);
383
346
  var keys = Object.keys(data);
384
347
  for (var i = 0; i < keys.length; i++) {
385
348
  rec[keys[i]] = data[keys[i]];
386
349
  }
387
- save();
388
- if (onChange) onChange(records);
350
+ rec.updatedAt = recordRevision.nextRevision(previous);
351
+ if (!save()) {
352
+ recordRevision.restoreRecord(rec, previous);
353
+ return null;
354
+ }
355
+ if (!notifyOrRollback(function () { recordRevision.restoreRecord(rec, previous); })) return null;
389
356
  return rec;
390
357
  }
391
358
 
@@ -395,40 +362,33 @@ function createLoopRegistry(opts) {
395
362
  if (records[i].id === id) { idx = i; break; }
396
363
  }
397
364
  if (idx === -1) return false;
398
- records.splice(idx, 1);
399
- save();
400
- if (onChange) onChange(records);
365
+ var removed = records.splice(idx, 1)[0];
366
+ if (!save()) { records.splice(idx, 0, removed); return false; }
367
+ if (!notifyOrRollback(function () { records.splice(idx, 0, removed); })) return false;
401
368
  return true;
402
369
  }
403
370
 
404
371
  function toggleEnabled(id) {
405
372
  var rec = getById(id);
406
- if (!rec || !rec.cron) return null; // only toggle scheduled loops
373
+ var oneOffSchedule = rec && !rec.cron && !rec.scheduleRule && rec.date && rec.time && rec.source === "schedule";
374
+ if (!rec || (!rec.cron && !rec.scheduleRule && !oneOffSchedule)) return null;
375
+ var previous = Object.assign({}, rec);
407
376
  rec.enabled = !rec.enabled;
408
- rec.updatedAt = Date.now();
409
- rec.nextRunAt = rec.enabled ? nextRunTime(rec.cron) : null;
410
- save();
411
- if (onChange) onChange(records);
412
- return rec;
413
- }
414
-
415
- function recordRun(id, result) {
416
- var rec = getById(id);
417
- if (!rec) return;
418
- rec.lastRunAt = result.startedAt || Date.now();
419
- rec.lastRunResult = result.reason || null;
420
- rec.runs.push({
421
- startedAt: result.startedAt || rec.lastRunAt,
422
- finishedAt: Date.now(),
423
- result: result.reason || "unknown",
424
- iterations: result.iterations || 0,
425
- });
426
- // Keep only last 20 run entries
427
- if (rec.runs.length > 20) {
428
- rec.runs = rec.runs.slice(-20);
377
+ rec.scheduleEpoch = Math.max(1, Number(rec.scheduleEpoch) || 1) + 1;
378
+ rec.updatedAt = recordRevision.nextRevision(rec);
379
+ if (!rec.enabled) {
380
+ rec.nextRunAt = null;
381
+ } else if (rec.cron || rec.scheduleRule) {
382
+ rec.nextRunAt = nextRecordRunTime(rec);
383
+ } else {
384
+ rec.nextRunAt = oneOffRunTime(rec);
385
+ }
386
+ if (!save()) {
387
+ recordRevision.restoreRecord(rec, previous);
388
+ return null;
429
389
  }
430
- save();
431
- if (onChange) onChange(records);
390
+ if (!notifyOrRollback(function () { recordRevision.restoreRecord(rec, previous); })) return null;
391
+ return rec;
432
392
  }
433
393
 
434
394
  function getAll() {
@@ -445,25 +405,48 @@ function createLoopRegistry(opts) {
445
405
  function getScheduled() {
446
406
  var result = [];
447
407
  for (var i = 0; i < records.length; i++) {
448
- if (records[i].cron) result.push(records[i]);
408
+ if (records[i].cron || records[i].scheduleRule) result.push(records[i]);
449
409
  }
450
410
  return result;
451
411
  }
452
412
 
413
+ durableOccurrence = attachLoopRegistryOccurrence({
414
+ getById: getById,
415
+ save: save,
416
+ changed: function () { if (onChange) onChange(records); },
417
+ lastSaveError: function () { return lastSaveError; },
418
+ prepareTrigger: prepareTrigger,
419
+ onTrigger: onTrigger,
420
+ scheduledRuns: scheduledRuns,
421
+ scheduleRule: scheduleRule,
422
+ nextRecordRunTime: nextRecordRunTime,
423
+ });
424
+
453
425
  return {
454
426
  load: load,
455
427
  save: save,
456
428
  startTimer: startTimer,
457
429
  stopTimer: stopTimer,
430
+ tick: tick,
458
431
  register: register,
459
432
  update: update,
460
433
  updateRecord: updateRecord,
461
434
  remove: remove,
462
435
  toggleEnabled: toggleEnabled,
463
- recordRun: recordRun,
436
+ normalizeDurableOccurrence: durableOccurrence.normalize,
437
+ markNeedsOwner: durableOccurrence.markNeedsOwner,
438
+ prepareDurableOccurrence: durableOccurrence.prepare,
439
+ triggerDurableOccurrence: durableOccurrence.trigger,
440
+ completeDurableOccurrence: durableOccurrence.complete,
441
+ reconcileDurableOccurrence: durableOccurrence.reconcile,
442
+ recordRun: scheduledRuns.recordLegacy,
443
+ beginRun: scheduledRuns.begin,
444
+ updateRun: scheduledRuns.update,
445
+ completeRun: scheduledRuns.complete,
464
446
  getAll: getAll,
465
447
  getById: getById,
466
448
  getScheduled: getScheduled,
449
+ getLastSaveError: function () { return lastSaveError; },
467
450
  nextRunTime: nextRunTime,
468
451
  };
469
452
  }