diff --git a/CHANGELOG.md b/CHANGELOG.md index b19ea5b..8c25a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 is excluded so an `Edit` does not look self-derived. Absent rather than empty when there is nothing to say. +- **Trigger sources for scheduled jobs** — a job can now fire from a calendar + file or a file change, not only a clock. `{ "kind": "ics", "path": "team.ics", +"match": "release" }` fires when a matching event starts; + `{ "kind": "file", "paths": ["schema.json"] }` fires when a watched path + changes. `schedule` still means cron and existing jobs need no migration. + Everything is **polled** by the existing `scheduler run`, so there is no + daemon and no way for a trigger to fire while nothing is listening. See + [`docs/triggers.md`](docs/triggers.md). + + Standard iCalendar text is the only calendar input — no vendor SDK, no OAuth + to a calendar service. The reader handles `DTSTART`, folded `SUMMARY` lines and + `RRULE FREQ=DAILY`/`WEEKLY` with `INTERVAL`/`BYDAY`/`UNTIL`/`COUNT`, and + **reports** anything it cannot express rather than dropping it: a silently + ignored `RRULE` is a job that never fires, and that failure is + indistinguishable from "nothing was scheduled". All-day entries never fire — + they name a day, not a moment. A trigger decides when, never what may happen: + every scheduled run still goes through the unattended clamp. + ### 🔒 Security - **A sub-agent did not inherit the file contract.** The `Task` delegation diff --git a/README.md b/README.md index 9be3eba..cff192a 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Mac 客户端(v1 即将发布):拖入 Applications → 首启完成 onboar | [docs/file-contract.md](docs/file-contract.md) | 路径维度权限契约(`deepcode contract`) | | [docs/change-ledger.md](docs/change-ledger.md) | 变更账本与回滚(`deepcode ledger`) | | [docs/combo.md](docs/combo.md) | `/combo` —— 把做完的 thread 蒸馏成 skill | +| [docs/triggers.md](docs/triggers.md) | 定时任务触发源:cron / ICS 日历 / 文件变更 | | [docs/DEMO_SCRIPT.md](docs/DEMO_SCRIPT.md) | 5 分钟 launch 视频逐段录制脚本 | ### 设计文档 diff --git a/apps/cli/src/scheduler.ts b/apps/cli/src/scheduler.ts index f18b5cd..406ac65 100644 --- a/apps/cli/src/scheduler.ts +++ b/apps/cli/src/scheduler.ts @@ -9,7 +9,8 @@ // so it survives `nvm`/path quirks) and best-effort `launchctl load`s it. import { - dueJobs, + dueJobsWithTriggers, + resolveTrigger, installPlist, launchdPlistPath, listCronJobs, @@ -50,17 +51,38 @@ export async function runSchedulerRun(deps: SchedulerDeps = {}): Promise<{ ran: const home = deps.home ?? homedir(); const out = deps.output ?? process.stdout; const store = await loadCronStore(home); - const due = dueJobs(store.jobs, now); + const due = await dueJobsWithTriggers(store.jobs, now); const ran: string[] = []; - if (due.length === 0) return { ran }; + + // A file trigger compares mtimes against `lastRunAt`, so a job that has never + // run has nothing to compare against and deliberately does not fire. Stamp + // the baseline here — otherwise it has no way to ever acquire one, and the + // job stays silent forever while looking configured. + let stamped = false; + for (const job of store.jobs) { + if (!job.enabled || job.lastRunAt) continue; + if (resolveTrigger(job).kind !== 'file') continue; + job.lastRunAt = now.toISOString(); + stamped = true; + out.write(`[scheduler] ${job.id}: watching from ${job.lastRunAt}\n`); + } + + if (due.length === 0) { + if (stamped) await saveCronStore(store, home); + return { ran }; + } out.write(`[scheduler] ${now.toISOString()} — ${due.length} job(s) due\n`); - for (const job of due) { + for (const { job, verdict } of due) { + // Say what the calendar could not express, next to the job it belongs to. + for (const note of verdict.diagnostics ?? []) { + out.write(`[scheduler] ${job.id}: ${note}\n`); + } try { await (deps.runJob ?? ((j) => defaultRunJob(j, home)))(job); job.lastRunAt = now.toISOString(); ran.push(job.id); - out.write(`[scheduler] ran ${job.id}\n`); + out.write(`[scheduler] ran ${job.id}${verdict.reason ? ` — ${verdict.reason}` : ''}\n`); } catch (err) { out.write(`[scheduler] job ${job.id} failed: ${(err as Error).message}\n`); } diff --git a/docs/FLOATBOAT_ADOPTION_PLAN.md b/docs/FLOATBOAT_ADOPTION_PLAN.md index 24d5a51..d532511 100644 --- a/docs/FLOATBOAT_ADOPTION_PLAN.md +++ b/docs/FLOATBOAT_ADOPTION_PLAN.md @@ -500,15 +500,15 @@ File Contract 接入(PR 2)是唯一需要谨慎评审的一步。 写下与计划不符的地方,比宣称"照计划完成"有用。 -| 项 | 计划 | 实际 | -| ---------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PR 0 的问题陈述 | 称无人值守可能"静默放行" | **计划写错了**。`ask` 路径本来就 fail-closed(`runHeadless` 传 `approval: async () => false`)。真正缺的是"停下来"的能力和可见性,PR #237 按事实重写了范围 | -| 权限档位的钳制 | 放在 PR 0 | 推迟到 PR 7。没有 opt-in 的钳制只是破坏,等 `TriggerProfile` 落地才安全 | -| 契约 `deny` 与 `bypassPermissions` | 计划未明确 | 实施时决定 **`deny` 不可被 `bypassPermissions` 豁免**。它是关于路径的常驻声明,不是逐次提示;否则契约最强的一句话也最容易被关掉 | -| 四客户端一致性测试 | 计划要求 4 个客户端逐字段相等 | 实际只有 CLI 与 app-server **独立解析**策略;VS Code / LSP 是协议瘦客户端,逐字节消费 server 的答复,构造上即相等。测试断言前两者,并在文档里说明后两者的理由 —— 不宣称验证了 4 条独立路径 | -| Grep/Glob 结果过滤 | 列为 PR 1 的已知缺口 | 仍未做。契约对 Grep/Glob 只裁决搜索根,命中结果里混入 deny 路径的内容需要工具输出层二次过滤 | -| 制品 `provenance` | 列在 PR 8(P2) | 已做。建在 change ledger 上而不是第二套存储:每条变更记录带 `derivedFrom`(本轮在写之前读过的文件)。**观察得来而非声明**——只算 `Read`,失败的读不算,被写的文件本身不算。见 [`change-ledger.md`](change-ledger.md) | -| 触发源抽象(ICS / watch) | 列在 PR 8(P2) | 未做。`cron` 仍只有时间源 | +| 项 | 计划 | 实际 | +| ---------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PR 0 的问题陈述 | 称无人值守可能"静默放行" | **计划写错了**。`ask` 路径本来就 fail-closed(`runHeadless` 传 `approval: async () => false`)。真正缺的是"停下来"的能力和可见性,PR #237 按事实重写了范围 | +| 权限档位的钳制 | 放在 PR 0 | 推迟到 PR 7。没有 opt-in 的钳制只是破坏,等 `TriggerProfile` 落地才安全 | +| 契约 `deny` 与 `bypassPermissions` | 计划未明确 | 实施时决定 **`deny` 不可被 `bypassPermissions` 豁免**。它是关于路径的常驻声明,不是逐次提示;否则契约最强的一句话也最容易被关掉 | +| 四客户端一致性测试 | 计划要求 4 个客户端逐字段相等 | 实际只有 CLI 与 app-server **独立解析**策略;VS Code / LSP 是协议瘦客户端,逐字节消费 server 的答复,构造上即相等。测试断言前两者,并在文档里说明后两者的理由 —— 不宣称验证了 4 条独立路径 | +| Grep/Glob 结果过滤 | 列为 PR 1 的已知缺口 | 已做。结果集经 `evaluatePath`(网关用的同一个函数)过滤后再返回,输出末尾只报被扣下的**数量**、不报路径;`ask` 不过滤——搜索途中无人可问,且命中还不算读取。见 #254 | +| 制品 `provenance` | 列在 PR 8(P2) | 已做。建在 change ledger 上而不是第二套存储:每条变更记录带 `derivedFrom`(本轮在写之前读过的文件)。**观察得来而非声明**——只算 `Read`,失败的读不算,被写的文件本身不算。见 [`change-ledger.md`](change-ledger.md) | +| 触发源抽象(ICS / watch) | 列在 PR 8(P2) | 已做。`TriggerSource` = cron / ics / file,全部走既有 `scheduler run` 轮询(无 daemon)。ICS 只接受标准文本、不内置任何日历厂商 SDK;解析器对 `RRULE` 未覆盖的部分**报告而不丢弃**——静默忽略的 RRULE 就是一个永不触发的任务,且和"根本没配"长得一模一样。见 [`triggers.md`](triggers.md) | **未解假设的最终结论**(§7 提的四个): diff --git a/docs/triggers.md b/docs/triggers.md new file mode 100644 index 0000000..c7e18f9 --- /dev/null +++ b/docs/triggers.md @@ -0,0 +1,119 @@ +# Trigger sources + +A scheduled job runs when its **trigger** fires. Until 0.4 the only trigger was a +clock, so "every weekday at 09:00" was expressible and "when the release meeting +starts" or "when the schema changes" were not — both of which are scheduling, and +neither of which is a time. + +```jsonc +// ~/.deepcode/cron.json +{ + "jobs": [ + { "id": "nightly", "schedule": "0 3 * * *", "prompt": "…", "cwd": "/repo" }, + + { + "id": "release-prep", + "trigger": { "kind": "ics", "path": "team.ics", "match": "release" }, + "prompt": "Run the release checklist", + "cwd": "/repo", + }, + + { + "id": "regen", + "trigger": { "kind": "file", "paths": ["schema.json"] }, + "prompt": "Regenerate the client from schema.json", + "cwd": "/repo", + }, + ], +} +``` + +`schedule` still works and still means cron. A job written before triggers +existed needs no migration — a store nobody has to rewrite cannot be rewritten +wrongly. + +## Everything is polled + +`deepcode scheduler run` already wakes on a timer and asks what is due. Every +trigger answers that same question, so there is no daemon, no watcher process, +and no way for a trigger to fire while nothing is listening. + +The cost is **minute granularity** for all of them, which is the granularity you +can observe anyway: a trigger resolvable to the second would fire or not +depending on how promptly launchd got around to it. + +## `cron` + +```jsonc +{ "kind": "cron", "schedule": "0 9 * * 1-5" } +``` + +Five fields: minute, hour, day-of-month, month, day-of-week. + +## `ics` — a calendar file + +```jsonc +{ "kind": "ics", "path": "team.ics", "match": "release" } +``` + +Fires in the minute a matching event **starts**. `match` is a case-insensitive +substring of the event summary; without it, every event in the file fires the +job. + +**Standard iCalendar text is the only calendar input.** No vendor SDK, no OAuth +to anybody's calendar service, no remote account polling. Every calendar worth +integrating with exports `.ics`, and a file on disk is a boundary you can +inspect — which a vendor client library is not. Point the path at an export, a +synced file, or something your own tooling writes. + +### What the reader supports + +| Construct | Behaviour | +| ------------------------------ | ---------------------------------------------------- | +| `DTSTART` UTC (`…Z`) | Used as written | +| `DTSTART` floating (no zone) | Read as UTC | +| `DTSTART;VALUE=DATE` (all-day) | **Never fires** — see below | +| `SUMMARY`, incl. folded lines | Used for `match` | +| `RRULE FREQ=DAILY` / `=WEEKLY` | Expanded, with `INTERVAL`, `BYDAY`, `UNTIL`, `COUNT` | +| Anything else | **Reported**, never silently dropped | + +Unsupported constructs are logged next to the job that hit them. A silently +ignored `RRULE` is a job that never fires, and that failure looks exactly like +"nothing was scheduled" — which is the one thing it must not be mistaken for. + +`TZID` is reported rather than honoured. DeepCode carries no timezone database, +and quietly applying the host's zone would make the same file fire at different +moments on different machines. + +**All-day entries never fire.** They name a day, not a moment; choosing one +(midnight? 09:00?) would be DeepCode inventing a schedule you did not write. Use +a `cron` trigger if you want a time. + +## `file` — something changed + +```jsonc +{ "kind": "file", "paths": ["schema.json", "proto/"] } +``` + +Fires when any listed path's modification time is newer than the job's last run. +Relative paths resolve against the job's `cwd`. + +Two behaviours worth knowing: + +- **The first evaluation never fires.** It records a baseline instead. Otherwise + every file trigger would fire the moment it was created, on files nobody had + touched since anyone cared. +- **A missing path is not a change.** A watched file may simply not have been + generated yet, and reporting that every minute would bury the messages that + matter. + +## Permissions are unchanged + +A trigger decides _when_, never _what may happen_. Every scheduled run still goes +through the [trigger profile](FLOATBOAT_ADOPTION_PLAN.md) clamp: a permissive +`permissions.defaultMode` inherited from interactive settings is reduced to +`default` unless the job sets `profile.mode` explicitly, and a call needing +approval is refused because nobody is present to give it. + +A calendar you do not control deciding _when_ DeepCode runs is already worth +thinking about. It must never also decide what it may do. diff --git a/packages/core/src/cron/ics.test.ts b/packages/core/src/cron/ics.test.ts new file mode 100644 index 0000000..9dd9ae4 --- /dev/null +++ b/packages/core/src/cron/ics.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import { matchingEvents, occursAt, parseIcs } from './ics.js'; + +const at = (iso: string): number => Date.parse(iso); + +function calendar(body: string): string { + return ['BEGIN:VCALENDAR', 'VERSION:2.0', body, 'END:VCALENDAR'].join('\r\n'); +} + +describe('parseIcs', () => { + it('reads a plain UTC event', () => { + const { events } = parseIcs( + calendar( + ['BEGIN:VEVENT', 'SUMMARY:Release meeting', 'DTSTART:20260810T090000Z', 'END:VEVENT'].join( + '\r\n', + ), + ), + ); + expect(events).toEqual([ + { summary: 'Release meeting', startMs: at('2026-08-10T09:00:00Z'), allDay: false }, + ]); + }); + + it('unfolds continuation lines', () => { + // Calendars fold at 75 octets, so a normal-length SUMMARY arrives split. A + // parser that skips this misreads ordinary files, not exotic ones. + const { events } = parseIcs( + calendar( + [ + 'BEGIN:VEVENT', + 'SUMMARY:Weekly release review with the', + ' whole team', + 'DTSTART:20260810T090000Z', + 'END:VEVENT', + ].join('\r\n'), + ), + ); + expect(events[0]!.summary).toBe('Weekly release review with the whole team'); + }); + + it('unescapes the sequences RFC 5545 defines', () => { + const { events } = parseIcs( + calendar( + [ + 'BEGIN:VEVENT', + 'SUMMARY:Ship\\, then\\; rest', + 'DTSTART:20260810T090000Z', + 'END:VEVENT', + ].join('\r\n'), + ), + ); + expect(events[0]!.summary).toBe('Ship, then; rest'); + }); + + it('marks a date-only event as all-day', () => { + const { events } = parseIcs( + calendar( + ['BEGIN:VEVENT', 'SUMMARY:Holiday', 'DTSTART;VALUE=DATE:20260810', 'END:VEVENT'].join( + '\r\n', + ), + ), + ); + expect(events[0]!.allDay).toBe(true); + }); + + it('reports a TZID rather than guessing a zone', () => { + // Applying the host's zone would make the same file fire at different + // moments on different machines. + const { unsupported } = parseIcs( + calendar( + [ + 'BEGIN:VEVENT', + 'SUMMARY:Standup', + 'DTSTART;TZID=Europe/Berlin:20260810T090000', + 'END:VEVENT', + ].join('\r\n'), + ), + ); + expect(unsupported.join(' ')).toMatch(/TZID/); + }); + + it('reports recurrence rules it cannot express, rather than dropping them', () => { + // A silently ignored RRULE is a job that never fires, and the failure looks + // exactly like "nothing was scheduled". + const { unsupported } = parseIcs( + calendar( + [ + 'BEGIN:VEVENT', + 'SUMMARY:Monthly billing', + 'DTSTART:20260810T090000Z', + 'RRULE:FREQ=MONTHLY;BYMONTHDAY=10', + 'END:VEVENT', + ].join('\r\n'), + ), + ); + expect(unsupported.join(' ')).toMatch(/FREQ=MONTHLY/); + }); + + it('reports an ordinal BYDAY it cannot express', () => { + const { unsupported } = parseIcs( + calendar( + [ + 'BEGIN:VEVENT', + 'DTSTART:20260810T090000Z', + 'RRULE:FREQ=WEEKLY;BYDAY=2MO', + 'END:VEVENT', + ].join('\r\n'), + ), + ); + expect(unsupported.join(' ')).toMatch(/BYDAY=2MO/); + }); + + it('skips an event with no DTSTART instead of inventing one', () => { + const { events } = parseIcs( + calendar(['BEGIN:VEVENT', 'SUMMARY:Undated', 'END:VEVENT'].join('\r\n')), + ); + expect(events).toEqual([]); + }); +}); + +describe('occursAt', () => { + const base = { summary: 'Standup', startMs: at('2026-08-10T09:00:00Z'), allDay: false }; + + it('matches the minute the event starts', () => { + expect(occursAt(base, at('2026-08-10T09:00:00Z'))).toBe(true); + expect(occursAt(base, at('2026-08-10T09:00:59Z'))).toBe(true); // same minute + expect(occursAt(base, at('2026-08-10T09:01:00Z'))).toBe(false); + }); + + it('never matches an all-day entry', () => { + // It names a day, not a moment. Picking one would be inventing a schedule + // the user did not write. + expect(occursAt({ ...base, allDay: true }, at('2026-08-10T00:00:00Z'))).toBe(false); + expect(occursAt({ ...base, allDay: true }, at('2026-08-10T09:00:00Z'))).toBe(false); + }); + + it('does not match before the first occurrence', () => { + const daily = { ...base, recurrence: { freq: 'DAILY' as const, interval: 1, byDay: [] } }; + expect(occursAt(daily, at('2026-08-09T09:00:00Z'))).toBe(false); + }); + + describe('DAILY', () => { + const daily = { ...base, recurrence: { freq: 'DAILY' as const, interval: 1, byDay: [] } }; + + it('repeats every day at the same time', () => { + expect(occursAt(daily, at('2026-08-11T09:00:00Z'))).toBe(true); + expect(occursAt(daily, at('2026-09-01T09:00:00Z'))).toBe(true); + }); + + it('keeps the time of day — recurrence repeats the day, not the clock', () => { + expect(occursAt(daily, at('2026-08-11T10:00:00Z'))).toBe(false); + }); + + it('honours INTERVAL', () => { + const everyThird = { ...base, recurrence: { ...daily.recurrence, interval: 3 } }; + expect(occursAt(everyThird, at('2026-08-13T09:00:00Z'))).toBe(true); + expect(occursAt(everyThird, at('2026-08-12T09:00:00Z'))).toBe(false); + }); + + it('stops at UNTIL', () => { + const bounded = { + ...base, + recurrence: { ...daily.recurrence, untilMs: at('2026-08-12T09:00:00Z') }, + }; + expect(occursAt(bounded, at('2026-08-12T09:00:00Z'))).toBe(true); + expect(occursAt(bounded, at('2026-08-13T09:00:00Z'))).toBe(false); + }); + + it('stops after COUNT occurrences, counting the first', () => { + const thrice = { ...base, recurrence: { ...daily.recurrence, count: 3 } }; + expect(occursAt(thrice, at('2026-08-10T09:00:00Z'))).toBe(true); + expect(occursAt(thrice, at('2026-08-12T09:00:00Z'))).toBe(true); + expect(occursAt(thrice, at('2026-08-13T09:00:00Z'))).toBe(false); + }); + }); + + describe('WEEKLY', () => { + // 2026-08-10 is a Monday. + const weekly = { + ...base, + recurrence: { freq: 'WEEKLY' as const, interval: 1, byDay: [] }, + }; + + it('repeats on the start weekday when BYDAY is absent', () => { + expect(occursAt(weekly, at('2026-08-17T09:00:00Z'))).toBe(true); + expect(occursAt(weekly, at('2026-08-18T09:00:00Z'))).toBe(false); + }); + + it('fires on every listed weekday', () => { + const mwf = { ...base, recurrence: { ...weekly.recurrence, byDay: [1, 3, 5] } }; + expect(occursAt(mwf, at('2026-08-12T09:00:00Z'))).toBe(true); // Wednesday + expect(occursAt(mwf, at('2026-08-14T09:00:00Z'))).toBe(true); // Friday + expect(occursAt(mwf, at('2026-08-13T09:00:00Z'))).toBe(false); // Thursday + }); + + it('honours INTERVAL by week, not by day', () => { + const fortnightly = { ...base, recurrence: { ...weekly.recurrence, interval: 2 } }; + expect(occursAt(fortnightly, at('2026-08-24T09:00:00Z'))).toBe(true); + expect(occursAt(fortnightly, at('2026-08-17T09:00:00Z'))).toBe(false); + }); + }); +}); + +describe('matchingEvents', () => { + const cal = parseIcs( + calendar( + [ + 'BEGIN:VEVENT', + 'SUMMARY:Release meeting', + 'DTSTART:20260810T090000Z', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'SUMMARY:Lunch', + 'DTSTART:20260810T120000Z', + 'END:VEVENT', + ].join('\r\n'), + ), + ); + + it('returns everything with no filter', () => { + expect(matchingEvents(cal)).toHaveLength(2); + }); + + it('filters by substring, case-insensitively', () => { + expect(matchingEvents(cal, 'release').map((e) => e.summary)).toEqual(['Release meeting']); + }); +}); diff --git a/packages/core/src/cron/ics.ts b/packages/core/src/cron/ics.ts new file mode 100644 index 0000000..45ffad9 --- /dev/null +++ b/packages/core/src/cron/ics.ts @@ -0,0 +1,261 @@ +// A deliberately small iCalendar (RFC 5545) reader. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §4 PR 8 — "触发源抽象(ICS / watch)" +// +// Standard ICS text is the only calendar input DeepCode accepts. No vendor SDK, +// no OAuth to anybody's calendar service, no polling of a remote account: you +// point a job at a `.ics` file and DeepCode reads it. Every calendar worth +// integrating with can export one, and a file on disk is a boundary you can +// inspect — which a vendor client library is not. +// +// The parser is strict and small, for the same reason the file-contract parser +// is: a construct it does not recognise is reported, never dropped. A silently +// ignored `RRULE` is a scheduled job that never fires, and the failure looks +// exactly like "nothing was scheduled". + +/** One expandable calendar entry. Times are UTC epoch milliseconds. */ +export interface IcsEvent { + summary: string; + /** First occurrence. */ + startMs: number; + /** Whole-day entries have no meaningful minute, and never match a minute. */ + allDay: boolean; + recurrence?: IcsRecurrence; +} + +export interface IcsRecurrence { + freq: 'DAILY' | 'WEEKLY'; + interval: number; + /** 0–6, Sunday first, matching `Date#getUTCDay`. Empty means "same as DTSTART". */ + byDay: number[]; + untilMs?: number; + count?: number; +} + +export interface IcsCalendar { + events: IcsEvent[]; + /** + * Things this parser saw and could not express. + * + * Surfaced rather than swallowed. An unsupported `RRULE` means the user's job + * will not fire on the days they expect, and the only thing worse than not + * supporting it is not supporting it quietly. + */ + unsupported: string[]; +} + +const DAYS: Record = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 }; + +/** + * Undo RFC 5545 line folding. + * + * A continuation line begins with a space or tab and belongs to the previous + * one. Calendars fold aggressively — a 90-character SUMMARY is routinely split — + * so a parser that skips this step misreads ordinary files, not exotic ones. + */ +function unfold(text: string): string[] { + const out: string[] = []; + for (const raw of text.split(/\r?\n/)) { + if ((raw.startsWith(' ') || raw.startsWith('\t')) && out.length > 0) { + out[out.length - 1] += raw.slice(1); + } else { + out.push(raw); + } + } + return out; +} + +/** `DTSTART;TZID=X:20260809T090000` → `{ name, params, value }`. */ +function splitLine(line: string): { name: string; params: string[]; value: string } | null { + const colon = line.indexOf(':'); + if (colon === -1) return null; + const [name, ...params] = line.slice(0, colon).split(';'); + return { name: (name ?? '').toUpperCase(), params, value: line.slice(colon + 1) }; +} + +/** + * Parse an ICS timestamp. + * + * Handles UTC (`…Z`), date-only (`VALUE=DATE`), and floating local times. A + * floating time is read as UTC rather than guessed at: DeepCode has no way to + * know the calendar's zone, and quietly applying the host's would make the same + * file fire at different moments on different machines. `TZID` is reported as + * unsupported for the same reason. + */ +function parseStamp(value: string): { ms: number; allDay: boolean } | null { + const date = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(value.trim()); + if (!date) return null; + const [, y, mo, d, h, mi, s] = date; + const ms = Date.UTC( + Number(y), + Number(mo) - 1, + Number(d), + Number(h ?? '0'), + Number(mi ?? '0'), + Number(s ?? '0'), + ); + return { ms, allDay: h === undefined }; +} + +function parseRecurrence(value: string, unsupported: string[]): IcsRecurrence | undefined { + const parts = new Map(); + for (const chunk of value.split(';')) { + const eq = chunk.indexOf('='); + if (eq > 0) parts.set(chunk.slice(0, eq).toUpperCase(), chunk.slice(eq + 1)); + } + + const freq = (parts.get('FREQ') ?? '').toUpperCase(); + if (freq !== 'DAILY' && freq !== 'WEEKLY') { + // MONTHLY and YEARLY need month-length and leap-year rules that are easy to + // get subtly wrong, and a scheduler that fires on the wrong day is worse + // than one that says it cannot. + unsupported.push(`RRULE FREQ=${freq || '(missing)'} is not supported (only DAILY and WEEKLY)`); + return undefined; + } + for (const key of ['BYMONTHDAY', 'BYMONTH', 'BYSETPOS', 'BYWEEKNO', 'BYYEARDAY']) { + if (parts.has(key)) unsupported.push(`RRULE ${key} is not supported`); + } + + const byDay: number[] = []; + for (const token of (parts.get('BYDAY') ?? '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean)) { + // `2MO` (second Monday) carries an ordinal this parser does not implement. + const day = DAYS[token.toUpperCase()]; + if (day === undefined) unsupported.push(`RRULE BYDAY=${token} is not supported`); + else byDay.push(day); + } + + const until = parts.get('UNTIL') ? parseStamp(parts.get('UNTIL')!) : null; + const count = parts.get('COUNT') ? Number(parts.get('COUNT')) : undefined; + return { + freq, + interval: Math.max(1, Number(parts.get('INTERVAL') ?? '1') || 1), + byDay, + ...(until ? { untilMs: until.ms } : {}), + ...(Number.isFinite(count) && count ? { count } : {}), + }; +} + +export function parseIcs(text: string): IcsCalendar { + const events: IcsEvent[] = []; + const unsupported: string[] = []; + let current: Partial | null = null; + + for (const line of unfold(text)) { + const trimmed = line.trim(); + if (trimmed === 'BEGIN:VEVENT') { + current = {}; + continue; + } + if (trimmed === 'END:VEVENT') { + if (current && typeof current.startMs === 'number') { + events.push({ + summary: current.summary ?? '', + startMs: current.startMs, + allDay: current.allDay ?? false, + ...(current.recurrence ? { recurrence: current.recurrence } : {}), + }); + } + current = null; + continue; + } + if (!current) continue; + + const field = splitLine(trimmed); + if (!field) continue; + if (field.name === 'SUMMARY') { + // Unescape the four sequences RFC 5545 defines. Left as-is otherwise: + // a SUMMARY is matched against, not executed. + current.summary = field.value + .replace(/\\n/gi, '\n') + .replace(/\\,/g, ',') + .replace(/\\;/g, ';') + .replace(/\\\\/g, '\\'); + continue; + } + if (field.name === 'DTSTART') { + if (field.params.some((p) => p.toUpperCase().startsWith('TZID='))) { + unsupported.push( + `DTSTART with TZID is read as UTC — DeepCode does not carry a timezone database`, + ); + } + const stamp = parseStamp(field.value); + if (stamp) { + current.startMs = stamp.ms; + current.allDay = stamp.allDay; + } + continue; + } + if (field.name === 'RRULE') { + current.recurrence = parseRecurrence(field.value, unsupported); + } + } + + return { events, unsupported: [...new Set(unsupported)] }; +} + +const MINUTE = 60_000; +const DAY = 24 * 60 * MINUTE; + +/** + * Does any occurrence of `event` begin during the minute containing `atMs`? + * + * Minute granularity, matching cron: the scheduler wakes on a timer and asks + * "what is due now", so a trigger that could only be observed to the second + * would fire or not depending on how promptly launchd got around to it. + * + * All-day entries never match. They name a day, not a moment, and picking one + * (midnight? 09:00?) would be this module inventing a schedule the user did not + * write. + */ +export function occursAt(event: IcsEvent, atMs: number): boolean { + if (event.allDay) return false; + const minuteStart = Math.floor(atMs / MINUTE) * MINUTE; + if (event.startMs === minuteStart) return true; + + const rule = event.recurrence; + if (!rule) return false; + if (minuteStart < event.startMs) return false; + if (rule.untilMs !== undefined && minuteStart > rule.untilMs) return false; + + // The time of day has to match the original; recurrence repeats the day, not + // the clock. + const timeOfDay = (ms: number): number => ms - Math.floor(ms / DAY) * DAY; + if (timeOfDay(minuteStart) !== timeOfDay(event.startMs)) return false; + + const daysApart = Math.round((minuteStart - event.startMs) / DAY); + + if (rule.freq === 'DAILY') { + if (daysApart % rule.interval !== 0) return false; + return withinCount(rule, Math.floor(daysApart / rule.interval)); + } + + // WEEKLY: the candidate must fall on a listed weekday, in an active week. + const weekday = new Date(minuteStart).getUTCDay(); + const days = rule.byDay.length > 0 ? rule.byDay : [new Date(event.startMs).getUTCDay()]; + if (!days.includes(weekday)) return false; + const weeksApart = Math.floor( + (startOfWeek(minuteStart) - startOfWeek(event.startMs)) / (7 * DAY), + ); + if (weeksApart % rule.interval !== 0) return false; + // COUNT counts occurrences, and a weekly rule with three BYDAY entries + // produces three per active week — not one. + return withinCount(rule, Math.floor(weeksApart / rule.interval) * days.length); +} + +function startOfWeek(ms: number): number { + const day = new Date(ms).getUTCDay(); + return Math.floor(ms / DAY) * DAY - day * DAY; +} + +function withinCount(rule: IcsRecurrence, index: number): boolean { + return rule.count === undefined || index < rule.count; +} + +/** Events whose summary contains `match` (case-insensitive); all of them when absent. */ +export function matchingEvents(calendar: IcsCalendar, match?: string): IcsEvent[] { + if (!match) return calendar.events; + const needle = match.toLowerCase(); + return calendar.events.filter((event) => event.summary.toLowerCase().includes(needle)); +} diff --git a/packages/core/src/cron/index.ts b/packages/core/src/cron/index.ts index d87e031..356b4fc 100644 --- a/packages/core/src/cron/index.ts +++ b/packages/core/src/cron/index.ts @@ -5,6 +5,12 @@ import { promises as fs } from 'node:fs'; import type { TriggerProfile } from './profile.js'; +import { + isTriggerDue, + resolveTrigger, + type TriggerSource, + type TriggerVerdict, +} from './triggers.js'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -20,8 +26,18 @@ export type UnattendedApprovalPolicy = 'deny' | 'abort'; export interface CronJob { id: string; - /** 5-field cron expression: "min hour day-of-month month day-of-week". */ + /** + * 5-field cron expression: "min hour day-of-month month day-of-week". + * + * Still the source when `trigger` is absent, which is every job written + * before triggers existed. Kept rather than migrated: a store nobody has to + * rewrite is a store that cannot be rewritten wrongly. + */ schedule: string; + /** + * Where this job's events come from. Absent means the `schedule` above. + */ + trigger?: TriggerSource; /** Prompt to run headlessly when the job fires. */ prompt: string; /** Working directory to run in. */ @@ -202,11 +218,66 @@ export function isCronDue(schedule: string, date: Date): boolean { return true; } -/** Enabled jobs due to run at `now`. */ +/** + * Enabled jobs due to run at `now`. + * + * Synchronous and cron-only. Kept because it is the shape every existing caller + * and test uses, and because a clock trigger needs nothing but the clock — but + * a job with a non-cron trigger is not decidable here, so it is skipped rather + * than guessed at. Use `dueJobsWithTriggers` to evaluate all of them. + */ export function dueJobs(jobs: CronJob[], now: Date): CronJob[] { - return jobs.filter((j) => j.enabled && isCronDue(j.schedule, now)); + return jobs.filter( + (j) => j.enabled && resolveTrigger(j).kind === 'cron' && isCronDue(j.schedule, now), + ); } +export interface DueJob { + job: CronJob; + verdict: TriggerVerdict; +} + +/** + * Every enabled job whose trigger has fired, whatever kind it is. + * + * Async because a calendar has to be read and a watched file has to be stat'd. + * Diagnostics ride along on the verdict so the scheduler can log "this + * calendar has an RRULE I cannot express" next to the job it belongs to, + * instead of on nobody's behalf. + */ +export async function dueJobsWithTriggers(jobs: CronJob[], now: Date): Promise { + const out: DueJob[] = []; + for (const job of jobs) { + if (!job.enabled) continue; + const verdict = await isTriggerDue( + resolveTrigger(job), + { now, cwd: job.cwd, lastRunAt: job.lastRunAt }, + isCronDue, + ); + if (verdict.due) out.push({ job, verdict }); + } + return out; +} + +export { + describeTrigger, + isTriggerDue, + resolveTrigger, + validateTrigger, + type TriggerContext, + type TriggerSource, + type TriggerVerdict, +} from './triggers.js'; + +export { + matchingEvents, + occursAt, + parseIcs, + type IcsCalendar, + type IcsEvent, + type IcsRecurrence, +} from './ics.js'; + export { describeClamp, resolveTriggerMode, diff --git a/packages/core/src/cron/triggers.test.ts b/packages/core/src/cron/triggers.test.ts new file mode 100644 index 0000000..f84783d --- /dev/null +++ b/packages/core/src/cron/triggers.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; +import { + describeTrigger, + isTriggerDue, + resolveTrigger, + validateTrigger, + type TriggerSource, +} from './triggers.js'; + +const alwaysDue = () => true; +const neverDue = () => false; +const at = (iso: string): Date => new Date(iso); + +const ICS = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'SUMMARY:Release meeting', + 'DTSTART:20260810T090000Z', + 'RRULE:FREQ=WEEKLY;BYDAY=MO', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'SUMMARY:Lunch', + 'DTSTART:20260810T120000Z', + 'END:VEVENT', + 'END:VCALENDAR', +].join('\r\n'); + +describe('resolveTrigger', () => { + it('falls back to the legacy schedule field', () => { + // Jobs written before triggers existed keep working with no migration — + // a store nobody has to rewrite cannot be rewritten wrongly. + expect(resolveTrigger({ schedule: '0 9 * * *' })).toEqual({ + kind: 'cron', + schedule: '0 9 * * *', + }); + }); + + it('prefers an explicit trigger', () => { + const trigger: TriggerSource = { kind: 'file', paths: ['schema.json'] }; + expect(resolveTrigger({ schedule: '0 9 * * *', trigger })).toBe(trigger); + }); +}); + +describe('isTriggerDue — cron', () => { + it('delegates to the cron matcher', async () => { + const source: TriggerSource = { kind: 'cron', schedule: '0 9 * * *' }; + const ctx = { now: at('2026-08-10T09:00:00Z'), cwd: '/work' }; + expect((await isTriggerDue(source, ctx, alwaysDue)).due).toBe(true); + expect((await isTriggerDue(source, ctx, neverDue)).due).toBe(false); + }); +}); + +describe('isTriggerDue — ics', () => { + const ctx = (iso: string, match?: string) => ({ + source: { kind: 'ics' as const, path: 'team.ics', ...(match ? { match } : {}) }, + ctx: { now: at(iso), cwd: '/work', readFile: async () => ICS }, + }); + + it('fires in the minute an event starts', async () => { + const { source, ctx: c } = ctx('2026-08-10T09:00:00Z'); + const verdict = await isTriggerDue(source, c, neverDue); + expect(verdict.due).toBe(true); + expect(verdict.reason).toMatch(/Release meeting/); + }); + + it('stays quiet in between', async () => { + const { source, ctx: c } = ctx('2026-08-10T09:30:00Z'); + expect((await isTriggerDue(source, c, neverDue)).due).toBe(false); + }); + + it('follows the recurrence, not just the first occurrence', async () => { + const { source, ctx: c } = ctx('2026-08-17T09:00:00Z'); // next Monday + expect((await isTriggerDue(source, c, neverDue)).due).toBe(true); + }); + + it('filters by summary', async () => { + const lunch = ctx('2026-08-10T09:00:00Z', 'lunch'); + expect((await isTriggerDue(lunch.source, lunch.ctx, neverDue)).due).toBe(false); + const release = ctx('2026-08-10T09:00:00Z', 'release'); + expect((await isTriggerDue(release.source, release.ctx, neverDue)).due).toBe(true); + }); + + it('reports an unreadable calendar instead of reading it as empty', async () => { + // "This job will never fire and should be fixed" and "an ordinary quiet + // minute" must not look the same in the log. + const verdict = await isTriggerDue( + { kind: 'ics', path: 'missing.ics' }, + { + now: at('2026-08-10T09:00:00Z'), + cwd: '/work', + readFile: async () => { + throw new Error('ENOENT: no such file'); + }, + }, + neverDue, + ); + expect(verdict.due).toBe(false); + expect(verdict.reason).toMatch(/calendar unreadable.*ENOENT/); + }); + + it('carries calendar diagnostics through to the caller', async () => { + const verdict = await isTriggerDue( + { kind: 'ics', path: 'team.ics' }, + { + now: at('2026-08-10T09:00:00Z'), + cwd: '/work', + readFile: async () => + [ + 'BEGIN:VEVENT', + 'SUMMARY:Billing', + 'DTSTART:20260810T090000Z', + 'RRULE:FREQ=MONTHLY', + 'END:VEVENT', + ].join('\r\n'), + }, + neverDue, + ); + expect(verdict.diagnostics?.join(' ')).toMatch(/FREQ=MONTHLY/); + }); +}); + +describe('isTriggerDue — file', () => { + const source: TriggerSource = { kind: 'file', paths: ['schema.json', 'gen/config.yaml'] }; + const now = at('2026-08-10T09:00:00Z'); + + it('records a baseline instead of firing on its first evaluation', async () => { + // Otherwise every file trigger fires the moment it is created, on files + // nobody has touched since anyone cared. + const verdict = await isTriggerDue( + source, + { now, cwd: '/work', statMtimeMs: async () => Date.parse('2020-01-01T00:00:00Z') }, + neverDue, + ); + expect(verdict.due).toBe(false); + expect(verdict.reason).toMatch(/baseline/); + }); + + it('fires when a watched path is newer than the last run', async () => { + const verdict = await isTriggerDue( + source, + { + now, + cwd: '/work', + lastRunAt: '2026-08-10T08:00:00Z', + statMtimeMs: async (p) => + p.endsWith('schema.json') ? Date.parse('2026-08-10T08:30:00Z') : null, + }, + neverDue, + ); + expect(verdict.due).toBe(true); + expect(verdict.reason).toMatch(/schema\.json changed/); + }); + + it('stays quiet when nothing moved', async () => { + const verdict = await isTriggerDue( + source, + { + now, + cwd: '/work', + lastRunAt: '2026-08-10T08:00:00Z', + statMtimeMs: async () => Date.parse('2026-08-10T07:00:00Z'), + }, + neverDue, + ); + expect(verdict.due).toBe(false); + }); + + it('treats a missing path as unchanged, not as an error every minute', async () => { + // A watched file being absent is a normal state — it may not have been + // generated yet. Reporting it each minute would bury the messages that + // matter. + const verdict = await isTriggerDue( + source, + { now, cwd: '/work', lastRunAt: '2026-08-10T08:00:00Z', statMtimeMs: async () => null }, + neverDue, + ); + expect(verdict).toEqual({ due: false }); + }); +}); + +describe('validateTrigger', () => { + it('rejects a source that cannot fire', () => { + expect(validateTrigger({ kind: 'cron', schedule: ' ' })).toMatch(/needs a schedule/); + expect(validateTrigger({ kind: 'ics', path: '' })).toMatch(/\.ics/); + expect(validateTrigger({ kind: 'file', paths: [] })).toMatch(/at least one path/); + }); + + it('accepts a usable one', () => { + expect(validateTrigger({ kind: 'cron', schedule: '0 9 * * *' })).toBeNull(); + expect(validateTrigger({ kind: 'ics', path: 'team.ics' })).toBeNull(); + expect(validateTrigger({ kind: 'file', paths: ['a'] })).toBeNull(); + }); +}); + +describe('describeTrigger', () => { + it('says what will make the job run', () => { + expect(describeTrigger({ kind: 'cron', schedule: '0 9 * * *' })).toBe('cron 0 9 * * *'); + expect(describeTrigger({ kind: 'ics', path: 't.ics', match: 'release' })).toBe( + 'calendar t.ics matching "release"', + ); + expect(describeTrigger({ kind: 'file', paths: ['a', 'b'] })).toBe('changes to a, b'); + }); +}); diff --git a/packages/core/src/cron/triggers.ts b/packages/core/src/cron/triggers.ts new file mode 100644 index 0000000..9dbc21c --- /dev/null +++ b/packages/core/src/cron/triggers.ts @@ -0,0 +1,150 @@ +// What makes a scheduled job fire. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §4 PR 8 — "触发源抽象(ICS / watch)" +// +// Until now the only answer was a clock: `cron` had a 5-field expression and +// nothing else. "Run the release checklist when the release meeting starts" and +// "re-run the generator when the schema changes" are both scheduling, and +// neither is a time. +// +// Every source is **polled**, not pushed. `deepcode scheduler run` already wakes +// on a timer and asks what is due; keeping that shape means no daemon, no +// watcher process, and no way for a trigger to fire while nothing is watching. +// It also means minute granularity for all of them, which is the granularity the +// user can actually observe. + +import { promises as fs } from 'node:fs'; +import { isAbsolute, resolve } from 'node:path'; +import { matchingEvents, occursAt, parseIcs, type IcsCalendar } from './ics.js'; + +export type TriggerSource = + | { kind: 'cron'; schedule: string } + /** A local `.ics` file. Standard calendar text, no vendor SDK, no account. */ + | { kind: 'ics'; path: string; match?: string } + /** Paths whose modification time moving forward is the event. */ + | { kind: 'file'; paths: string[] }; + +export interface TriggerContext { + /** The moment being evaluated. */ + now: Date; + /** The job's working directory, for resolving relative paths. */ + cwd: string; + /** + * When this job last ran. A file trigger compares against it, so a job with + * no recorded run establishes a baseline instead of firing — otherwise every + * file trigger fires once the moment it is created, on files that have not + * changed since anyone cared. + */ + lastRunAt?: string; + /** Injectable for tests; defaults to reading the filesystem. */ + readFile?: (path: string) => Promise; + statMtimeMs?: (path: string) => Promise; +} + +export interface TriggerVerdict { + due: boolean; + /** Shown in the job log — why it fired, or why it could not be evaluated. */ + reason?: string; + /** Non-fatal problems, e.g. calendar constructs the reader cannot express. */ + diagnostics?: string[]; +} + +/** + * The source a job uses. + * + * `trigger` wins when present; otherwise the legacy `schedule` string is the + * source. Jobs written before this existed keep working untouched, and a job + * file never has to be migrated. + */ +export function resolveTrigger(job: { schedule?: string; trigger?: TriggerSource }): TriggerSource { + if (job.trigger) return job.trigger; + return { kind: 'cron', schedule: job.schedule ?? '' }; +} + +/** A one-line description for `cron list` and the job log. */ +export function describeTrigger(source: TriggerSource): string { + switch (source.kind) { + case 'cron': + return `cron ${source.schedule}`; + case 'ics': + return `calendar ${source.path}${source.match ? ` matching "${source.match}"` : ''}`; + case 'file': + return `changes to ${source.paths.join(', ')}`; + } +} + +async function defaultRead(path: string): Promise { + return fs.readFile(path, 'utf8'); +} + +async function defaultMtime(path: string): Promise { + try { + return (await fs.stat(path)).mtimeMs; + } catch { + return null; + } +} + +export async function isTriggerDue( + source: TriggerSource, + ctx: TriggerContext, + isCronDue: (schedule: string, date: Date) => boolean, +): Promise { + if (source.kind === 'cron') { + return { due: isCronDue(source.schedule, ctx.now) }; + } + + const at = (p: string): string => (isAbsolute(p) ? p : resolve(ctx.cwd, p)); + + if (source.kind === 'ics') { + let calendar: IcsCalendar; + try { + calendar = parseIcs(await (ctx.readFile ?? defaultRead)(at(source.path))); + } catch (error) { + // An unreadable calendar is reported, not treated as "no events". The + // difference matters: one is a job that will never fire and should be + // fixed, the other is an ordinary quiet minute. + return { due: false, reason: `calendar unreadable: ${(error as Error).message}` }; + } + const events = matchingEvents(calendar, source.match); + const hit = events.find((event) => occursAt(event, ctx.now.getTime())); + return { + due: hit !== undefined, + ...(hit ? { reason: `calendar event "${hit.summary || '(untitled)'}" starts now` } : {}), + ...(calendar.unsupported.length > 0 ? { diagnostics: calendar.unsupported } : {}), + }; + } + + // file: fire when anything watched is newer than the last run. + const since = ctx.lastRunAt ? Date.parse(ctx.lastRunAt) : Number.NaN; + const stat = ctx.statMtimeMs ?? defaultMtime; + if (!Number.isFinite(since)) { + return { + due: false, + reason: 'first evaluation — recording a baseline rather than firing on files nobody touched', + }; + } + for (const path of source.paths) { + const mtime = await stat(at(path)); + // A missing path is not a change. Reporting it every minute would bury the + // one message that matters, and a watched file being absent is a normal + // state — it may not have been generated yet. + if (mtime !== null && mtime > since) { + return { due: true, reason: `${path} changed` }; + } + } + return { due: false }; +} + +/** Structural validation for a trigger written by a user or a tool. */ +export function validateTrigger(source: TriggerSource): string | null { + switch (source.kind) { + case 'cron': + return source.schedule.trim() ? null : 'cron trigger needs a schedule'; + case 'ics': + return source.path.trim() ? null : 'calendar trigger needs a path to an .ics file'; + case 'file': + return source.paths.length > 0 ? null : 'file trigger needs at least one path'; + default: + return `unknown trigger kind: ${(source as { kind: string }).kind}`; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 278467f..bc70ceb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -455,6 +455,14 @@ export { validateCronExpr, isCronDue, dueJobs, + dueJobsWithTriggers, + describeTrigger, + isTriggerDue, + matchingEvents, + occursAt, + parseIcs, + resolveTrigger, + validateTrigger, resolveUnattendedApproval, describeClamp, resolveTriggerMode, @@ -464,6 +472,13 @@ export { type TriggerProfile, type CronJob, type CronStore, + type DueJob, + type IcsCalendar, + type IcsEvent, + type IcsRecurrence, + type TriggerContext, + type TriggerSource, + type TriggerVerdict, type UnattendedApprovalPolicy, } from './cron/index.js';