Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 视频逐段录制脚本 |

### 设计文档
Expand Down
32 changes: 27 additions & 5 deletions apps/cli/src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
// so it survives `nvm`/path quirks) and best-effort `launchctl load`s it.

import {
dueJobs,
dueJobsWithTriggers,
resolveTrigger,
installPlist,
launchdPlistPath,
listCronJobs,
Expand Down Expand Up @@ -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`);
}
Expand Down
18 changes: 9 additions & 9 deletions docs/FLOATBOAT_ADOPTION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 提的四个):

Expand Down
119 changes: 119 additions & 0 deletions docs/triggers.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading