u-foo 1.2.12 → 1.2.14

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,7 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { getUfooPaths } = require("../ufoo/paths");
1
4
  const {
2
- createCronScheduler,
3
5
  parseIntervalMs,
4
6
  formatIntervalMs,
5
7
  } = require("../chat/cronScheduler");
@@ -49,6 +51,68 @@ function resolveCronIntervalMs(op = {}) {
49
51
  return parseIntervalMs(everyRaw);
50
52
  }
51
53
 
54
+ function parseCronAtMs(value = "") {
55
+ const text = String(value || "").trim();
56
+ if (!text) return 0;
57
+
58
+ if (/^\d+$/.test(text)) {
59
+ const parsed = Number.parseInt(text, 10);
60
+ if (!Number.isFinite(parsed) || parsed <= 0) return 0;
61
+ return text.length <= 10 ? parsed * 1000 : parsed;
62
+ }
63
+
64
+ const normalized = text.replace(/\//g, "-");
65
+ const direct = normalized.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})(?::(\d{2}))?$/);
66
+ if (direct) {
67
+ const seconds = direct[3] || "00";
68
+ const parsed = Date.parse(`${direct[1]}T${direct[2]}:${seconds}`);
69
+ return Number.isFinite(parsed) ? parsed : 0;
70
+ }
71
+
72
+ const parsed = Date.parse(normalized);
73
+ return Number.isFinite(parsed) ? parsed : 0;
74
+ }
75
+
76
+ function formatCronAtMs(value = 0) {
77
+ const ts = Number(value) || 0;
78
+ if (ts <= 0) return "";
79
+ const d = new Date(ts);
80
+ const pad = (v) => String(v).padStart(2, "0");
81
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
82
+ }
83
+
84
+ function resolveCronOnceAtMs(op = {}) {
85
+ const numeric = Number(
86
+ op.once_at_ms
87
+ ?? op.onceAtMs
88
+ ?? op.at_ms
89
+ ?? op.atMs
90
+ ?? op.run_at_ms
91
+ ?? op.runAtMs
92
+ );
93
+ if (Number.isFinite(numeric) && numeric > 0) {
94
+ return Math.floor(numeric);
95
+ }
96
+
97
+ const combined = (op.date && op.time)
98
+ ? `${String(op.date).trim()} ${String(op.time).trim()}`
99
+ : "";
100
+
101
+ const raw = String(
102
+ op.at
103
+ || op.once
104
+ || op.run_at
105
+ || op.runAt
106
+ || op.datetime
107
+ || op.date_time
108
+ || combined
109
+ || ""
110
+ ).trim();
111
+
112
+ if (!raw) return 0;
113
+ return parseCronAtMs(raw);
114
+ }
115
+
52
116
  function resolveCronPrompt(op = {}) {
53
117
  return String(op.prompt || op.message || op.msg || "").trim();
54
118
  }
@@ -57,61 +121,302 @@ function resolveCronTaskId(op = {}) {
57
121
  return String(op.id || op.task_id || op.taskId || "").trim();
58
122
  }
59
123
 
124
+ function sanitizeSummaryText(value = "") {
125
+ return String(value || "")
126
+ .replace(/[{}]/g, "")
127
+ .replace(/\s+/g, " ")
128
+ .trim();
129
+ }
130
+
131
+ function summarizeCronTask(task = {}) {
132
+ const id = String(task.id || "");
133
+ const targets = Array.isArray(task.targets) ? task.targets.join("+") : "";
134
+ const promptRaw = sanitizeSummaryText(task.prompt || "");
135
+ const prompt = promptRaw.length > 24 ? `${promptRaw.slice(0, 24)}...` : promptRaw;
136
+
137
+ if (Number(task.onceAtMs) > 0) {
138
+ return `${id}@once(${formatCronAtMs(task.onceAtMs)})->${targets}: ${prompt || "(empty)"}`;
139
+ }
140
+
141
+ const interval = formatIntervalMs(task.intervalMs || 0);
142
+ return `${id}@${interval}->${targets}: ${prompt || "(empty)"}`;
143
+ }
144
+
60
145
  function formatCronTask(task = {}) {
146
+ const onceAtMs = Number(task.onceAtMs) || 0;
61
147
  return {
62
148
  id: String(task.id || ""),
149
+ mode: onceAtMs > 0 ? "once" : "interval",
63
150
  intervalMs: Number(task.intervalMs) || 0,
64
- interval: formatIntervalMs(task.intervalMs || 0),
151
+ interval: Number(task.intervalMs) > 0 ? formatIntervalMs(task.intervalMs) : "",
152
+ onceAtMs,
153
+ onceAt: onceAtMs > 0 ? formatCronAtMs(onceAtMs) : "",
65
154
  targets: Array.isArray(task.targets) ? task.targets.slice() : [],
66
155
  prompt: String(task.prompt || ""),
67
156
  createdAt: Number(task.createdAt) || 0,
68
157
  lastRunAt: Number(task.lastRunAt) || 0,
69
158
  tickCount: Number(task.tickCount) || 0,
70
- summary: String(task.summary || ""),
159
+ summary: summarizeCronTask(task),
71
160
  };
72
161
  }
73
162
 
74
163
  function createDaemonCronController(options = {}) {
75
164
  const {
165
+ projectRoot = "",
76
166
  dispatch = async () => {},
77
167
  log = () => {},
78
- setIntervalFn,
79
- clearIntervalFn,
80
- nowFn,
168
+ setIntervalFn = setInterval,
169
+ clearIntervalFn = clearInterval,
170
+ setTimeoutFn = setTimeout,
171
+ clearTimeoutFn = clearTimeout,
172
+ nowFn = () => Date.now(),
173
+ fsModule = fs,
174
+ pathModule = path,
175
+ getUfooPathsImpl = getUfooPaths,
176
+ storageFile = "",
81
177
  } = options;
82
178
 
83
- const scheduler = createCronScheduler({
84
- dispatch: ({ taskId, target, message }) => {
179
+ let seq = 0;
180
+ const tasks = [];
181
+
182
+ const persistedFile = storageFile
183
+ || (projectRoot ? pathModule.join(getUfooPathsImpl(projectRoot).runDir, "cron.tasks.json") : "");
184
+
185
+ function nextTaskId() {
186
+ seq += 1;
187
+ return `c${seq}`;
188
+ }
189
+
190
+ function persistState() {
191
+ if (!persistedFile) return;
192
+ const state = {
193
+ version: 1,
194
+ seq,
195
+ tasks: tasks.map((task) => ({
196
+ id: task.id,
197
+ intervalMs: task.intervalMs,
198
+ onceAtMs: task.onceAtMs,
199
+ targets: task.targets.slice(),
200
+ prompt: task.prompt,
201
+ createdAt: task.createdAt,
202
+ lastRunAt: task.lastRunAt,
203
+ tickCount: task.tickCount,
204
+ })),
205
+ };
206
+
207
+ try {
208
+ fsModule.mkdirSync(pathModule.dirname(persistedFile), { recursive: true });
209
+ const tmpFile = `${persistedFile}.tmp`;
210
+ fsModule.writeFileSync(tmpFile, JSON.stringify(state, null, 2), "utf8");
211
+ fsModule.renameSync(tmpFile, persistedFile);
212
+ } catch (err) {
213
+ const detail = err && err.message ? err.message : String(err || "persist failed");
214
+ log(`cron persist failed: ${detail}`);
215
+ }
216
+ }
217
+
218
+ function clearTaskTimer(task) {
219
+ if (!task || !task.timer) return;
220
+ if (task.onceAtMs > 0) {
221
+ clearTimeoutFn(task.timer);
222
+ } else {
223
+ clearIntervalFn(task.timer);
224
+ }
225
+ task.timer = null;
226
+ }
227
+
228
+ function runTask(task) {
229
+ task.lastRunAt = nowFn();
230
+ task.tickCount += 1;
231
+
232
+ for (const target of task.targets) {
85
233
  try {
86
- Promise.resolve(dispatch({ taskId, target, message })).catch((err) => {
234
+ Promise.resolve(dispatch({
235
+ taskId: task.id,
236
+ target,
237
+ message: task.prompt,
238
+ })).catch((err) => {
87
239
  const detail = err && err.message ? err.message : String(err || "dispatch failed");
88
- log(`cron dispatch failed task=${taskId} target=${target}: ${detail}`);
240
+ log(`cron dispatch failed task=${task.id} target=${target}: ${detail}`);
89
241
  });
90
242
  } catch (err) {
91
243
  const detail = err && err.message ? err.message : String(err || "dispatch failed");
92
- log(`cron dispatch failed task=${taskId} target=${target}: ${detail}`);
244
+ log(`cron dispatch failed task=${task.id} target=${target}: ${detail}`);
93
245
  }
94
- },
95
- setIntervalFn,
96
- clearIntervalFn,
97
- nowFn,
98
- });
246
+ }
247
+ }
248
+
249
+ function stopTask(taskId = "") {
250
+ const id = String(taskId || "").trim();
251
+ if (!id) return false;
252
+ const idx = tasks.findIndex((task) => task.id === id);
253
+ if (idx < 0) return false;
254
+
255
+ const task = tasks[idx];
256
+ clearTaskTimer(task);
257
+ tasks.splice(idx, 1);
258
+ persistState();
259
+ return true;
260
+ }
261
+
262
+ function attachTaskTimer(task) {
263
+ if (task.onceAtMs > 0) {
264
+ const delay = Math.max(0, task.onceAtMs - nowFn());
265
+ task.timer = setTimeoutFn(() => {
266
+ runTask(task);
267
+ stopTask(task.id);
268
+ }, delay);
269
+ return;
270
+ }
271
+
272
+ task.timer = setIntervalFn(() => {
273
+ runTask(task);
274
+ persistState();
275
+ }, task.intervalMs);
276
+ }
277
+
278
+ function addTask({ intervalMs = 0, onceAtMs = 0, targets = [], prompt = "" } = {}) {
279
+ const safeInterval = Number.parseInt(intervalMs, 10);
280
+ const safeOnceAt = Number.parseInt(onceAtMs, 10);
281
+ const safeTargets = Array.isArray(targets)
282
+ ? targets.map((item) => String(item || "").trim()).filter(Boolean)
283
+ : [];
284
+ const safePrompt = String(prompt || "").trim();
285
+
286
+ if (!safePrompt || safeTargets.length === 0) return null;
287
+
288
+ const useOnce = Number.isFinite(safeOnceAt) && safeOnceAt > 0;
289
+ if (!useOnce) {
290
+ if (!Number.isFinite(safeInterval) || safeInterval < 1000) return null;
291
+ }
292
+
293
+ const task = {
294
+ id: nextTaskId(),
295
+ intervalMs: useOnce ? 0 : safeInterval,
296
+ onceAtMs: useOnce ? safeOnceAt : 0,
297
+ targets: Array.from(new Set(safeTargets)),
298
+ prompt: safePrompt,
299
+ createdAt: nowFn(),
300
+ lastRunAt: 0,
301
+ tickCount: 0,
302
+ timer: null,
303
+ };
304
+
305
+ attachTaskTimer(task);
306
+ tasks.push(task);
307
+ persistState();
308
+
309
+ return formatCronTask(task);
310
+ }
99
311
 
100
312
  function listTasks() {
101
- return scheduler.listTasks().map(formatCronTask);
313
+ return tasks.map((task) => formatCronTask(task));
314
+ }
315
+
316
+ function stopAll() {
317
+ if (tasks.length === 0) return 0;
318
+ const count = tasks.length;
319
+ while (tasks.length > 0) {
320
+ const task = tasks.pop();
321
+ clearTaskTimer(task);
322
+ }
323
+ persistState();
324
+ return count;
325
+ }
326
+
327
+ function recoverPersistedTasks() {
328
+ if (!persistedFile) return;
329
+ if (!fsModule.existsSync(persistedFile)) return;
330
+
331
+ let payload = null;
332
+ try {
333
+ payload = JSON.parse(fsModule.readFileSync(persistedFile, "utf8"));
334
+ } catch (err) {
335
+ const detail = err && err.message ? err.message : String(err || "read failed");
336
+ log(`cron load failed: ${detail}`);
337
+ return;
338
+ }
339
+
340
+ const persistedSeq = Number(payload && payload.seq);
341
+ if (Number.isFinite(persistedSeq) && persistedSeq > 0) {
342
+ seq = Math.floor(persistedSeq);
343
+ }
344
+
345
+ const rawTasks = Array.isArray(payload && payload.tasks) ? payload.tasks : [];
346
+ if (rawTasks.length === 0) {
347
+ persistState();
348
+ return;
349
+ }
350
+
351
+ const now = nowFn();
352
+ let changed = false;
353
+
354
+ for (const item of rawTasks) {
355
+ const rawId = String(item && item.id ? item.id : "").trim();
356
+ const parsedId = rawId.match(/^c(\d+)$/i);
357
+ if (parsedId) {
358
+ const numericId = Number.parseInt(parsedId[1], 10);
359
+ if (Number.isFinite(numericId) && numericId > seq) {
360
+ seq = numericId;
361
+ }
362
+ }
363
+
364
+ const intervalMs = Number(item && item.intervalMs);
365
+ const onceAtMs = Number(item && item.onceAtMs);
366
+ const targets = Array.isArray(item && item.targets)
367
+ ? item.targets.map((v) => String(v || "").trim()).filter(Boolean)
368
+ : [];
369
+ const prompt = String(item && item.prompt ? item.prompt : "").trim();
370
+
371
+ if (!prompt || targets.length === 0) {
372
+ changed = true;
373
+ continue;
374
+ }
375
+
376
+ if (Number.isFinite(onceAtMs) && onceAtMs > 0) {
377
+ if (onceAtMs <= now) {
378
+ changed = true;
379
+ continue;
380
+ }
381
+ } else if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
382
+ changed = true;
383
+ continue;
384
+ }
385
+
386
+ const task = {
387
+ id: rawId || nextTaskId(),
388
+ intervalMs: Number.isFinite(intervalMs) ? Math.floor(intervalMs) : 0,
389
+ onceAtMs: Number.isFinite(onceAtMs) ? Math.floor(onceAtMs) : 0,
390
+ targets: Array.from(new Set(targets)),
391
+ prompt,
392
+ createdAt: Number(item && item.createdAt) || now,
393
+ lastRunAt: Number(item && item.lastRunAt) || 0,
394
+ tickCount: Number(item && item.tickCount) || 0,
395
+ timer: null,
396
+ };
397
+
398
+ attachTaskTimer(task);
399
+ tasks.push(task);
400
+ }
401
+
402
+ if (changed) {
403
+ persistState();
404
+ }
102
405
  }
103
406
 
407
+ recoverPersistedTasks();
408
+
104
409
  function handleCronOp(op = {}) {
105
410
  const operation = resolveCronOperation(op);
106
411
 
107
412
  if (operation === "list" || operation === "ls") {
108
- const tasks = listTasks();
413
+ const listed = listTasks();
109
414
  return {
110
415
  action: "cron",
111
416
  operation: "list",
112
417
  ok: true,
113
- count: tasks.length,
114
- tasks,
418
+ count: listed.length,
419
+ tasks: listed,
115
420
  };
116
421
  }
117
422
 
@@ -127,7 +432,7 @@ function createDaemonCronController(options = {}) {
127
432
  }
128
433
 
129
434
  if (id === "all") {
130
- const stopped = scheduler.stopAll();
435
+ const stopped = stopAll();
131
436
  return {
132
437
  action: "cron",
133
438
  operation: "stop",
@@ -137,7 +442,7 @@ function createDaemonCronController(options = {}) {
137
442
  };
138
443
  }
139
444
 
140
- const ok = scheduler.stopTask(id);
445
+ const ok = stopTask(id);
141
446
  if (!ok) {
142
447
  return {
143
448
  action: "cron",
@@ -167,7 +472,36 @@ function createDaemonCronController(options = {}) {
167
472
  }
168
473
 
169
474
  const intervalMs = resolveCronIntervalMs(op);
170
- if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
475
+ const onceAtMs = resolveCronOnceAtMs(op);
476
+
477
+ if (intervalMs > 0 && onceAtMs > 0) {
478
+ return {
479
+ action: "cron",
480
+ operation: "start",
481
+ ok: false,
482
+ error: "cron start accepts either every or at/once, not both",
483
+ };
484
+ }
485
+
486
+ if (onceAtMs > 0 && onceAtMs <= nowFn()) {
487
+ return {
488
+ action: "cron",
489
+ operation: "start",
490
+ ok: false,
491
+ error: "one-time cron time must be in the future",
492
+ };
493
+ }
494
+
495
+ if (intervalMs <= 0 && onceAtMs <= 0) {
496
+ return {
497
+ action: "cron",
498
+ operation: "start",
499
+ ok: false,
500
+ error: "cron start requires every or at/once",
501
+ };
502
+ }
503
+
504
+ if (intervalMs > 0 && intervalMs < 1000) {
171
505
  return {
172
506
  action: "cron",
173
507
  operation: "start",
@@ -196,8 +530,9 @@ function createDaemonCronController(options = {}) {
196
530
  };
197
531
  }
198
532
 
199
- const task = scheduler.addTask({
533
+ const task = addTask({
200
534
  intervalMs,
535
+ onceAtMs,
201
536
  targets,
202
537
  prompt,
203
538
  });
@@ -215,14 +550,10 @@ function createDaemonCronController(options = {}) {
215
550
  action: "cron",
216
551
  operation: "start",
217
552
  ok: true,
218
- task: formatCronTask(task),
553
+ task,
219
554
  };
220
555
  }
221
556
 
222
- function stopAll() {
223
- return scheduler.stopAll();
224
- }
225
-
226
557
  return {
227
558
  handleCronOp,
228
559
  listTasks,
@@ -235,7 +566,9 @@ module.exports = {
235
566
  normalizeCronTargets,
236
567
  resolveCronOperation,
237
568
  resolveCronIntervalMs,
569
+ resolveCronOnceAtMs,
238
570
  resolveCronPrompt,
239
571
  resolveCronTaskId,
572
+ parseCronAtMs,
240
573
  formatCronTask,
241
574
  };
@@ -673,6 +673,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
673
673
  providerSessions = loadProviderSessionCache(projectRoot);
674
674
  probeHandles = new Map();
675
675
  daemonCronController = createDaemonCronController({
676
+ projectRoot,
676
677
  dispatch: async ({ taskId, target, message }) => {
677
678
  await dispatchMessages(projectRoot, [{ target, message }]);
678
679
  log(`cron:${taskId} -> ${target}`);
@@ -827,6 +828,69 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
827
828
  type: IPC_RESPONSE_TYPES.ERROR,
828
829
  error: err.message || "bus_send failed",
829
830
  })}
831
+ `,
832
+ );
833
+ }
834
+ return;
835
+ }
836
+ if (req.type === IPC_REQUEST_TYPES.CRON) {
837
+ if (!daemonCronController) {
838
+ socket.write(
839
+ `${JSON.stringify({
840
+ type: IPC_RESPONSE_TYPES.ERROR,
841
+ error: "cron controller unavailable",
842
+ })}
843
+ `,
844
+ );
845
+ return;
846
+ }
847
+
848
+ try {
849
+ const result = daemonCronController.handleCronOp(req);
850
+ let reply = "";
851
+ if (!result.ok) {
852
+ reply = `Cron failed: ${result.error || "unknown error"}`;
853
+ } else if (result.operation === "list") {
854
+ reply = result.count > 0
855
+ ? `Cron ${result.count} task(s)`
856
+ : "Cron none";
857
+ } else if (result.operation === "stop") {
858
+ if (result.id === "all") {
859
+ reply = `Stopped ${result.stopped || 0} cron task(s)`;
860
+ } else {
861
+ reply = `Stopped cron task ${result.id}`;
862
+ }
863
+ } else if (result.operation === "start" && result.task) {
864
+ if (result.task.mode === "once") {
865
+ reply = `Cron scheduled ${result.task.id} at ${result.task.onceAt || result.task.onceAtMs}`;
866
+ } else {
867
+ reply = `Cron started ${result.task.id}: every ${result.task.interval || result.task.intervalMs}`;
868
+ }
869
+ } else {
870
+ reply = "Cron updated";
871
+ }
872
+
873
+ socket.write(
874
+ `${JSON.stringify({
875
+ type: IPC_RESPONSE_TYPES.RESPONSE,
876
+ data: {
877
+ reply,
878
+ cron: result,
879
+ ops: [{ action: "cron", operation: result.operation || String(req.operation || "") }],
880
+ },
881
+ })}
882
+ `,
883
+ );
884
+ ipcServer.sendToSockets({
885
+ type: IPC_RESPONSE_TYPES.STATUS,
886
+ data: buildRuntimeStatus(),
887
+ });
888
+ } catch (err) {
889
+ socket.write(
890
+ `${JSON.stringify({
891
+ type: IPC_RESPONSE_TYPES.ERROR,
892
+ error: err.message || "cron request failed",
893
+ })}
830
894
  `,
831
895
  );
832
896
  }
@@ -65,8 +65,11 @@ function normalizeCronTasks(raw = []) {
65
65
  const items = Array.isArray(raw) ? raw : [];
66
66
  return items.map((task) => ({
67
67
  id: String(task && task.id ? task.id : ""),
68
+ mode: String(task && task.mode ? task.mode : ((task && task.onceAtMs) ? "once" : "interval")),
68
69
  intervalMs: Number(task && task.intervalMs ? task.intervalMs : 0) || 0,
69
70
  interval: String(task && task.interval ? task.interval : ""),
71
+ onceAtMs: Number(task && task.onceAtMs ? task.onceAtMs : 0) || 0,
72
+ onceAt: String(task && task.onceAt ? task.onceAt : ""),
70
73
  targets: Array.isArray(task && task.targets) ? task.targets.slice() : [],
71
74
  prompt: String(task && task.prompt ? task.prompt : ""),
72
75
  summary: String(task && task.summary ? task.summary : ""),
@@ -151,7 +151,7 @@ class OnlineConnect {
151
151
  this.projectRoot = options.projectRoot || process.cwd();
152
152
  this.nickname = options.nickname || "";
153
153
  this.subscriberId = options.subscriberId || autoSubscriberId(this.nickname);
154
- this.url = options.url || "ws://127.0.0.1:8787/ufoo/online";
154
+ this.url = options.url || "wss://online.ufoo.dev/ufoo/online";
155
155
  this.world = options.world || "default";
156
156
  this.agentType = options.agentType || "ufoo";
157
157
  this.tokenFile = options.tokenFile || "";
@@ -20,7 +20,7 @@ function waitForOpen(ws, timeoutMs = 5000) {
20
20
  class OnlineClient extends EventEmitter {
21
21
  constructor(options = {}) {
22
22
  super();
23
- this.url = options.url || "ws://127.0.0.1:8787/ufoo/online";
23
+ this.url = options.url || "wss://online.ufoo.dev/ufoo/online";
24
24
  this.subscriberId = options.subscriberId || "";
25
25
  this.nickname = options.nickname || "";
26
26
  this.world = options.world || "default";
@@ -3,6 +3,7 @@
3
3
  const IPC_REQUEST_TYPES = {
4
4
  STATUS: "status",
5
5
  PROMPT: "prompt",
6
+ CRON: "cron",
6
7
  BUS_SEND: "bus_send",
7
8
  CLOSE_AGENT: "close_agent",
8
9
  LAUNCH_AGENT: "launch_agent",