@nanhara/hara 0.122.2 → 0.122.4

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 (64) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +15 -2
  3. package/dist/agent/limits.js +51 -0
  4. package/dist/agent/loop.js +367 -112
  5. package/dist/agent/repeat-guard.js +49 -12
  6. package/dist/checkpoints.js +9 -0
  7. package/dist/cli.js +2 -1
  8. package/dist/config.js +10 -2
  9. package/dist/context/agents-md.js +21 -2
  10. package/dist/context/mentions.js +84 -1
  11. package/dist/context/workspace-scope.js +49 -0
  12. package/dist/cron/deliver.js +61 -38
  13. package/dist/cron/runner.js +423 -65
  14. package/dist/cron/schedule.js +23 -7
  15. package/dist/cron/store.js +709 -43
  16. package/dist/fs-walk.js +279 -7
  17. package/dist/fs-write.js +9 -0
  18. package/dist/gateway/feishu.js +351 -64
  19. package/dist/gateway/flows-pending.js +31 -17
  20. package/dist/gateway/flows.js +306 -31
  21. package/dist/gateway/outbound-files.js +20 -6
  22. package/dist/gateway/runtime-state.js +1485 -0
  23. package/dist/gateway/serve.js +550 -242
  24. package/dist/gateway/sessions.js +3 -3
  25. package/dist/gateway/telegram.js +279 -29
  26. package/dist/gateway/tts.js +182 -38
  27. package/dist/hooks.js +22 -22
  28. package/dist/index.js +466 -158
  29. package/dist/memory/store.js +8 -5
  30. package/dist/org/planner.js +11 -6
  31. package/dist/org/projects.js +3 -3
  32. package/dist/process-identity.js +52 -0
  33. package/dist/providers/bounded-turn.js +42 -0
  34. package/dist/recall.js +69 -1
  35. package/dist/runtime.js +24 -0
  36. package/dist/sandbox.js +54 -4
  37. package/dist/search/embed.js +13 -2
  38. package/dist/search/hybrid.js +6 -3
  39. package/dist/search/semindex.js +87 -5
  40. package/dist/security/guardian.js +3 -15
  41. package/dist/security/permissions.js +11 -0
  42. package/dist/serve/server.js +70 -42
  43. package/dist/serve/sessions.js +4 -3
  44. package/dist/sync-sleep.js +46 -0
  45. package/dist/tools/agent.js +1 -1
  46. package/dist/tools/ask_user.js +5 -1
  47. package/dist/tools/builtin.js +5 -2
  48. package/dist/tools/codebase.js +67 -18
  49. package/dist/tools/computer.js +149 -68
  50. package/dist/tools/cron.js +72 -18
  51. package/dist/tools/edit.js +3 -2
  52. package/dist/tools/external_agent.js +66 -12
  53. package/dist/tools/memory.js +25 -7
  54. package/dist/tools/patch.js +11 -2
  55. package/dist/tools/registry.js +16 -1
  56. package/dist/tools/search.js +68 -9
  57. package/dist/tools/send.js +1 -1
  58. package/dist/tools/skill.js +1 -1
  59. package/dist/tools/task.js +3 -3
  60. package/dist/tools/web.js +43 -13
  61. package/dist/tui/App.js +93 -25
  62. package/dist/vision.js +5 -6
  63. package/package.json +2 -2
  64. package/runtime-bootstrap.cjs +22 -0
@@ -3,7 +3,22 @@
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
- import { chmodSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
6
+ import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, unlinkSync, utimesSync, writeFileSync, } from "node:fs";
7
+ import { cronMatches, parseCron, validTz } from "./schedule.js";
8
+ import { compareProcessIdentity, defaultProcessIdentity } from "../process-identity.js";
9
+ import { sleepSync } from "../sync-sleep.js";
10
+ /** Hard persistence bounds: outages must apply backpressure instead of growing jobs.json forever. */
11
+ export const MAX_CRON_PENDING_NOTIFICATIONS = 64;
12
+ const CRON_RUN_NOTIFICATION_RESERVE = 2;
13
+ const MAX_CRON_JOBS = 4_096;
14
+ export const MAX_CRON_STORE_BYTES = 32 * 1024 * 1024;
15
+ export class CronStoreCorruptError extends Error {
16
+ code = "HARA_CRON_STORE_CORRUPT";
17
+ constructor(detail) {
18
+ super(`cron job store is invalid (${detail}); refusing to overwrite it. Inspect ${jobsPath()} and ${join(cronDir(), "store-error.log")}`);
19
+ this.name = "CronStoreCorruptError";
20
+ }
21
+ }
7
22
  export function cronDir() {
8
23
  return join(homedir(), ".hara", "cron");
9
24
  }
@@ -13,40 +28,471 @@ export function jobsPath() {
13
28
  export function logPath(id) {
14
29
  return join(cronDir(), "logs", `${id}.log`);
15
30
  }
31
+ const STORE_LOCK_ATTEMPTS = 200;
32
+ const STORE_LOCK_WAIT_MS = 10;
33
+ const STORE_MALFORMED_POISON_MS = 500;
34
+ // Store mutations are synchronous and normally hold the lock for milliseconds. A generous 30-second lease
35
+ // recovers a crashed lock whose PID has since been reused; the old owner is fenced at the commit guard below.
36
+ const STORE_LOCK_LEASE_MS = 30_000;
37
+ const MAX_STORE_LOCK_BYTES = 512;
38
+ function storeLockPath() {
39
+ return join(cronDir(), ".jobs.lock");
40
+ }
41
+ function readStoreLockSnapshot(path) {
42
+ try {
43
+ const before = lstatSync(path);
44
+ let raw = null;
45
+ if (before.isFile() && before.size <= MAX_STORE_LOCK_BYTES) {
46
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
47
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
48
+ try {
49
+ const opened = fstatSync(fd);
50
+ if (opened.isFile() && opened.dev === before.dev && opened.ino === before.ino && opened.size <= MAX_STORE_LOCK_BYTES) {
51
+ const buffer = Buffer.alloc(opened.size);
52
+ let offset = 0;
53
+ while (offset < buffer.length) {
54
+ const bytes = readSync(fd, buffer, offset, buffer.length - offset, offset);
55
+ if (!bytes)
56
+ break;
57
+ offset += bytes;
58
+ }
59
+ raw = buffer.subarray(0, offset).toString("utf8");
60
+ }
61
+ }
62
+ finally {
63
+ closeSync(fd);
64
+ }
65
+ }
66
+ const after = lstatSync(path);
67
+ if (before.dev !== after.dev
68
+ || before.ino !== after.ino
69
+ || before.size !== after.size
70
+ || before.mtimeMs !== after.mtimeMs
71
+ || before.ctimeMs !== after.ctimeMs)
72
+ return null;
73
+ return { raw, dev: after.dev, ino: after.ino, size: after.size, mtimeMs: after.mtimeMs, ctimeMs: after.ctimeMs };
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ function parseStoreLock(snapshot) {
80
+ if (snapshot?.raw === null || snapshot?.raw === undefined)
81
+ return null;
82
+ try {
83
+ const parsed = JSON.parse(snapshot.raw);
84
+ return Number.isSafeInteger(parsed?.pid) && parsed.pid > 0
85
+ && typeof parsed?.token === "string" && parsed.token.length > 0 && parsed.token.length <= 128
86
+ && (parsed.birthIdentity === undefined
87
+ || (typeof parsed.birthIdentity === "string" && /^[\x20-\x7e]{1,256}$/.test(parsed.birthIdentity)))
88
+ ? {
89
+ pid: parsed.pid,
90
+ token: parsed.token,
91
+ ...(parsed.birthIdentity ? { birthIdentity: parsed.birthIdentity } : {}),
92
+ }
93
+ : null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ function readStoreLock(path) {
100
+ return parseStoreLock(readStoreLockSnapshot(path));
101
+ }
102
+ function sameStoreLock(left, right) {
103
+ return !!right
104
+ && left.raw === right.raw
105
+ && left.dev === right.dev
106
+ && left.ino === right.ino
107
+ && left.size === right.size
108
+ && left.mtimeMs === right.mtimeMs
109
+ && left.ctimeMs === right.ctimeMs;
110
+ }
111
+ function malformedStoreLockStale(snapshot, now = Date.now()) {
112
+ return !parseStoreLock(snapshot) && now - snapshot.mtimeMs >= STORE_MALFORMED_POISON_MS;
113
+ }
114
+ function validStoreLockLeaseExpired(snapshot, now = Date.now()) {
115
+ return !!parseStoreLock(snapshot) && now - snapshot.mtimeMs >= STORE_LOCK_LEASE_MS;
116
+ }
117
+ function processAlive(pid) {
118
+ try {
119
+ process.kill(pid, 0);
120
+ return true;
121
+ }
122
+ catch (error) {
123
+ return error?.code === "EPERM";
124
+ }
125
+ }
126
+ const CURRENT_PROCESS_BIRTH_IDENTITY = defaultProcessIdentity(process.pid);
127
+ function newStoreLockRecord() {
128
+ const birthIdentity = CURRENT_PROCESS_BIRTH_IDENTITY;
129
+ return {
130
+ pid: process.pid,
131
+ token: randomUUID(),
132
+ ...(birthIdentity ? { birthIdentity } : {}),
133
+ };
134
+ }
135
+ function storeLockOwnerAlive(record) {
136
+ if (!processAlive(record.pid))
137
+ return false;
138
+ if (!record.birthIdentity)
139
+ return true;
140
+ const current = record.pid === process.pid ? CURRENT_PROCESS_BIRTH_IDENTITY : defaultProcessIdentity(record.pid);
141
+ return compareProcessIdentity(record.birthIdentity, current) !== "different";
142
+ }
143
+ function writeStoreLockExclusive(path, record) {
144
+ // Never expose a partial lock record at the contested pathname. A process may be suspended indefinitely
145
+ // between open and write; publish the fully written+fsynced inode with an atomic create-if-absent link.
146
+ const staging = `${path}.${process.pid}.${randomUUID()}.tmp`;
147
+ let fd;
148
+ try {
149
+ fd = openSync(staging, "wx", 0o600);
150
+ writeFileSync(fd, JSON.stringify(record), "utf8");
151
+ fsyncSync(fd);
152
+ closeSync(fd);
153
+ fd = undefined;
154
+ linkSync(staging, path);
155
+ }
156
+ finally {
157
+ if (fd !== undefined)
158
+ closeSync(fd);
159
+ try {
160
+ unlinkSync(staging);
161
+ }
162
+ catch { /* a crash may leave only an inert, uniquely named staging file */ }
163
+ }
164
+ }
165
+ function removeOwnedStoreLock(path, owner) {
166
+ const current = readStoreLock(path);
167
+ if (current?.pid === owner.pid && current.token === owner.token)
168
+ rmSync(path, { force: true });
169
+ }
170
+ /** A valid reclaim guard protects the tiny verify+commit critical section. New records use OS birth identity
171
+ * to distinguish a live owner from PID reuse. A live legacy identity-less guard fails closed: age alone cannot
172
+ * fence an owner suspended after its final token check but before rename, and this lock format was unpublished. */
173
+ function clearDeadOrPoisonedStoreGuard(path) {
174
+ const observed = readStoreLockSnapshot(path);
175
+ if (!observed)
176
+ return true;
177
+ const record = parseStoreLock(observed);
178
+ const reclaimable = record ? !storeLockOwnerAlive(record) : malformedStoreLockStale(observed);
179
+ if (!reclaimable)
180
+ return false;
181
+ const current = readStoreLockSnapshot(path);
182
+ const currentRecord = parseStoreLock(current);
183
+ const stillReclaimable = record
184
+ ? currentRecord?.pid === record.pid
185
+ && currentRecord.token === record.token
186
+ && !!current
187
+ && !storeLockOwnerAlive(currentRecord)
188
+ : !!current && malformedStoreLockStale(current);
189
+ if (!sameStoreLock(observed, current) || !stillReclaimable)
190
+ return false;
191
+ rmSync(path, { force: true });
192
+ return true;
193
+ }
194
+ function withStoreCommitFence(lock, reclaim, claim, fn) {
195
+ let guard;
196
+ for (let attempt = 0; attempt < STORE_LOCK_ATTEMPTS; attempt++) {
197
+ if (!clearDeadOrPoisonedStoreGuard(reclaim)) {
198
+ sleepSync(STORE_LOCK_WAIT_MS);
199
+ continue;
200
+ }
201
+ const candidate = newStoreLockRecord();
202
+ try {
203
+ writeStoreLockExclusive(reclaim, candidate);
204
+ guard = candidate;
205
+ break;
206
+ }
207
+ catch (error) {
208
+ if (error?.code !== "EEXIST")
209
+ throw error;
210
+ }
211
+ sleepSync(STORE_LOCK_WAIT_MS);
212
+ }
213
+ if (!guard)
214
+ throw new Error("cron job store commit fence is busy; retry the operation");
215
+ try {
216
+ const currentGuard = readStoreLock(reclaim);
217
+ if (currentGuard?.pid !== guard.pid || currentGuard.token !== guard.token) {
218
+ throw new Error("cron job store commit guard was replaced before commit; retry the operation");
219
+ }
220
+ const current = readStoreLock(lock);
221
+ if (current?.pid !== claim.pid || current.token !== claim.token) {
222
+ throw new Error("cron job store lease was replaced before commit; retry the operation");
223
+ }
224
+ // Renew both names immediately before the synchronous namespace commit. A stale-owner reaper that
225
+ // observed either snapshot sees this renewal or loses its snapshot CAS; a resumed owner whose guard was
226
+ // already replaced fails above before it can publish stale jobs.json contents.
227
+ const now = new Date();
228
+ utimesSync(reclaim, now, now);
229
+ utimesSync(lock, now, now);
230
+ return fn();
231
+ }
232
+ finally {
233
+ removeOwnedStoreLock(reclaim, guard);
234
+ }
235
+ }
236
+ /** All cron read-modify-write operations share this bounded O_EXCL mutex. Dead owners are reclaimed only
237
+ * under a second exclusive guard and after token revalidation, so a reaper can never unlink a successor. */
238
+ function withStoreLock(fn) {
239
+ mkdirSync(cronDir(), { recursive: true, mode: 0o700 });
240
+ const lock = storeLockPath();
241
+ const reclaim = `${lock}.reclaim`;
242
+ let claim;
243
+ for (let attempt = 0; attempt < STORE_LOCK_ATTEMPTS; attempt++) {
244
+ const reclaimObserved = readStoreLockSnapshot(reclaim);
245
+ if (reclaimObserved) {
246
+ if (clearDeadOrPoisonedStoreGuard(reclaim))
247
+ continue;
248
+ sleepSync(STORE_LOCK_WAIT_MS);
249
+ continue;
250
+ }
251
+ const candidate = newStoreLockRecord();
252
+ try {
253
+ writeStoreLockExclusive(lock, candidate);
254
+ // A reaper may have installed its guard after our preflight but before link(2). Never enter a
255
+ // transaction under that guard: remove only our token and retry after the transition completes.
256
+ if (existsSync(reclaim)) {
257
+ removeOwnedStoreLock(lock, candidate);
258
+ }
259
+ else {
260
+ claim = candidate;
261
+ break;
262
+ }
263
+ }
264
+ catch (error) {
265
+ if (error?.code !== "EEXIST")
266
+ throw error;
267
+ }
268
+ const observed = readStoreLockSnapshot(lock);
269
+ const held = parseStoreLock(observed);
270
+ if (observed && ((held && (!storeLockOwnerAlive(held) || validStoreLockLeaseExpired(observed))) || malformedStoreLockStale(observed))) {
271
+ const guard = newStoreLockRecord();
272
+ try {
273
+ writeStoreLockExclusive(reclaim, guard);
274
+ const current = readStoreLockSnapshot(lock);
275
+ const currentRecord = parseStoreLock(current);
276
+ const stillReclaimable = held
277
+ ? currentRecord?.pid === held.pid
278
+ && currentRecord.token === held.token
279
+ && !!current
280
+ && (!storeLockOwnerAlive(currentRecord) || validStoreLockLeaseExpired(current))
281
+ : !!current && malformedStoreLockStale(current);
282
+ if (sameStoreLock(observed, current) && stillReclaimable)
283
+ rmSync(lock);
284
+ }
285
+ catch {
286
+ // Another writer won reclamation, or the evidence is malformed. Never fail open.
287
+ }
288
+ finally {
289
+ const currentGuard = readStoreLock(reclaim);
290
+ if (currentGuard?.pid === process.pid && currentGuard.token === guard.token)
291
+ rmSync(reclaim, { force: true });
292
+ }
293
+ }
294
+ sleepSync(STORE_LOCK_WAIT_MS);
295
+ }
296
+ if (!claim)
297
+ throw new Error("cron job store is busy; retry the operation");
298
+ const lease = {
299
+ commit: (commit) => withStoreCommitFence(lock, reclaim, claim, commit),
300
+ };
301
+ try {
302
+ return fn(lease);
303
+ }
304
+ finally {
305
+ removeOwnedStoreLock(lock, claim);
306
+ }
307
+ }
308
+ function validFinite(value) {
309
+ return typeof value === "number" && Number.isFinite(value);
310
+ }
311
+ function validSchedule(value) {
312
+ if (!value || typeof value !== "object")
313
+ return false;
314
+ const schedule = value;
315
+ if (schedule.kind === "cron")
316
+ return typeof schedule.expr === "string" && !!parseCron(schedule.expr);
317
+ if (schedule.kind === "every")
318
+ return validFinite(schedule.everyMs) && schedule.everyMs > 0 && typeof schedule.display === "string";
319
+ return schedule.kind === "once" && validFinite(schedule.runAt) && typeof schedule.display === "string";
320
+ }
321
+ function validPendingNotification(value) {
322
+ if (!value || typeof value !== "object" || Array.isArray(value))
323
+ return false;
324
+ const notification = value;
325
+ return typeof notification.id === "string" && notification.id.length > 0 && notification.id.length <= 256
326
+ && (notification.kind === "outcome" || notification.kind === "alert")
327
+ && typeof notification.target === "string" && notification.target.length > 0 && notification.target.length <= 4_096
328
+ && typeof notification.text === "string" && notification.text.length <= 8_192
329
+ && validFinite(notification.createdAt)
330
+ && typeof notification.attempts === "number" && Number.isInteger(notification.attempts)
331
+ && notification.attempts >= 0 && notification.attempts <= Number.MAX_SAFE_INTEGER
332
+ && validFinite(notification.nextAttemptAt)
333
+ && (notification.lastError === undefined || (typeof notification.lastError === "string" && notification.lastError.length <= 4_096));
334
+ }
335
+ function validPendingNotifications(value) {
336
+ if (value === undefined)
337
+ return true;
338
+ if (!Array.isArray(value) || value.length > MAX_CRON_PENDING_NOTIFICATIONS)
339
+ return false;
340
+ const ids = new Set();
341
+ let alerts = 0;
342
+ for (const notification of value) {
343
+ if (!validPendingNotification(notification) || ids.has(notification.id))
344
+ return false;
345
+ ids.add(notification.id);
346
+ if (notification.kind === "alert" && ++alerts > 1)
347
+ return false;
348
+ }
349
+ return true;
350
+ }
351
+ function validCronJob(value) {
352
+ if (!value || typeof value !== "object" || Array.isArray(value))
353
+ return false;
354
+ const job = value;
355
+ return typeof job.id === "string" && !!job.id && job.id.length <= 128
356
+ && typeof job.name === "string" && job.name.length <= 1_000
357
+ && validSchedule(job.schedule)
358
+ && typeof job.task === "string"
359
+ && (job.mode === "print" || job.mode === "org" || job.mode === "command")
360
+ && typeof job.cwd === "string" && !!job.cwd
361
+ && typeof job.enabled === "boolean"
362
+ && validFinite(job.createdAt)
363
+ && (job.pendingDueAt === undefined || (job.schedule.kind === "cron" && validFinite(job.pendingDueAt)))
364
+ && (job.tz === undefined || (typeof job.tz === "string" && validTz(job.tz)))
365
+ && (job.deliver === undefined || (typeof job.deliver === "string" && job.deliver.length > 0 && job.deliver.length <= 4_096))
366
+ && (job.alertAfter === undefined || (Number.isInteger(job.alertAfter) && job.alertAfter >= 1 && job.alertAfter <= 1_000))
367
+ && (job.lastRunAt === undefined || validFinite(job.lastRunAt))
368
+ && (job.runningSince === undefined || validFinite(job.runningSince))
369
+ && (job.runningPid === undefined || (Number.isInteger(job.runningPid) && job.runningPid > 0))
370
+ && (job.runningToken === undefined || (typeof job.runningToken === "string" && job.runningToken.length > 0 && job.runningToken.length <= 128))
371
+ && (job.lastDurationMs === undefined || validFinite(job.lastDurationMs))
372
+ && (job.lastError === undefined || typeof job.lastError === "string")
373
+ && (job.consecutiveErrors === undefined || (Number.isInteger(job.consecutiveErrors) && job.consecutiveErrors >= 0))
374
+ && (job.lastAlertAt === undefined || validFinite(job.lastAlertAt))
375
+ && validPendingNotifications(job.pendingNotifications)
376
+ && (job.lastStatus === undefined || ["ok", "error", "running", "timed_out"].includes(job.lastStatus));
377
+ }
378
+ function recordStoreError(detail) {
379
+ try {
380
+ mkdirSync(cronDir(), { recursive: true, mode: 0o700 });
381
+ const path = join(cronDir(), "store-error.log");
382
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
383
+ writeFileSync(temp, `${new Date().toISOString()} ${detail}\n`, { mode: 0o600 });
384
+ renameSync(temp, path);
385
+ try {
386
+ chmodSync(path, 0o600);
387
+ }
388
+ catch { /* best effort */ }
389
+ }
390
+ catch {
391
+ // Diagnostics must never turn a parse failure into destructive recovery.
392
+ }
393
+ }
16
394
  export function loadJobs() {
17
395
  const p = jobsPath();
18
396
  if (!existsSync(p))
19
397
  return [];
398
+ const snapshot = lstatSync(p);
399
+ if (!snapshot.isFile() || snapshot.size > MAX_CRON_STORE_BYTES) {
400
+ const detail = !snapshot.isFile()
401
+ ? "jobs.json is not a regular file"
402
+ : `jobs.json exceeds the ${MAX_CRON_STORE_BYTES}-byte safety limit`;
403
+ recordStoreError(detail);
404
+ throw new CronStoreCorruptError(detail);
405
+ }
406
+ let parsed;
20
407
  try {
21
- const j = JSON.parse(readFileSync(p, "utf8"));
22
- return Array.isArray(j) ? j : [];
408
+ parsed = JSON.parse(readFileSync(p, "utf8"));
23
409
  }
24
410
  catch {
25
- return [];
411
+ const detail = "JSON could not be parsed";
412
+ recordStoreError(detail);
413
+ throw new CronStoreCorruptError(detail);
414
+ }
415
+ if (!Array.isArray(parsed)) {
416
+ const detail = "top level is not an array";
417
+ recordStoreError(detail);
418
+ throw new CronStoreCorruptError(detail);
26
419
  }
420
+ if (parsed.length > MAX_CRON_JOBS) {
421
+ const detail = `top level exceeds the ${MAX_CRON_JOBS}-job safety limit`;
422
+ recordStoreError(detail);
423
+ throw new CronStoreCorruptError(detail);
424
+ }
425
+ const ids = new Set();
426
+ for (let index = 0; index < parsed.length; index++) {
427
+ const job = parsed[index];
428
+ if (!validCronJob(job)) {
429
+ const detail = `entry ${index + 1} has an invalid schema`;
430
+ recordStoreError(detail);
431
+ throw new CronStoreCorruptError(detail);
432
+ }
433
+ if (ids.has(job.id)) {
434
+ const detail = `entry ${index + 1} duplicates job id ${job.id}`;
435
+ recordStoreError(detail);
436
+ throw new CronStoreCorruptError(detail);
437
+ }
438
+ ids.add(job.id);
439
+ }
440
+ return parsed;
27
441
  }
28
- /** Persist the job list with an atomic temp-write + rename (never leaves a half-written jobs.json). */
29
- export function saveJobs(jobs) {
442
+ /** Persist while the caller owns the store lock. The final rename is fenced against an expired owner that
443
+ * resumes after another process reclaimed its primary lease. */
444
+ function saveJobsUnlocked(jobs, lease) {
30
445
  mkdirSync(cronDir(), { recursive: true, mode: 0o700 });
31
446
  try {
32
447
  chmodSync(cronDir(), 0o700);
33
448
  }
34
449
  catch { /* best effort */ }
450
+ if (jobs.length > MAX_CRON_JOBS || jobs.some((job) => !validCronJob(job))) {
451
+ throw new Error("refusing to persist cron jobs outside the bounded store schema");
452
+ }
453
+ const serialized = JSON.stringify(jobs, null, 2) + "\n";
454
+ if (Buffer.byteLength(serialized, "utf8") > MAX_CRON_STORE_BYTES) {
455
+ throw new Error(`refusing to persist cron jobs larger than ${MAX_CRON_STORE_BYTES} bytes`);
456
+ }
35
457
  const p = jobsPath();
36
- const tmp = `${p}.${process.pid}.tmp`;
37
- writeFileSync(tmp, JSON.stringify(jobs, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
38
- renameSync(tmp, p);
458
+ const tmp = `${p}.${process.pid}.${randomUUID()}.tmp`;
459
+ try {
460
+ writeFileSync(tmp, serialized, { encoding: "utf8", mode: 0o600 });
461
+ lease.commit(() => renameSync(tmp, p));
462
+ }
463
+ finally {
464
+ rmSync(tmp, { force: true });
465
+ }
39
466
  try {
40
467
  chmodSync(p, 0o600);
41
468
  }
42
469
  catch { /* best effort */ }
43
470
  }
471
+ /** Replace the complete list under the same mutex used by granular mutations. Prefer add/remove/set APIs
472
+ * for concurrent callers; this is retained for migrations and explicit whole-store replacement. */
473
+ export function saveJobs(jobs) {
474
+ withStoreLock((lease) => saveJobsUnlocked(jobs, lease));
475
+ }
476
+ function mutateJobs(mutate) {
477
+ return withStoreLock((lease) => {
478
+ const jobs = loadJobs();
479
+ const result = mutate(jobs);
480
+ saveJobsUnlocked(jobs, lease);
481
+ return result;
482
+ });
483
+ }
44
484
  export function addJob(j) {
45
- const jobs = loadJobs();
46
- const job = { id: randomUUID().slice(0, 8), enabled: j.enabled ?? true, ...j };
47
- jobs.push(job);
48
- saveJobs(jobs);
49
- return job;
485
+ return mutateJobs((jobs) => {
486
+ const enabled = j.enabled ?? true;
487
+ const job = { ...j, id: randomUUID().slice(0, 8), enabled };
488
+ delete job.pendingDueAt;
489
+ delete job.pendingNotifications;
490
+ if (enabled && job.schedule.kind === "cron" && cronMatches(job.schedule.expr, new Date(job.createdAt), job.tz)) {
491
+ job.pendingDueAt = job.createdAt;
492
+ }
493
+ jobs.push(job);
494
+ return job;
495
+ });
50
496
  }
51
497
  /** Resolve an id or unique id-prefix to a single job. Exact id wins; otherwise the prefix must match
52
498
  * EXACTLY one job — an ambiguous prefix returns "ambiguous" (never silently picks one, since callers
@@ -66,40 +512,260 @@ export function findJob(idOrPrefix) {
66
512
  }
67
513
  /** Delete a job by EXACT id (callers resolve the prefix first via resolveJob). */
68
514
  export function removeJob(id) {
69
- const jobs = loadJobs();
70
- if (!jobs.some((x) => x.id === id))
71
- return false;
72
- saveJobs(jobs.filter((x) => x.id !== id));
73
- return true;
515
+ return mutateJobs((jobs) => {
516
+ const index = jobs.findIndex((x) => x.id === id);
517
+ if (index < 0)
518
+ return false;
519
+ jobs.splice(index, 1);
520
+ return true;
521
+ });
74
522
  }
75
523
  /** Enable/disable a job by EXACT id. */
76
524
  export function setEnabled(id, on) {
77
- const jobs = loadJobs();
78
- const job = jobs.find((x) => x.id === id);
79
- if (!job)
80
- return false;
81
- job.enabled = on;
82
- saveJobs(jobs);
83
- return true;
525
+ return mutateJobs((jobs) => {
526
+ const job = jobs.find((x) => x.id === id);
527
+ if (!job)
528
+ return false;
529
+ job.enabled = on;
530
+ // Disabling explicitly abandons an unconsumed creation-minute occurrence. Enabling later must resume
531
+ // from the schedule at that time, never replay a stale trigger inferred from job creation.
532
+ if (!on)
533
+ delete job.pendingDueAt;
534
+ return true;
535
+ });
84
536
  }
85
- /** Record a run's outcome (and stamp lastRunAt) for the given job. */
86
- export function recordRun(id, at, status, error) {
87
- const jobs = loadJobs();
88
- const job = jobs.find((x) => x.id === id);
89
- if (!job)
90
- return;
91
- job.lastRunAt = at;
92
- job.lastStatus = status;
93
- job.lastError = error;
94
- job.consecutiveErrors = status === "error" ? (job.consecutiveErrors ?? 0) + 1 : 0;
95
- saveJobs(jobs);
537
+ /** Persist a run before any child process/provider work starts. */
538
+ export function recordRunStart(id, at, requireEnabled = false) {
539
+ return mutateJobs((jobs) => {
540
+ const job = jobs.find((x) => x.id === id);
541
+ if (!job || (requireEnabled && !job.enabled))
542
+ return null;
543
+ // Never overwrite an unresolved attempt merely because its recorded parent PID is gone. The parent may
544
+ // have launched a detached child before crashing, so a second manual run could duplicate file/network
545
+ // side effects. Scheduler ticks call `recoverInterruptedRuns` first; manual callers receive a focused
546
+ // diagnostic and must explicitly retry only after that recovery has disabled the ambiguous attempt.
547
+ if (job.lastStatus === "running")
548
+ return null;
549
+ const pendingCount = job.pendingNotifications?.length ?? 0;
550
+ if (pendingCount > MAX_CRON_PENDING_NOTIFICATIONS - CRON_RUN_NOTIFICATION_RESERVE) {
551
+ const error = `cron delivery backlog has ${pendingCount}/${MAX_CRON_PENDING_NOTIFICATIONS} pending notifications; job disabled before launch to keep jobs.json bounded — restore delivery, let the queue drain, then re-enable the job`;
552
+ job.enabled = false;
553
+ delete job.pendingDueAt;
554
+ job.lastStatus = "error";
555
+ delete job.lastDurationMs;
556
+ job.lastError = error;
557
+ const alertAlreadyPending = job.pendingNotifications?.some((notification) => notification.kind === "alert") ?? false;
558
+ if (job.deliver && !alertAlreadyPending) {
559
+ enqueueNotification(job, "alert", `🚨 ${job.name} was disabled before launch: ${error}`, at, `delivery-backlog-${Math.trunc(at)}`);
560
+ }
561
+ return null;
562
+ }
563
+ const token = randomUUID();
564
+ job.lastRunAt = at;
565
+ delete job.pendingDueAt;
566
+ job.lastStatus = "running";
567
+ job.runningSince = at;
568
+ job.runningPid = process.pid;
569
+ job.runningToken = token;
570
+ delete job.lastDurationMs;
571
+ delete job.lastError;
572
+ return token;
573
+ });
574
+ }
575
+ const ALERT_COOLDOWN_MS = 6 * 3_600_000;
576
+ export const MAX_CRON_RUNNING_AGE_MS = 24 * 60 * 60_000 + 5 * 60_000;
577
+ function notificationText(value, max) {
578
+ return String(value ?? "").replace(/\0/g, "").slice(0, max);
579
+ }
580
+ function enqueueNotification(job, kind, text, nowMs, attemptKey) {
581
+ const id = `cron:${job.id}:${attemptKey}:${kind}`;
582
+ const pending = (job.pendingNotifications ??= []);
583
+ if (pending.some((notification) => notification.id === id))
584
+ return id;
585
+ let compacted = false;
586
+ if (pending.length >= MAX_CRON_PENDING_NOTIFICATIONS) {
587
+ // Alerts are never discarded. Under the validated schema there is at most one, so a full queue always
588
+ // has an older outcome that can be represented by an explicit compaction marker on the new critical fact.
589
+ const sameAttemptOutcome = kind === "alert" ? `cron:${job.id}:${attemptKey}:outcome` : undefined;
590
+ let oldestOutcome = -1;
591
+ for (let index = 0; index < pending.length; index++) {
592
+ if (pending[index].kind !== "outcome" || pending[index].id === sameAttemptOutcome)
593
+ continue;
594
+ if (oldestOutcome < 0 || pending[index].createdAt < pending[oldestOutcome].createdAt)
595
+ oldestOutcome = index;
596
+ }
597
+ if (oldestOutcome < 0)
598
+ return null;
599
+ pending.splice(oldestOutcome, 1);
600
+ compacted = true;
601
+ }
602
+ const safeText = compacted
603
+ ? `⚠️ Delivery backlog reached ${MAX_CRON_PENDING_NOTIFICATIONS}; one older outcome was compacted without delivery.\n${text}`
604
+ : text;
605
+ pending.push({
606
+ id,
607
+ kind,
608
+ target: job.deliver,
609
+ text: notificationText(safeText, 8_192),
610
+ createdAt: nowMs,
611
+ attempts: 0,
612
+ nextAttemptAt: nowMs,
613
+ });
614
+ return id;
615
+ }
616
+ /** Queue outcome + threshold alarm while already under the jobs transaction. `lastAlertAt` is deliberately
617
+ * NOT changed here: cooldown begins only after the alert transport is confirmed and acknowledged. */
618
+ function enqueueOutcomeForJob(job, result, nowMs, attemptKey) {
619
+ if (!job.deliver)
620
+ return [];
621
+ const queued = [];
622
+ const snippet = notificationText(result.output, 1_500).trim();
623
+ const safeError = notificationText(result.error || "failed", 1_000);
624
+ const head = result.ok ? `⏰ ${job.name} ✓` : `⏰ ${job.name} ✗ ${safeError}`;
625
+ const outcomeId = enqueueNotification(job, "outcome", snippet ? `${head}\n${snippet}` : head, nowMs, attemptKey);
626
+ if (outcomeId)
627
+ queued.push(outcomeId);
628
+ if (!result.ok) {
629
+ const count = job.consecutiveErrors ?? 0;
630
+ const threshold = job.alertAfter ?? 3;
631
+ const cooled = !job.lastAlertAt || nowMs - job.lastAlertAt > ALERT_COOLDOWN_MS;
632
+ const alertAlreadyPending = job.pendingNotifications?.some((notification) => notification.kind === "alert") ?? false;
633
+ if (count >= threshold && cooled && !alertAlreadyPending) {
634
+ const alertId = enqueueNotification(job, "alert", `🚨 ${job.name} has failed ${count}× in a row — latest: ${safeError}. Log: ${logPath(job.id)}`, nowMs, attemptKey);
635
+ if (alertId)
636
+ queued.push(alertId);
637
+ }
638
+ }
639
+ return queued;
640
+ }
641
+ /** Compatibility/public helper for callers that recorded state separately. Production tick code passes the
642
+ * outcome to `recordRun`, making state + delivery intent one atomic jobs.json commit. */
643
+ export function enqueueOutcomeNotifications(id, result, nowMs = Date.now(), attemptKey = randomUUID()) {
644
+ return mutateJobs((jobs) => {
645
+ const job = jobs.find((candidate) => candidate.id === id);
646
+ return job ? enqueueOutcomeForJob(job, result, nowMs, attemptKey) : [];
647
+ });
648
+ }
649
+ /** Record a run's terminal outcome. Timeouts are errors for streak/alert purposes but stay distinguishable
650
+ * in persisted state and `cron list`. `at` is the finish time; lastRunAt remains the actual start. When
651
+ * `delivery` is supplied, terminal state and its transport effects commit atomically. */
652
+ export function recordRun(id, at, status, error, durationMs, runningToken, delivery) {
653
+ return mutateJobs((jobs) => {
654
+ const job = jobs.find((x) => x.id === id);
655
+ if (!job || (runningToken !== undefined && job.runningToken !== runningToken))
656
+ return false;
657
+ const attemptKey = runningToken ?? job.runningToken ?? randomUUID();
658
+ const startedAt = job.runningSince;
659
+ job.lastRunAt = startedAt ?? at;
660
+ job.lastStatus = status;
661
+ delete job.runningSince;
662
+ delete job.runningPid;
663
+ delete job.runningToken;
664
+ const measuredDuration = durationMs ?? (startedAt === undefined ? undefined : Math.max(0, at - startedAt));
665
+ if (measuredDuration === undefined)
666
+ delete job.lastDurationMs;
667
+ else
668
+ job.lastDurationMs = Math.max(0, Math.trunc(measuredDuration));
669
+ if (status === "ok" || !error)
670
+ delete job.lastError;
671
+ else
672
+ job.lastError = error;
673
+ job.consecutiveErrors = status === "ok" ? 0 : (job.consecutiveErrors ?? 0) + 1;
674
+ if (delivery)
675
+ enqueueOutcomeForJob(job, delivery, at, attemptKey);
676
+ return true;
677
+ });
678
+ }
679
+ /** Return due effects only, alert-first, with a hard item cap so a long outage cannot consume a whole tick. */
680
+ export function listPendingNotifications(nowMs = Date.now(), limit = 8, jobId) {
681
+ const bounded = Math.max(0, Math.min(64, Math.trunc(limit)));
682
+ if (!bounded)
683
+ return [];
684
+ return loadJobs()
685
+ .filter((job) => !jobId || job.id === jobId)
686
+ .flatMap((job) => (job.pendingNotifications ?? []).map((notification) => ({ ...notification, jobId: job.id })))
687
+ .filter((notification) => notification.nextAttemptAt <= nowMs)
688
+ .sort((left, right) => {
689
+ if (left.kind !== right.kind)
690
+ return left.kind === "alert" ? -1 : 1;
691
+ return left.createdAt - right.createdAt;
692
+ })
693
+ .slice(0, bounded);
694
+ }
695
+ /** Confirm one transport effect. Alert cooldown is recorded in the same commit that removes its pending
696
+ * record, so a crash cannot acknowledge delivery without also suppressing an immediate duplicate alert. */
697
+ export function acknowledgePendingNotification(jobId, notificationId, at = Date.now()) {
698
+ return mutateJobs((jobs) => {
699
+ const job = jobs.find((candidate) => candidate.id === jobId);
700
+ const pending = job?.pendingNotifications;
701
+ const index = pending?.findIndex((notification) => notification.id === notificationId) ?? -1;
702
+ if (!job || !pending || index < 0)
703
+ return false;
704
+ const [notification] = pending.splice(index, 1);
705
+ if (notification.kind === "alert")
706
+ job.lastAlertAt = at;
707
+ if (!pending.length)
708
+ delete job.pendingNotifications;
709
+ return true;
710
+ });
711
+ }
712
+ /** Persist an attempt failure and exponential backoff. The effect itself remains unchanged, including its
713
+ * stable idempotency key, until a later tick receives a confirmed transport success. */
714
+ export function deferPendingNotification(jobId, notificationId, error, at = Date.now()) {
715
+ return mutateJobs((jobs) => {
716
+ const notification = jobs
717
+ .find((candidate) => candidate.id === jobId)
718
+ ?.pendingNotifications?.find((candidate) => candidate.id === notificationId);
719
+ if (!notification)
720
+ return false;
721
+ notification.attempts = Math.min(Number.MAX_SAFE_INTEGER, notification.attempts + 1);
722
+ const exponent = Math.min(10, Math.max(0, notification.attempts - 1));
723
+ const delay = Math.min(6 * 3_600_000, 30_000 * (2 ** exponent));
724
+ notification.nextAttemptAt = at + delay;
725
+ notification.lastError = notificationText(error || "transport request failed", 4_096);
726
+ return true;
727
+ });
728
+ }
729
+ /** Close `running` records whose owner is gone OR whose age exceeds every supported job timeout plus grace.
730
+ * The age fence handles PID reuse and a live-but-wedged legacy parent. The attempt remains consumed and the
731
+ * job is disabled: a detached child may still exist, so recovery never kills or automatically replays it. */
732
+ export function recoverInterruptedRuns(at, jobId) {
733
+ return mutateJobs((jobs) => {
734
+ const recovered = [];
735
+ for (const job of jobs) {
736
+ if (jobId && job.id !== jobId)
737
+ continue;
738
+ if (job.lastStatus !== "running")
739
+ continue;
740
+ const startedAt = job.runningSince ?? job.lastRunAt ?? at;
741
+ const tooOld = at - startedAt >= MAX_CRON_RUNNING_AGE_MS;
742
+ if (job.runningPid && processAlive(job.runningPid) && !tooOld)
743
+ continue;
744
+ const attemptKey = job.runningToken ?? randomUUID();
745
+ const error = tooOld
746
+ ? "cron running state exceeded the 24h maximum job lifetime plus cleanup grace; job disabled because the recorded PID may be reused or its child may still be running — verify, then re-enable or run it manually"
747
+ : "previous Hara process exited before recording a terminal cron result; job disabled because an orphaned child may still be running — verify, then re-enable or run it manually";
748
+ job.lastRunAt = startedAt;
749
+ job.lastStatus = "error";
750
+ job.enabled = false;
751
+ delete job.pendingDueAt;
752
+ delete job.runningSince;
753
+ delete job.runningPid;
754
+ delete job.runningToken;
755
+ job.lastDurationMs = Math.max(0, Math.trunc(at - startedAt));
756
+ job.lastError = error;
757
+ job.consecutiveErrors = (job.consecutiveErrors ?? 0) + 1;
758
+ enqueueOutcomeForJob(job, { ok: false, error, output: "" }, at, attemptKey);
759
+ recovered.push({ job: { ...job }, error });
760
+ }
761
+ return recovered;
762
+ });
96
763
  }
97
764
  /** Stamp the failure-alert time (cooldown gate). */
98
765
  export function recordAlert(id, at) {
99
- const jobs = loadJobs();
100
- const job = jobs.find((x) => x.id === id);
101
- if (!job)
102
- return;
103
- job.lastAlertAt = at;
104
- saveJobs(jobs);
766
+ mutateJobs((jobs) => {
767
+ const job = jobs.find((x) => x.id === id);
768
+ if (job)
769
+ job.lastAlertAt = at;
770
+ });
105
771
  }