/** * 最小 cron expression matcher:5 欄位(minute hour dayOfMonth month dayOfWeek)。 * * 用於 cypher-executor scheduled() handler — 把 workflow 註冊的 cron_expr 跟 * 每分鐘 tick 的 event.scheduledTime 比對,匹配就觸發該 workflow。 * * 支援語法(夠用即可,未來再擴): * `*` — 任何值 * `5` — 等於 5 * `*​/N` — 每 N 個(N>0) * `5,10,15` — 任一 * `1-5` — range(含兩端) * * 不支援(暫): * `?` / `L` / `W` / `#` 等延伸語法 * month / weekday 用名稱(jan/mon 等) * * 對應 SDD: arcrun.md 三-A P1 #3。 */ /** 一個欄位(如 'minute')的值是否匹配 expr 段 */ function matchField(expr: string, value: number, min: number, max: number): boolean { if (expr === '*') return true; for (const part of expr.split(',')) { if (matchPart(part.trim(), value, min, max)) return true; } return false; } function matchPart(part: string, value: number, min: number, max: number): boolean { // `*/N` if (part.startsWith('*/')) { const step = parseInt(part.slice(2), 10); if (!Number.isFinite(step) || step <= 0) return false; return (value - min) % step === 0; } // `X-Y` 或 `X-Y/N` if (part.includes('-')) { const [rangePart, stepStr] = part.split('/'); const [aStr, bStr] = rangePart.split('-'); const a = parseInt(aStr, 10); const b = parseInt(bStr, 10); if (!Number.isFinite(a) || !Number.isFinite(b)) return false; if (value < a || value > b) return false; if (stepStr === undefined) return true; const step = parseInt(stepStr, 10); if (!Number.isFinite(step) || step <= 0) return false; return (value - a) % step === 0; } // `N` const n = parseInt(part, 10); if (!Number.isFinite(n)) return false; if (n < min || n > max) return false; return value === n; } /** * 比對 cron expr 跟某個時間點。 * @param expr - 5 欄位 cron(minute hour dom month dow) * @param date - 要比對的時間(UTC) */ export function cronMatch(expr: string, date: Date): boolean { const fields = expr.trim().split(/\s+/); if (fields.length !== 5) return false; const [m, h, dom, mon, dow] = fields; // dow: 0=Sun ... 6=Sat (跟 JavaScript 一致;ISO Mon=1 暫不轉) return ( matchField(m, date.getUTCMinutes(), 0, 59) && matchField(h, date.getUTCHours(), 0, 23) && matchField(dom, date.getUTCDate(), 1, 31) && matchField(mon, date.getUTCMonth() + 1, 1, 12) && matchField(dow, date.getUTCDay(), 0, 6) ); } /** * 從 workflow YAML 的 config 找出 cron 零件節點的 cron_expr。 * 找不到回 null(代表此 workflow 不是 cron-triggered)。 * * @param graph - acr push 解析後的 ExecutionGraph */ export function extractCronExpr(graph: unknown): string | null { if (!graph || typeof graph !== 'object') return null; const nodes = (graph as { nodes?: Array<{ id: string; componentId?: string; data?: Record }> }).nodes; if (!Array.isArray(nodes)) return null; for (const node of nodes) { if (node.componentId !== 'cron') continue; const expr = node.data?.cron_expr; if (typeof expr === 'string' && expr.trim()) return expr.trim(); } return null; }