@namzu/sdk 44.3.0 → 45.1.0

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 (271) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/authorization/command-line.d.ts +66 -19
  3. package/dist/authorization/command-line.d.ts.map +1 -1
  4. package/dist/authorization/command-line.js +130 -270
  5. package/dist/authorization/command-line.js.map +1 -1
  6. package/dist/authorization/gate.d.ts +7 -0
  7. package/dist/authorization/gate.d.ts.map +1 -1
  8. package/dist/authorization/gate.js +13 -3
  9. package/dist/authorization/gate.js.map +1 -1
  10. package/dist/authorization/rules.d.ts +10 -1
  11. package/dist/authorization/rules.d.ts.map +1 -1
  12. package/dist/authorization/rules.js +55 -8
  13. package/dist/authorization/rules.js.map +1 -1
  14. package/dist/authorization/shell-lexer.d.ts +152 -0
  15. package/dist/authorization/shell-lexer.d.ts.map +1 -0
  16. package/dist/authorization/shell-lexer.js +2156 -0
  17. package/dist/authorization/shell-lexer.js.map +1 -0
  18. package/dist/authorization/skill-grant.d.ts +182 -0
  19. package/dist/authorization/skill-grant.d.ts.map +1 -0
  20. package/dist/authorization/skill-grant.js +314 -0
  21. package/dist/authorization/skill-grant.js.map +1 -0
  22. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  23. package/dist/bridge/a2a/mapper.js +2 -0
  24. package/dist/bridge/a2a/mapper.js.map +1 -1
  25. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  26. package/dist/bridge/sse/mapper.js +1 -0
  27. package/dist/bridge/sse/mapper.js.map +1 -1
  28. package/dist/directory/types.d.ts +2 -0
  29. package/dist/directory/types.d.ts.map +1 -1
  30. package/dist/directory/types.js.map +1 -1
  31. package/dist/manager/resident/outbox.d.ts +4 -4
  32. package/dist/persona/assembler.d.ts.map +1 -1
  33. package/dist/persona/assembler.js +5 -2
  34. package/dist/persona/assembler.js.map +1 -1
  35. package/dist/prompt/coding-agent-doctrine.d.ts +1 -1
  36. package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
  37. package/dist/prompt/coding-agent-doctrine.js +1 -0
  38. package/dist/prompt/coding-agent-doctrine.js.map +1 -1
  39. package/dist/public-runtime.d.ts +5 -1
  40. package/dist/public-runtime.d.ts.map +1 -1
  41. package/dist/public-runtime.js +15 -1
  42. package/dist/public-runtime.js.map +1 -1
  43. package/dist/public-tools.d.ts +4 -0
  44. package/dist/public-tools.d.ts.map +1 -1
  45. package/dist/public-tools.js +12 -1
  46. package/dist/public-tools.js.map +1 -1
  47. package/dist/public-types.d.ts +9 -1
  48. package/dist/public-types.d.ts.map +1 -1
  49. package/dist/runtime/jobs/registry.d.ts +2 -2
  50. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  51. package/dist/runtime/jobs/registry.js +6 -2
  52. package/dist/runtime/jobs/registry.js.map +1 -1
  53. package/dist/runtime/query/declined.d.ts +12 -0
  54. package/dist/runtime/query/declined.d.ts.map +1 -0
  55. package/dist/runtime/query/declined.js +12 -0
  56. package/dist/runtime/query/declined.js.map +1 -0
  57. package/dist/runtime/query/executor.d.ts +36 -40
  58. package/dist/runtime/query/executor.d.ts.map +1 -1
  59. package/dist/runtime/query/executor.js +95 -53
  60. package/dist/runtime/query/executor.js.map +1 -1
  61. package/dist/runtime/query/index.d.ts.map +1 -1
  62. package/dist/runtime/query/index.js +8 -0
  63. package/dist/runtime/query/index.js.map +1 -1
  64. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  65. package/dist/runtime/query/iteration/index.js +13 -0
  66. package/dist/runtime/query/iteration/index.js.map +1 -1
  67. package/dist/runtime/query/iteration/phases/context.d.ts +13 -0
  68. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  69. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  70. package/dist/runtime/query/iteration/phases/handoff.d.ts +22 -0
  71. package/dist/runtime/query/iteration/phases/handoff.d.ts.map +1 -0
  72. package/dist/runtime/query/iteration/phases/handoff.js +65 -0
  73. package/dist/runtime/query/iteration/phases/handoff.js.map +1 -0
  74. package/dist/runtime/query/iteration/phases/index.d.ts +1 -0
  75. package/dist/runtime/query/iteration/phases/index.d.ts.map +1 -1
  76. package/dist/runtime/query/iteration/phases/index.js +1 -0
  77. package/dist/runtime/query/iteration/phases/index.js.map +1 -1
  78. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  79. package/dist/runtime/query/iteration/phases/tool-review.js +56 -3
  80. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  81. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  82. package/dist/runtime/query/resume-pending.js +3 -2
  83. package/dist/runtime/query/resume-pending.js.map +1 -1
  84. package/dist/runtime/query/review-policy.d.ts +11 -0
  85. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  86. package/dist/runtime/query/review-policy.js +36 -3
  87. package/dist/runtime/query/review-policy.js.map +1 -1
  88. package/dist/runtime/query/tooling.d.ts +3 -0
  89. package/dist/runtime/query/tooling.d.ts.map +1 -1
  90. package/dist/runtime/query/tooling.js +1 -0
  91. package/dist/runtime/query/tooling.js.map +1 -1
  92. package/dist/schedules/cron.d.ts +21 -0
  93. package/dist/schedules/cron.d.ts.map +1 -0
  94. package/dist/schedules/cron.js +167 -0
  95. package/dist/schedules/cron.js.map +1 -0
  96. package/dist/schedules/describe.d.ts +14 -0
  97. package/dist/schedules/describe.d.ts.map +1 -0
  98. package/dist/schedules/describe.js +133 -0
  99. package/dist/schedules/describe.js.map +1 -0
  100. package/dist/schedules/errors.d.ts +11 -0
  101. package/dist/schedules/errors.d.ts.map +1 -0
  102. package/dist/schedules/errors.js +15 -0
  103. package/dist/schedules/errors.js.map +1 -0
  104. package/dist/schedules/evaluate.d.ts +35 -0
  105. package/dist/schedules/evaluate.d.ts.map +1 -0
  106. package/dist/schedules/evaluate.js +158 -0
  107. package/dist/schedules/evaluate.js.map +1 -0
  108. package/dist/schedules/index.d.ts +11 -0
  109. package/dist/schedules/index.d.ts.map +1 -0
  110. package/dist/schedules/index.js +8 -0
  111. package/dist/schedules/index.js.map +1 -0
  112. package/dist/schedules/next-fire.d.ts +50 -0
  113. package/dist/schedules/next-fire.d.ts.map +1 -0
  114. package/dist/schedules/next-fire.js +250 -0
  115. package/dist/schedules/next-fire.js.map +1 -0
  116. package/dist/schedules/spec.d.ts +30 -0
  117. package/dist/schedules/spec.d.ts.map +1 -0
  118. package/dist/schedules/spec.js +169 -0
  119. package/dist/schedules/spec.js.map +1 -0
  120. package/dist/schedules/types.d.ts +144 -0
  121. package/dist/schedules/types.d.ts.map +1 -0
  122. package/dist/schedules/types.js +11 -0
  123. package/dist/schedules/types.js.map +1 -0
  124. package/dist/schedules/tz.d.ts +44 -0
  125. package/dist/schedules/tz.d.ts.map +1 -0
  126. package/dist/schedules/tz.js +141 -0
  127. package/dist/schedules/tz.js.map +1 -0
  128. package/dist/skills/index.d.ts +1 -1
  129. package/dist/skills/index.d.ts.map +1 -1
  130. package/dist/skills/index.js +1 -1
  131. package/dist/skills/index.js.map +1 -1
  132. package/dist/skills/loader.d.ts +21 -0
  133. package/dist/skills/loader.d.ts.map +1 -1
  134. package/dist/skills/loader.js +51 -1
  135. package/dist/skills/loader.js.map +1 -1
  136. package/dist/tools/builtins/bash.d.ts.map +1 -1
  137. package/dist/tools/builtins/bash.js +18 -6
  138. package/dist/tools/builtins/bash.js.map +1 -1
  139. package/dist/tools/builtins/browser-url.d.ts +83 -0
  140. package/dist/tools/builtins/browser-url.d.ts.map +1 -0
  141. package/dist/tools/builtins/browser-url.js +240 -0
  142. package/dist/tools/builtins/browser-url.js.map +1 -0
  143. package/dist/tools/builtins/browser.d.ts +367 -0
  144. package/dist/tools/builtins/browser.d.ts.map +1 -0
  145. package/dist/tools/builtins/browser.js +704 -0
  146. package/dist/tools/builtins/browser.js.map +1 -0
  147. package/dist/tools/builtins/skill.d.ts +2 -9
  148. package/dist/tools/builtins/skill.d.ts.map +1 -1
  149. package/dist/tools/builtins/skill.js +74 -51
  150. package/dist/tools/builtins/skill.js.map +1 -1
  151. package/dist/tools/command-shell.d.ts +90 -0
  152. package/dist/tools/command-shell.d.ts.map +1 -0
  153. package/dist/tools/command-shell.js +129 -0
  154. package/dist/tools/command-shell.js.map +1 -0
  155. package/dist/tools/defineTool.d.ts +13 -0
  156. package/dist/tools/defineTool.d.ts.map +1 -1
  157. package/dist/tools/defineTool.js +30 -1
  158. package/dist/tools/defineTool.js.map +1 -1
  159. package/dist/tools/schedules/index.d.ts +5 -0
  160. package/dist/tools/schedules/index.d.ts.map +1 -0
  161. package/dist/tools/schedules/index.js +4 -0
  162. package/dist/tools/schedules/index.js.map +1 -0
  163. package/dist/tools/schedules/loop-tool.d.ts +14 -0
  164. package/dist/tools/schedules/loop-tool.d.ts.map +1 -0
  165. package/dist/tools/schedules/loop-tool.js +81 -0
  166. package/dist/tools/schedules/loop-tool.js.map +1 -0
  167. package/dist/tools/schedules/present.d.ts +16 -0
  168. package/dist/tools/schedules/present.d.ts.map +1 -0
  169. package/dist/tools/schedules/present.js +69 -0
  170. package/dist/tools/schedules/present.js.map +1 -0
  171. package/dist/tools/schedules/prompt-scan.d.ts +17 -0
  172. package/dist/tools/schedules/prompt-scan.d.ts.map +1 -0
  173. package/dist/tools/schedules/prompt-scan.js +92 -0
  174. package/dist/tools/schedules/prompt-scan.js.map +1 -0
  175. package/dist/tools/schedules/schedule-tool.d.ts +16 -0
  176. package/dist/tools/schedules/schedule-tool.d.ts.map +1 -0
  177. package/dist/tools/schedules/schedule-tool.js +327 -0
  178. package/dist/tools/schedules/schedule-tool.js.map +1 -0
  179. package/dist/tools/schedules/types.d.ts +184 -0
  180. package/dist/tools/schedules/types.d.ts.map +1 -0
  181. package/dist/tools/schedules/types.js +11 -0
  182. package/dist/tools/schedules/types.js.map +1 -0
  183. package/dist/types/authorization/index.d.ts +86 -9
  184. package/dist/types/authorization/index.d.ts.map +1 -1
  185. package/dist/types/authorization/index.js +11 -1
  186. package/dist/types/authorization/index.js.map +1 -1
  187. package/dist/types/browser/index.d.ts +280 -0
  188. package/dist/types/browser/index.d.ts.map +1 -0
  189. package/dist/types/browser/index.js +12 -0
  190. package/dist/types/browser/index.js.map +1 -0
  191. package/dist/types/hitl/index.d.ts +23 -0
  192. package/dist/types/hitl/index.d.ts.map +1 -1
  193. package/dist/types/hitl/index.js.map +1 -1
  194. package/dist/types/session/events.d.ts +6 -0
  195. package/dist/types/session/events.d.ts.map +1 -1
  196. package/dist/types/session/events.js.map +1 -1
  197. package/dist/types/session/records.d.ts +23 -0
  198. package/dist/types/session/records.d.ts.map +1 -1
  199. package/dist/types/session/records.js +9 -0
  200. package/dist/types/session/records.js.map +1 -1
  201. package/dist/types/tool/index.d.ts +108 -5
  202. package/dist/types/tool/index.d.ts.map +1 -1
  203. package/dist/types/tool/index.js.map +1 -1
  204. package/dist/types/tool/presentation.d.ts +7 -0
  205. package/dist/types/tool/presentation.d.ts.map +1 -1
  206. package/dist/utils/frontmatter.d.ts +35 -3
  207. package/dist/utils/frontmatter.d.ts.map +1 -1
  208. package/dist/utils/frontmatter.js +45 -5
  209. package/dist/utils/frontmatter.js.map +1 -1
  210. package/dist/utils/id.d.ts +8 -0
  211. package/dist/utils/id.d.ts.map +1 -1
  212. package/dist/utils/id.js +12 -0
  213. package/dist/utils/id.js.map +1 -1
  214. package/package.json +1 -1
  215. package/src/authorization/command-line.ts +148 -293
  216. package/src/authorization/gate.ts +22 -2
  217. package/src/authorization/rules.ts +67 -8
  218. package/src/authorization/shell-lexer.ts +2349 -0
  219. package/src/authorization/skill-grant.ts +400 -0
  220. package/src/bridge/a2a/mapper.ts +2 -0
  221. package/src/bridge/sse/mapper.ts +1 -0
  222. package/src/directory/types.ts +2 -0
  223. package/src/persona/assembler.ts +5 -2
  224. package/src/prompt/coding-agent-doctrine.ts +1 -0
  225. package/src/public-runtime.ts +37 -0
  226. package/src/public-tools.ts +37 -1
  227. package/src/public-types.ts +57 -0
  228. package/src/runtime/jobs/registry.ts +19 -11
  229. package/src/runtime/query/declined.ts +12 -0
  230. package/src/runtime/query/executor.ts +116 -56
  231. package/src/runtime/query/index.ts +8 -0
  232. package/src/runtime/query/iteration/index.ts +13 -0
  233. package/src/runtime/query/iteration/phases/context.ts +13 -0
  234. package/src/runtime/query/iteration/phases/handoff.ts +74 -0
  235. package/src/runtime/query/iteration/phases/index.ts +1 -0
  236. package/src/runtime/query/iteration/phases/tool-review.ts +55 -3
  237. package/src/runtime/query/resume-pending.ts +3 -2
  238. package/src/runtime/query/review-policy.ts +57 -3
  239. package/src/runtime/query/tooling.ts +4 -0
  240. package/src/schedules/cron.ts +202 -0
  241. package/src/schedules/describe.ts +138 -0
  242. package/src/schedules/errors.ts +15 -0
  243. package/src/schedules/evaluate.ts +178 -0
  244. package/src/schedules/index.ts +18 -0
  245. package/src/schedules/next-fire.ts +257 -0
  246. package/src/schedules/spec.ts +210 -0
  247. package/src/schedules/types.ts +163 -0
  248. package/src/schedules/tz.ts +155 -0
  249. package/src/skills/index.ts +1 -1
  250. package/src/skills/loader.ts +59 -1
  251. package/src/tools/builtins/bash.ts +24 -6
  252. package/src/tools/builtins/browser-url.ts +251 -0
  253. package/src/tools/builtins/browser.ts +817 -0
  254. package/src/tools/builtins/skill.ts +92 -53
  255. package/src/tools/command-shell.ts +166 -0
  256. package/src/tools/defineTool.ts +36 -1
  257. package/src/tools/schedules/index.ts +4 -0
  258. package/src/tools/schedules/loop-tool.ts +85 -0
  259. package/src/tools/schedules/present.ts +86 -0
  260. package/src/tools/schedules/prompt-scan.ts +96 -0
  261. package/src/tools/schedules/schedule-tool.ts +376 -0
  262. package/src/tools/schedules/types.ts +197 -0
  263. package/src/types/authorization/index.ts +60 -2
  264. package/src/types/browser/index.ts +341 -0
  265. package/src/types/hitl/index.ts +21 -0
  266. package/src/types/session/events.ts +6 -0
  267. package/src/types/session/records.ts +10 -0
  268. package/src/types/tool/index.ts +109 -5
  269. package/src/types/tool/presentation.ts +7 -0
  270. package/src/utils/frontmatter.ts +81 -5
  271. package/src/utils/id.ts +14 -0
@@ -0,0 +1,18 @@
1
+ export { parseCronExpression } from './cron.js'
2
+ export { describeSchedule } from './describe.js'
3
+ export type { DescribeScheduleOptions } from './describe.js'
4
+ export { ScheduleValidationError } from './errors.js'
5
+ export {
6
+ SCHEDULE_CATCH_UP_WINDOW_MS,
7
+ SCHEDULE_LATE_GRACE_MS,
8
+ evaluateJob,
9
+ } from './evaluate.js'
10
+ export {
11
+ countOccurrences,
12
+ nextFireTime,
13
+ previousFireTime,
14
+ } from './next-fire.js'
15
+ export { parseDuration, parseScheduleSpec, upcomingFireTimes } from './spec.js'
16
+ export type { ParseScheduleOptions } from './spec.js'
17
+ export { hostTimeZone, validateTimeZone } from './tz.js'
18
+ export type * from './types.js'
@@ -0,0 +1,257 @@
1
+ /**
2
+ * When a spec fires: the next instant, the previous one, and how many fall
3
+ * in a window.
4
+ *
5
+ * Cron is walked a local DAY at a time — never a minute at a time over years
6
+ * — and a day with no DST change is computed arithmetically from one offset.
7
+ * Only a day whose offset changes is examined slot by slot, and that is where
8
+ * cronie's rule is applied:
9
+ *
10
+ * - a fixed-time expression (`30 2 * * *`) whose time falls in a
11
+ * spring-forward gap fires once, at the first instant after the gap;
12
+ * whose time is repeated by a fall-back fires once, at the first of the two;
13
+ * - a wildcard or step expression (`0 * * * *`, `*\/15 * * * *`) fires at
14
+ * every instant whose wall time matches: the repeated hour runs twice and
15
+ * slots inside the gap do not exist.
16
+ */
17
+
18
+ import { cronMatchesDay, parseCronExpression } from './cron.js'
19
+ import { ScheduleValidationError } from './errors.js'
20
+ import type { CronExpression, OccurrenceCount, ScheduleSpec } from './types.js'
21
+ import { civil, firstInstantAfterGap, instantsForWall, offsetAt, validateTimeZone } from './tz.js'
22
+
23
+ const MINUTE = 60_000
24
+ const HOUR = 60 * MINUTE
25
+ const DAY = 24 * HOUR
26
+ /** How far ahead a cron search looks before deciding the expression never fires. */
27
+ export const CRON_HORIZON_DAYS = 366 * 5 + 2
28
+ /**
29
+ * Occurrence counts stop here. Counting a day without a DST change costs one
30
+ * multiplication, so the cap bounds the pathological case (a years-long gap
31
+ * on a per-minute job) rather than the ordinary one.
32
+ */
33
+ export const OCCURRENCE_COUNT_CAP = 100_000
34
+
35
+ const parsedCache = new Map<string, CronExpression>()
36
+
37
+ function cronOf(expr: string): CronExpression {
38
+ let parsed = parsedCache.get(expr)
39
+ if (!parsed) {
40
+ parsed = parseCronExpression(expr)
41
+ if (parsedCache.size > 256) parsedCache.clear()
42
+ parsedCache.set(expr, parsed)
43
+ }
44
+ return parsed
45
+ }
46
+
47
+ /** The local-midnight wall value of the day containing `wall`. */
48
+ function dayStart(wall: number): number {
49
+ return Math.floor(wall / DAY) * DAY
50
+ }
51
+
52
+ /**
53
+ * Every instant the expression fires on one local day (`dayWall` is that
54
+ * day's midnight as a wall value), ascending.
55
+ */
56
+ export function cronInstantsOnDay(expr: CronExpression, tz: string, dayWall: number): number[] {
57
+ const c = civil(dayWall)
58
+ if (!cronMatchesDay(expr, c.month, c.day, c.weekday)) return []
59
+ const before = offsetAt(dayWall - 14 * HOUR, tz)
60
+ const after = offsetAt(dayWall + DAY + 14 * HOUR, tz)
61
+ const out: number[] = []
62
+ if (before === after) {
63
+ for (const h of expr.hours)
64
+ for (const m of expr.minutes) out.push(dayWall + h * HOUR + m * MINUTE - before)
65
+ return out
66
+ }
67
+ // A day the offset changes on (or next to): slot by slot.
68
+ const seen = new Set<number>()
69
+ for (const h of expr.hours) {
70
+ for (const m of expr.minutes) {
71
+ const wall = dayWall + h * HOUR + m * MINUTE
72
+ const instants = instantsForWall(wall, tz)
73
+ if (instants.length === 0) {
74
+ if (expr.fixedTime) {
75
+ const shifted = firstInstantAfterGap(wall, tz)
76
+ if (!seen.has(shifted)) {
77
+ seen.add(shifted)
78
+ out.push(shifted)
79
+ }
80
+ }
81
+ continue
82
+ }
83
+ const chosen = expr.fixedTime ? instants.slice(0, 1) : instants
84
+ for (const t of chosen) {
85
+ if (seen.has(t)) continue
86
+ seen.add(t)
87
+ out.push(t)
88
+ }
89
+ }
90
+ }
91
+ return out.sort((a, b) => a - b)
92
+ }
93
+
94
+ function validEvery(spec: { everyMs: number; anchorAt: string }): number {
95
+ const anchor = Date.parse(spec.anchorAt)
96
+ if (!Number.isFinite(anchor)) {
97
+ throw new ScheduleValidationError(`anchor "${spec.anchorAt}" is not a date`, spec.anchorAt)
98
+ }
99
+ if (!Number.isSafeInteger(spec.everyMs) || spec.everyMs < MINUTE || spec.everyMs % MINUTE !== 0) {
100
+ throw new ScheduleValidationError(
101
+ 'an interval must be a whole number of minutes, at least one',
102
+ String(spec.everyMs),
103
+ )
104
+ }
105
+ return anchor
106
+ }
107
+
108
+ /**
109
+ * The first instant strictly after `afterExclusive` at which `spec` fires,
110
+ * or `null` when there is none (a past `at`, or a cron expression with no
111
+ * occurrence in the next five years).
112
+ */
113
+ export function nextFireTime(spec: ScheduleSpec, afterExclusive: Date): Date | null {
114
+ const after = afterExclusive.getTime()
115
+ switch (spec.kind) {
116
+ case 'at': {
117
+ const at = Date.parse(spec.at)
118
+ return Number.isFinite(at) && at > after ? new Date(at) : null
119
+ }
120
+ case 'every': {
121
+ const anchor = validEvery(spec)
122
+ const k = after < anchor ? 1 : Math.floor((after - anchor) / spec.everyMs) + 1
123
+ return new Date(anchor + Math.max(1, k) * spec.everyMs)
124
+ }
125
+ case 'cron': {
126
+ const expr = cronOf(spec.expr)
127
+ const tz = spec.tz
128
+ // A day early: a gap-shifted or offset-crossing instant of the
129
+ // previous local day can still lie after `after`.
130
+ let day = dayStart(after + offsetAt(after, tz)) - DAY
131
+ for (let i = 0; i <= CRON_HORIZON_DAYS; i++, day += DAY) {
132
+ for (const t of cronInstantsOnDay(expr, tz, day)) if (t > after) return new Date(t)
133
+ }
134
+ return null
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * The last instant at or before `atOrBefore` at which `spec` fired, or
141
+ * `null` if none within the horizon (or before the anchor / the `at`).
142
+ */
143
+ export function previousFireTime(spec: ScheduleSpec, atOrBefore: Date): Date | null {
144
+ const before = atOrBefore.getTime()
145
+ switch (spec.kind) {
146
+ case 'at': {
147
+ const at = Date.parse(spec.at)
148
+ return Number.isFinite(at) && at <= before ? new Date(at) : null
149
+ }
150
+ case 'every': {
151
+ const anchor = validEvery(spec)
152
+ const k = Math.floor((before - anchor) / spec.everyMs)
153
+ return k >= 1 ? new Date(anchor + k * spec.everyMs) : null
154
+ }
155
+ case 'cron': {
156
+ const expr = cronOf(spec.expr)
157
+ const tz = spec.tz
158
+ let day = dayStart(before + offsetAt(before, tz)) + DAY
159
+ for (let i = 0; i <= CRON_HORIZON_DAYS; i++, day -= DAY) {
160
+ const instants = cronInstantsOnDay(expr, tz, day)
161
+ for (let j = instants.length - 1; j >= 0; j--) {
162
+ const t = instants[j] as number
163
+ if (t <= before) return new Date(t)
164
+ }
165
+ }
166
+ return null
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * How many times `spec` fires in `(fromExclusive, toInclusive]`, with the
173
+ * first and latest of them. Stops counting at `cap` (default 100 000) and
174
+ * says so, without enumerating beyond it; the latest is exact either way.
175
+ */
176
+ export function countOccurrences(
177
+ spec: ScheduleSpec,
178
+ fromExclusive: Date,
179
+ toInclusive: Date,
180
+ cap: number = OCCURRENCE_COUNT_CAP,
181
+ ): OccurrenceCount {
182
+ const from = fromExclusive.getTime()
183
+ const to = toInclusive.getTime()
184
+ if (to <= from) return { count: 0, capped: false }
185
+ switch (spec.kind) {
186
+ case 'at': {
187
+ const at = Date.parse(spec.at)
188
+ return at > from && at <= to
189
+ ? { count: 1, capped: false, first: new Date(at), latest: new Date(at) }
190
+ : { count: 0, capped: false }
191
+ }
192
+ case 'every': {
193
+ const anchor = validEvery(spec)
194
+ const firstK = from < anchor ? 1 : Math.floor((from - anchor) / spec.everyMs) + 1
195
+ const lastK = Math.floor((to - anchor) / spec.everyMs)
196
+ if (lastK < Math.max(1, firstK)) return { count: 0, capped: false }
197
+ const k0 = Math.max(1, firstK)
198
+ const total = lastK - k0 + 1
199
+ return {
200
+ count: Math.min(total, cap),
201
+ capped: total > cap,
202
+ first: new Date(anchor + k0 * spec.everyMs),
203
+ latest: new Date(anchor + lastK * spec.everyMs),
204
+ }
205
+ }
206
+ case 'cron': {
207
+ const expr = cronOf(spec.expr)
208
+ const tz = spec.tz
209
+ const latest = previousFireTime(spec, toInclusive)
210
+ if (!latest || latest.getTime() <= from) return { count: 0, capped: false }
211
+ const perUniformDay = expr.hours.length * expr.minutes.length
212
+ let count = 0
213
+ let first: number | undefined
214
+ const lastDay = dayStart(to + offsetAt(to, tz)) + DAY
215
+ for (let day = dayStart(from + offsetAt(from, tz)) - DAY; day <= lastDay; day += DAY) {
216
+ const c = civil(day)
217
+ if (!cronMatchesDay(expr, c.month, c.day, c.weekday)) continue
218
+ const o1 = offsetAt(day - 14 * HOUR, tz)
219
+ const o2 = offsetAt(day + DAY + 14 * HOUR, tz)
220
+ const dayFirst = day - o1
221
+ const dayLast = day + DAY - o1
222
+ if (first !== undefined && o1 === o2 && dayFirst > from && dayLast <= to) {
223
+ count += perUniformDay
224
+ } else {
225
+ for (const t of cronInstantsOnDay(expr, tz, day)) {
226
+ if (t <= from || t > to) continue
227
+ if (first === undefined) first = t
228
+ count++
229
+ }
230
+ }
231
+ if (count > cap) break
232
+ }
233
+ return {
234
+ count: Math.min(count, cap),
235
+ capped: count > cap,
236
+ ...(first !== undefined ? { first: new Date(first) } : {}),
237
+ latest,
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ /** Validate a spec as a whole: its zone, its expression, and that it can ever fire after `now`. */
244
+ export function assertSpecFires(spec: ScheduleSpec, now: Date): void {
245
+ if (spec.kind === 'cron') {
246
+ validateTimeZone(spec.tz)
247
+ cronOf(spec.expr)
248
+ }
249
+ if (nextFireTime(spec, now) === null) {
250
+ throw new ScheduleValidationError(
251
+ spec.kind === 'at'
252
+ ? `${spec.at} is in the past`
253
+ : `"${spec.kind === 'cron' ? spec.expr : ''}" never fires in the next five years`,
254
+ spec.kind === 'cron' ? spec.expr : spec.kind === 'at' ? spec.at : undefined,
255
+ )
256
+ }
257
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The words an operator or a model types for a schedule, turned into a spec.
3
+ *
4
+ * | Input | Spec |
5
+ * |---|---|
6
+ * | `at 2026-09-24T09:00`, `at 2026-09-24 09:00`, `at 09:00`, `in 30m` | `at` |
7
+ * | an ISO instant with `Z` or an offset, with or without `at` | `at` |
8
+ * | `every 30m`, `every 2h`, `every 1d`, `every 90m` | `every` |
9
+ * | five-field cron, `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly` | `cron` |
10
+ *
11
+ * A local time is read in `tz`. Nothing is rounded silently: `every 30s` is
12
+ * refused and the refusal names the nearest interval that is allowed.
13
+ */
14
+
15
+ import { parseCronExpression } from './cron.js'
16
+ import { ScheduleValidationError } from './errors.js'
17
+ import { assertSpecFires, nextFireTime } from './next-fire.js'
18
+ import type { ScheduleSpec } from './types.js'
19
+ import { civil, hostTimeZone, instantsForWall, validateTimeZone, wallOf } from './tz.js'
20
+
21
+ const MINUTE = 60_000
22
+ const UNIT_MS: Readonly<Record<string, number>> = {
23
+ s: 1_000,
24
+ sec: 1_000,
25
+ secs: 1_000,
26
+ second: 1_000,
27
+ seconds: 1_000,
28
+ m: MINUTE,
29
+ min: MINUTE,
30
+ mins: MINUTE,
31
+ minute: MINUTE,
32
+ minutes: MINUTE,
33
+ h: 60 * MINUTE,
34
+ hr: 60 * MINUTE,
35
+ hrs: 60 * MINUTE,
36
+ hour: 60 * MINUTE,
37
+ hours: 60 * MINUTE,
38
+ d: 24 * 60 * MINUTE,
39
+ day: 24 * 60 * MINUTE,
40
+ days: 24 * 60 * MINUTE,
41
+ w: 7 * 24 * 60 * MINUTE,
42
+ week: 7 * 24 * 60 * MINUTE,
43
+ weeks: 7 * 24 * 60 * MINUTE,
44
+ }
45
+
46
+ export interface ParseScheduleOptions {
47
+ /** "Now", for `in …`, `at HH:MM` and the anchor of `every`. Default: the clock. */
48
+ readonly now?: Date
49
+ /** Zone a local time and a cron expression are read in. Default: the host's. */
50
+ readonly tz?: string
51
+ }
52
+
53
+ /** `90m` → 5 400 000. Throws on anything else. */
54
+ export function parseDuration(text: string): number {
55
+ const match = /^(\d+)\s*([a-z]+)$/i.exec(text.trim())
56
+ const unit = match ? UNIT_MS[(match[2] ?? '').toLowerCase()] : undefined
57
+ if (!match || unit === undefined) {
58
+ throw new ScheduleValidationError(
59
+ `"${text}" is not a duration (write it like 30m, 2h, 1d)`,
60
+ text,
61
+ )
62
+ }
63
+ return Number(match[1]) * unit
64
+ }
65
+
66
+ function formatInterval(ms: number): string {
67
+ if (ms % (24 * 60 * MINUTE) === 0) return `${ms / (24 * 60 * MINUTE)}d`
68
+ if (ms % (60 * MINUTE) === 0) return `${ms / (60 * MINUTE)}h`
69
+ return `${ms / MINUTE}m`
70
+ }
71
+
72
+ function everySpec(text: string, now: Date): ScheduleSpec {
73
+ const ms = parseDuration(text)
74
+ if (ms < MINUTE) {
75
+ throw new ScheduleValidationError(
76
+ `every ${text} is shorter than the minimum interval; the nearest allowed is every 1m`,
77
+ text,
78
+ )
79
+ }
80
+ if (ms % MINUTE !== 0) {
81
+ const down = Math.floor(ms / MINUTE) * MINUTE
82
+ const up = down + MINUTE
83
+ throw new ScheduleValidationError(
84
+ `every ${text} is not a whole number of minutes; the nearest allowed are every ${formatInterval(down)} and every ${formatInterval(up)}`,
85
+ text,
86
+ )
87
+ }
88
+ const anchor = new Date(Math.floor(now.getTime() / MINUTE) * MINUTE)
89
+ return { kind: 'every', everyMs: ms, anchorAt: anchor.toISOString() }
90
+ }
91
+
92
+ /** Resolve a local `YYYY-MM-DD HH:MM` in `tz` to one instant (the earlier of two). */
93
+ function localToInstant(
94
+ y: number,
95
+ mo: number,
96
+ d: number,
97
+ h: number,
98
+ mi: number,
99
+ tz: string,
100
+ token: string,
101
+ ): number {
102
+ const wall = Date.UTC(y, mo - 1, d, h, mi)
103
+ const c = civil(wall)
104
+ if (c.year !== y || c.month !== mo || c.day !== d || h > 23 || mi > 59) {
105
+ throw new ScheduleValidationError(`"${token}" is not a real date and time`, token)
106
+ }
107
+ const instants = instantsForWall(wall, tz)
108
+ if (instants.length === 0) {
109
+ throw new ScheduleValidationError(
110
+ `"${token}" does not exist in ${tz} (the clocks skip it); pick a time outside the change`,
111
+ token,
112
+ )
113
+ }
114
+ return instants[0] as number
115
+ }
116
+
117
+ function atSpec(text: string, now: Date, tz: string): ScheduleSpec {
118
+ const t = text.trim()
119
+ // An instant with its own offset: taken as written.
120
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i.test(t)) {
121
+ const at = Date.parse(t)
122
+ if (!Number.isFinite(at)) throw new ScheduleValidationError(`"${t}" is not a date`, t)
123
+ return { kind: 'at', at: new Date(at).toISOString() }
124
+ }
125
+ const full = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})$/.exec(t)
126
+ if (full) {
127
+ const [, y, mo, d, h, mi] = full.map(Number) as number[]
128
+ const at = localToInstant(
129
+ y as number,
130
+ mo as number,
131
+ d as number,
132
+ h as number,
133
+ mi as number,
134
+ tz,
135
+ t,
136
+ )
137
+ return { kind: 'at', at: new Date(at).toISOString() }
138
+ }
139
+ const clock = /^(\d{1,2}):(\d{2})$/.exec(t)
140
+ if (clock) {
141
+ const h = Number(clock[1])
142
+ const mi = Number(clock[2])
143
+ if (h > 23 || mi > 59) throw new ScheduleValidationError(`"${t}" is not a time of day`, t)
144
+ // The next time the wall clock reads HH:MM, today or tomorrow.
145
+ const today = civil(wallOf(now.getTime(), tz))
146
+ for (let add = 0; add < 3; add++) {
147
+ const day = new Date(Date.UTC(today.year, today.month - 1, today.day + add))
148
+ const instants = instantsForWall(
149
+ Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), h, mi),
150
+ tz,
151
+ )
152
+ const first = instants.find((i) => i > now.getTime())
153
+ if (first !== undefined) return { kind: 'at', at: new Date(first).toISOString() }
154
+ }
155
+ throw new ScheduleValidationError(`"${t}" does not occur in ${tz} in the next two days`, t)
156
+ }
157
+ throw new ScheduleValidationError(
158
+ `"${t}" is not a time (write 2026-09-24 09:00, 09:00, or an ISO instant)`,
159
+ t,
160
+ )
161
+ }
162
+
163
+ /**
164
+ * Parse schedule words into a spec, validated: the zone exists, the
165
+ * expression parses, and the spec fires at least once after `now`.
166
+ */
167
+ export function parseScheduleSpec(input: string, options: ParseScheduleOptions = {}): ScheduleSpec {
168
+ const now = options.now ?? new Date()
169
+ const tz = validateTimeZone(options.tz ?? hostTimeZone())
170
+ const text = input.trim()
171
+ if (text === '') throw new ScheduleValidationError('a schedule is required', '')
172
+ let spec: ScheduleSpec
173
+ const lower = text.toLowerCase()
174
+ if (lower.startsWith('every ')) {
175
+ spec = everySpec(text.slice(6).trim(), now)
176
+ } else if (lower.startsWith('in ')) {
177
+ const ms = parseDuration(text.slice(3).trim())
178
+ if (ms < MINUTE) {
179
+ throw new ScheduleValidationError(
180
+ `in ${text.slice(3).trim()} is less than a minute away; the nearest allowed is in 1m`,
181
+ text.slice(3).trim(),
182
+ )
183
+ }
184
+ spec = { kind: 'at', at: new Date(now.getTime() + ms).toISOString() }
185
+ } else if (lower.startsWith('at ')) {
186
+ spec = atSpec(text.slice(3), now, tz)
187
+ } else if (/^\d{4}-\d{2}-\d{2}T/i.test(text)) {
188
+ spec = atSpec(text, now, tz)
189
+ } else if (lower === '@reboot') {
190
+ throw new ScheduleValidationError('@reboot is not supported: a job runs on a schedule', text)
191
+ } else {
192
+ const expr = parseCronExpression(text)
193
+ spec = { kind: 'cron', expr: expr.source, tz }
194
+ }
195
+ assertSpecFires(spec, now)
196
+ return spec
197
+ }
198
+
199
+ /** The next `count` fire times after `after`. */
200
+ export function upcomingFireTimes(spec: ScheduleSpec, after: Date, count = 3): Date[] {
201
+ const out: Date[] = []
202
+ let cursor = after
203
+ for (let i = 0; i < count; i++) {
204
+ const next = nextFireTime(spec, cursor)
205
+ if (!next) break
206
+ out.push(next)
207
+ cursor = next
208
+ }
209
+ return out
210
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Types of the schedule time engine and evaluator.
3
+ *
4
+ * The SDK owns WHEN something is due and nothing about how a host stores a
5
+ * job or runs it: the CLI keeps its job files, claims and history records to
6
+ * itself, so its on-disk format is not SDK API. The unions exported here
7
+ * (`ScheduleDecision`, `ScheduleSkipReason`, `ScheduleMissedReason`) may grow
8
+ * in a minor release; switch over them with a `default:` branch.
9
+ */
10
+
11
+ /** A single instant (ISO-8601 UTC). */
12
+ export interface ScheduleAtSpec {
13
+ readonly kind: 'at'
14
+ readonly at: string
15
+ }
16
+
17
+ /**
18
+ * Elapsed time from an anchor: `anchorAt + k * everyMs` for k ≥ 1. Pure UTC
19
+ * arithmetic, so `every 24h` drifts against the wall clock across a DST
20
+ * change; a cron spec is the way to say "every day at 09:00".
21
+ */
22
+ export interface ScheduleEverySpec {
23
+ readonly kind: 'every'
24
+ /** At least one minute, and a whole number of minutes. */
25
+ readonly everyMs: number
26
+ readonly anchorAt: string
27
+ }
28
+
29
+ /** Five-field cron, evaluated in an IANA time zone the spec carries. */
30
+ export interface ScheduleCronSpec {
31
+ readonly kind: 'cron'
32
+ /** Normalised five-field text; macros are expanded. */
33
+ readonly expr: string
34
+ readonly tz: string
35
+ }
36
+
37
+ export type ScheduleSpec = ScheduleAtSpec | ScheduleEverySpec | ScheduleCronSpec
38
+
39
+ /** A parsed cron expression: each field as the sorted set of values it allows. */
40
+ export interface CronExpression {
41
+ readonly source: string
42
+ readonly minutes: readonly number[]
43
+ readonly hours: readonly number[]
44
+ readonly daysOfMonth: readonly number[]
45
+ readonly months: readonly number[]
46
+ /** 0-6, Sunday = 0 (a written 7 is folded into 0). */
47
+ readonly daysOfWeek: readonly number[]
48
+ /** The day-of-month field does not start with `*`. */
49
+ readonly domRestricted: boolean
50
+ /** The day-of-week field does not start with `*`. */
51
+ readonly dowRestricted: boolean
52
+ /**
53
+ * Minute and hour are single values or lists with no `*` or step. Decides
54
+ * the DST rule (cronie's): a fixed-time job fires once per day across a
55
+ * DST change, a wildcard job fires at every instant whose wall time
56
+ * matches.
57
+ */
58
+ readonly fixedTime: boolean
59
+ }
60
+
61
+ /** Why an occurrence did not run. May grow in a minor release. */
62
+ export type ScheduleSkipReason =
63
+ | 'previous-run-active'
64
+ | 'previous-run-awaiting-approval'
65
+ | 'paused'
66
+ | 'awaiting-confirmation'
67
+ | 'beyond-catch-up-window'
68
+ | 'one-shot-expired'
69
+ | 'quota-hold'
70
+
71
+ /** Why occurrences were missed. Best effort; may grow in a minor release. */
72
+ export type ScheduleMissedReason = 'daemon-not-running' | 'machine-asleep' | 'clock-jumped-forward'
73
+
74
+ /** The lifecycle a job is in, as far as the evaluator cares. */
75
+ export type ScheduleJobLifecycle =
76
+ | 'pending-confirmation'
77
+ | 'active'
78
+ | 'paused'
79
+ | 'completed'
80
+ | 'expired'
81
+
82
+ /** The evaluator's structural view of a job: what it needs and nothing else. */
83
+ export interface ScheduleEvaluationJob {
84
+ readonly spec: ScheduleSpec
85
+ readonly state: ScheduleJobLifecycle
86
+ /** Catch-up window; default {@link SCHEDULE_CATCH_UP_WINDOW_MS}. */
87
+ readonly catchUp?: { readonly windowMs: number }
88
+ readonly createdAt: string
89
+ readonly updatedAt: string
90
+ /** Bumped on every definition change. */
91
+ readonly revision: number
92
+ }
93
+
94
+ /** What the host remembers between evaluations of one job. */
95
+ export interface ScheduleEvaluationState {
96
+ /** Everything up to and including this instant has been decided. */
97
+ readonly lastEvaluatedAt?: string
98
+ /** The job revision `lastEvaluatedAt` was computed under. */
99
+ readonly jobRevision?: number
100
+ /** A run of this job that has not finished (or a fire queued but not started). */
101
+ readonly activeRun?: { readonly status: 'queued' | 'running' | 'awaiting-approval' }
102
+ /** Provider asked to be left alone until this instant. */
103
+ readonly quotaHoldUntil?: string
104
+ }
105
+
106
+ export interface ScheduleObservedGap {
107
+ readonly from: Date
108
+ readonly to: Date
109
+ readonly kind: 'asleep' | 'clock-forward' | 'clock-backward'
110
+ }
111
+
112
+ export interface ScheduleEvaluationInput {
113
+ readonly job: ScheduleEvaluationJob
114
+ readonly state: ScheduleEvaluationState
115
+ readonly now: Date
116
+ /** When this daemon instance started, and a gap it observed itself. */
117
+ readonly daemon: { readonly startedAt: Date; readonly observedGap?: ScheduleObservedGap }
118
+ /** How late a fire may be and still count as on time; default {@link SCHEDULE_LATE_GRACE_MS}. */
119
+ readonly lateGraceMs?: number
120
+ /** Whether an occurrence key was already started (claimed) by someone. */
121
+ readonly isClaimed?: (key: string) => boolean
122
+ }
123
+
124
+ export type ScheduleFireTrigger = 'scheduled' | 'late' | 'catch-up'
125
+
126
+ /** What to do about one job now. May grow in a minor release. */
127
+ export interface ScheduleDecision {
128
+ readonly fire?: {
129
+ /** Occurrence key: the scheduled instant in epoch milliseconds. */
130
+ readonly key: string
131
+ readonly scheduledFor: Date
132
+ readonly trigger: ScheduleFireTrigger
133
+ }
134
+ /** Occurrences that will not run, collapsed: one entry per reason. */
135
+ readonly skip: readonly {
136
+ readonly scheduledFor: Date
137
+ readonly reason: ScheduleSkipReason
138
+ readonly count: number
139
+ }[]
140
+ readonly missed?: {
141
+ readonly from: Date
142
+ readonly to: Date
143
+ readonly count: number
144
+ /** The count stopped at the cap; the real number is at least `count`. */
145
+ readonly capped: boolean
146
+ readonly reason: ScheduleMissedReason
147
+ }
148
+ readonly jobTransition?: 'completed' | 'expired'
149
+ readonly nextState: {
150
+ readonly lastEvaluatedAt: Date
151
+ readonly nextFireAt?: Date
152
+ readonly jobRevision: number
153
+ }
154
+ }
155
+
156
+ /** How occurrences in a window were counted. */
157
+ export interface OccurrenceCount {
158
+ readonly count: number
159
+ /** The count stopped at the cap. */
160
+ readonly capped: boolean
161
+ readonly first?: Date
162
+ readonly latest?: Date
163
+ }