OpenWork Automations 领域包:无基础设施依赖的定时任务调度契约与宿主机引擎生命周期设计
【免费下载链接】openworkThe open-source alternative to Claude Cowork (powered by opencode)项目地址: https://gitcode.com/GitHub_Trending/ope/openwork
导读
@openwork/automations是 OpenWork 中由云端 Den 与自托管(on-prem)Den 共享的纯领域包,它不依赖任何基础设施运行时,而是以类型契约的形式完整定义了一次性(once)、每日(daily)与每周(weekly)调度的确定性计算、DST 行为、生命周期迁移、修订摘要(revision digest)、发生次标识(occurrence identity)、幂等性、仓储与引擎适配器端口、到期工作选择、有界错失恢复,以及仓储一致性辅助工具。本文基于 packages/automations/README.md 并深入该包源码,讲解 Automations 领域模型的全部契约细节、调度算法的确定性实现、Den 宿主机引擎生命周期协议,以及如何通过内置的一致性校验工具验证自己的仓储实现,帮助你在自托管或二次开发场景中正确接入该领域包。
领域边界:纯契约,无运行时适配器
@openwork/automations的设计目标可以概括为一句话:它只拥有领域逻辑,绝不拥有运行时。这一点在包的 README 中开宗明义——"Pure, infrastructure-free Automations domain shared by hosted and on-prem Den"。
从 package.json 可以看到,该包的运行时依赖仅有@openwork/types(workspace 内部类型包)与zod(用于 schema 校验),其余基础设施能力全部通过端口(port)以接口形式暴露:
- MySQL 持久化、租赁(lease)、成员与模型校验由 Den 提供;
- Connect 访问与模型执行适配器由 Den 提供;
- OpenWork 桌面端只是 Den 的客户端,永远不会成为 Automations 的调度器或执行宿主。
与之对应,src/ports.ts 定义了唯一的仓储边界AutomationRepository接口,其中明确注释了"Claims and revision updates must be transactional"(认领与修订更新必须事务化)。该接口涵盖了create、update、list、get、setState、listDue、claim、heartbeat、appendEvent、complete、recoverExpiredLeases、requestCancellation、getRunReceipt、listRuns等全部领域操作。
关键领域概念清单
从 src/index.ts 的导出可以看到,领域包由七个模块组成:
| 模块 | 职责 |
|---|---|
contracts.ts | 修订摘要(revision digest)、发生次身份(occurrence identity) |
schedule.ts | 确定性调度计算、DST 行为、未来发生次预览 |
engine.ts | 宿主机引擎适配器契约、事件序列校验器 |
ports.ts | 仓储与列表项端口(AutomationRepository、AutomationListItem) |
runner.ts | 桌面 runner 认领窗口、在线判断、错失原因诊断 |
state.ts | 生命周期状态机与迁移校验 |
tick.ts | 到期工作选择(due-work selection)与认领推进 |
调度契约:一次性与循环调度的确定性计算
调度是 Automations 领域包的核心资产。调度的数据模型定义在类型包 packages/types/src/automations.ts 中,使用 zod 判别联合(discriminated union):
export const automationScheduleSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("once"), timezone: timezoneSchema, at: timestampSchema }), z.object({ kind: z.literal("daily"), timezone: timezoneSchema, hour: z.number().int().min(0).max(23), minute: z.number().int().min(0).max(59), }), z.object({ kind: z.literal("weekly"), timezone: timezoneSchema, daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).max(7) .transform((days) => [...new Set(days)].sort((left, right) => left - right)), hour: z.number().int().min(0).max(23), minute: z.number().int().min(0).max(59), }), ]) export type AutomationSchedule = z.infer<typeof automationScheduleSchema>要点:
once:指定timezone与绝对时间戳at,只触发一次;daily:指定timezone、hour(0–23)、minute(0–59);weekly:额外指定daysOfWeek,取值范围 0–6(对应周日到周六),至少 1 个、至多 7 个;schema 会自动去重并升序排序,保证同一配置序列化后字节级一致;timezone必须是合法的 IANA 时区名(如UTC、Asia/Shanghai),类型包通过Intl.DateTimeFormat校验。
发生次搜索:自动化发生次计算的确定性实现
src/schedule.ts 中automationOccurrences(input, options)是全部调度计算的核心,返回{ occurrences: number[]; warnings: string[] }:
export function automationOccurrences( input: AutomationSchedule, options: AutomationOccurrenceSearchOptions, ): { occurrences: number[]; warnings: string[] } { const schedule = automationScheduleSchema.parse(input) assertAutomationTimezone(schedule.timezone) const count = Math.max(0, Math.min(options.count ?? 5, 5)) // once: 直接返回 at > after 的单个时间戳 // daily/weekly: 从 after 的本地时间开始,逐日扫描最多 370 天, // 对每个应触发日解析"墙钟时刻 → 绝对时间戳" }几个值得注意的实现细节:
- 每次调用都会先用 schema 校验输入,保证发生次计算的输入永远是规范化的;
- 单次最多返回 5 个发生次(
count上限被钳制到 5); - 搜索窗口上限 370 天,避免极端边界下无限循环;
nextAutomationOccurrence(schedule, after)只是automationOccurrences取count: 1的便捷封装。
DST 行为:墙钟时间到绝对时间戳的解析
时区处理是调度系统最容易出错的地方。schedule.ts通过Intl.DateTimeFormat以目标时区格式化时间戳,解析出"本地年月日时分与星期",再用 UTC 构造名义时间戳nominal,随后在nominal ± 18 小时的窗口内逐分钟扫描,寻找"本地时间恰好等于目标墙钟时间"的绝对时间戳:
const nominal = Date.UTC(date.year, date.month - 1, date.day, hour, minute) const start = nominal - SEARCH_WINDOW_HOURS * 60 * 60 * 1_000 // 18 小时前 const end = nominal + SEARCH_WINDOW_HOURS * 60 * 60 * 1_000 // 18 小时后这样既处理了春季调快(spring-forward)时墙钟时间不存在的情况(返回shifted: true,向后取下一个有效分钟),也处理了秋季调慢(fall-back)时墙钟时间重复的情况(优先返回精确匹配,不重复触发)。当发生 DST 偏移时,会向调用方返回一条 warning:
A wall-clock occurrence falls inside a daylight-saving transition and was shifted to the next valid minute in <timezone>.previewAutomationSchedule(input, options)是对外暴露的预览入口,返回{ schedule, generatedAt, occurrences, warnings },其中occurrences固定取 5 个,warnings用于向前端透传 DST 提示——这正是调度创建/编辑界面"未来 5 次运行时间"预览的数据来源。
有界错失恢复:只补最近一次,绝不重放积压
src/schedule.ts 末尾的recoverableAutomationOccurrence实现了一个重要的产品语义:
/** Returns at most the latest missed occurrence; older backlog is never replayed. */ export function recoverableAutomationOccurrence( schedule: AutomationSchedule, input: { after: number; now: number }, ): number | null { const occurrences = automationOccurrences(schedule, { after: input.after, count: 5 }).occurrences .filter((occurrence) => occurrence <= input.now) return occurrences.at(-1) ?? null }即:当 Den 宕机一段时间后重启,面对积压的多个错失发生次,只认领最近一次,更早的积压直接丢弃,避免恢复时瞬间洪泛执行历史任务。这与 runner 模块中"错失窗口不会跨到下一个发生次"的设计互为犄角。
修订摘要与发生次身份:幂等性的两个基石
修订摘要(revision digest)
src/contracts.ts 中的automationRevisionDigest对修订内容(instructions、schedule、model、maximumRuntimeMs,以及可选的action、executionTarget、workspaceId)做可移植的稳定摘要:
- 先通过
canonical()将对象递归规范化为键按字典序排序的确定字符串; - 再用双哈希(FNV-1a 变体
0x811c9dc5与0x9e3779b9混合)产出 16 位十六进制摘要; - 注释明确:持久化层可以额外使用密码学摘要,但领域包本身保证摘要跨进程、跨平台可复现;
- 特别强调:只有当
workspaceId被设置时才参与摘要——因为固定工作区是行为变更,而 pinning 功能出现之前创建的记录必须保持字节级一致的摘要。
发生次身份(occurrence identity)
同文件中的automationOccurrenceIdentity(input)为每次运行产出occurrenceId与idempotencyKey:
const occurrence = input.scheduledFor === null ? `manual:${input.nonce}` : String(input.scheduledFor) const stable = [input.automationId, occurrence].map(encodeURIComponent).join(":") return { occurrenceId: `automation-occurrence:${stable}`, idempotencyKey: `automation:${stable}`, }设计要点:
- 定时发生次以
automationId + scheduledFor(绝对时间戳)作为稳定身份; - 手动发生次必须携带
nonce(否则直接抛错),身份为manual:<nonce>; idempotencyKey是 Den 持久化事件与认领去重的键,重复投递同一发生次不会产生重复运行。
生命周期状态机:受控的状态迁移
src/state.ts 定义了 Automations 的四个状态与迁移表:
const transitions: Readonly<Record<AutomationState, readonly AutomationState[]>> = { active: ["inactive", "needs_attention", "archived"], inactive: ["active", "needs_attention", "archived"], needs_attention: ["active", "inactive", "archived"], archived: [], // 终态,不可再迁移 }canTransitionAutomation(from, to)允许同状态幂等迁移(from === to),assertAutomationTransition则在非法迁移时抛出Invalid Automation transition: <from> -> <to>。此外,isTerminalAutomationRunStatus将运行状态succeeded / failed / cancelled / skipped视为终态。Den 的/v1/automations/:id/activate与/v1/automations/:id/deactivate路由(见 ee/apps/den-api/src/routes/automations/index.ts)正是基于这一状态机的薄封装。
到期工作选择与认领推进
src/tick.ts 是调度器"心跳"逻辑的纯函数部分:
selectDueAutomations(candidates, { now, limit }):过滤出state === "active"且nextDueAt <= now的条目,按nextDueAt升序(相同时按automationId字典序)排序,取前limit个(limit 被钳制在 1–500);hasActiveRun(runs):存在claimed或running状态即视为有活跃运行,用于判断是否允许新认领;nextAutomationAfterClaim(automation, nextDueAt, now):认领后推进nextDueAt并刷新latestRunAt、updatedAt。
配套的AutomationClaimResult判别联合(src/ports.ts)区分三种认领结果:
claimed:本次认领成功,携带run与revision;duplicate:同一发生次已被认领(幂等去重);overlap:已有活跃运行,新发生次与现有运行重叠,应跳过。
桌面 runner 的窗口语义与错失诊断
OpenWork 桌面端不参与调度,只作为执行 runner。 src/runner.ts 为此定义了三个关键语义:
有界认领窗口
export const AUTOMATION_MIN_CLAIM_WINDOW_MS = 60_000 export function desktopClaimDeadline(input: { now: number; windowMs: number; nextDueAt: number | null }): number { const requested = input.now + input.windowMs const bounded = input.nextDueAt === null ? requested : Math.min(requested, input.nextDueAt) const floor = input.now + Math.min(input.windowMs, AUTOMATION_MIN_CLAIM_WINDOW_MS) return Math.max(floor, bounded) }注释给出了关键设计动机:桌面是笔记本电脑,会休眠、重启、切换网络,所以认领窗口是"恢复窗口"而非"存活检查"——只要桌面在窗口内回来,就仍会执行该发生次,而不是让操作者面对一次错失运行。同时窗口绝不会跨到下一个发生次:一个仍未认领的每小时 10:00 运行必须在 11:00 到期前释放,否则后续发生次会因重叠而被跳过。
持久在线判断
export function desktopRunnerConnected(input: { lastSeenAt: number | null; now: number }): boolean { return input.lastSeenAt !== null && input.now - input.lastSeenAt <= AUTOMATION_DESKTOP_RUNNER_PRESENCE_WINDOW_MS }在线状态是**持久化(durable)而非实时(live)**的:注册每隔几分钟刷新一次,空闲事件流刻意避免写数据库,因此桌面在最后一次被看到之后的一段时间内仍被视为在线。
错失原因诊断
missedDesktopRunMessage将错失原因区分为三种可操作的结果(源码注释直言:一个笼统的结果曾掩盖真实缺陷数周):
- 桌面正忙(
busy)→Missed — the desktop was busy with another Automation run. - 桌面在线但未认领 →
Missed — the connected desktop did not pick this up in time. - 无桌面在线 →
Missed — no desktop was connected.
这与 docs/features/automations-desktop-runner/README.md 描述的离线行为一致:认领窗口到期后 Den 持久化记录skipped(runner_unavailable),应用显示Missed — desktop runner unavailable;而没有任何桌面 SSE 连接时手动触发 Run now 会立即失败并返回No desktop runner is online。
宿主机引擎生命周期:幂等准入、持久化重挂载与顺序事件
AutomationEngineAdapter(src/engine.ts)是托管运行与执行引擎之间的提供者中立边界:
export interface AutomationEngineAdapter { capabilities(): Promise<AutomationEngineCapabilityDeclaration> admit(request: AutomationEngineAdmissionRequest): Promise<AutomationEngineAdmissionReceipt> observe(receipt: AutomationEngineAdmissionReceipt, options?: AutomationEngineObserveOptions): AsyncIterable<AutomationEngineEvent> read(receipt: AutomationEngineAdmissionReceipt): Promise<AutomationEngineReadResult | null> cancel(receipt: AutomationEngineAdmissionReceipt): Promise<AutomationEngineCancellationResult> }能力声明与隔离约束
automationEngineCapabilityDeclarationSchema强制适配器声明(src/engine.ts):
admission: "idempotent"、reattachment: "receipt"、eventDelivery: "ordered_at_least_once"、resultPersistence: "durable";cancellation可为supported / best_effort / unsupported;isolation明确:运行位置在云端(cloud)、无文件系统、无 shell、无浏览器、无 computer 工具、Connect 访问按运行作用域隔离(run-scoped)、网络仅限提供者与 Connect(provider-and-connect-only)。
准入协议与接收凭证
生命周期协议(src/engine.ts)的核心规则:
- Den 先创建并持久化准入键(admission key),再调用
admit;重试同一个准入键必须返回同一份"持久化安全"的接收凭证(receipt)——这是幂等准入; - 能力访问令牌(
capabilityAccess,含 endpoint 与 bearerToken)只属于准入请求本身,绝不能复制进 receipt——receipt 中的attachment对 Den 是不透明的(opaque),适配器自行定义其形状与解释,但 Den 永远读不到令牌; automationEngineAdmissionRequestSchema通过superRefine交叉校验:修订必须属于该 Automation,运行必须属于该修订,防止跨实体串号。
事件持久化与崩溃恢复
事件流协议是"先持久化、后推进游标"的顺序:
- 每个事件带稳定幂等键(idempotencyKey)与严格递增的执行内序列号(sequence);
- Den 在推进连续序列游标之前先用稳定幂等键持久化每个观察到的事件;
- 进程重启后,Den 加载已持久化的 receipt 与游标,调用
read获取持久化状态/结果,然后无需内存中的引擎句柄即可从observe(receipt, { afterSequence })恢复订阅; - 取消(
cancel)使用同一份 receipt,因此取消操作在调度器所有权变更与 Den 重启后依然有效。
createAutomationEngineEventSequenceValidator(src/engine.ts)在事件落库前做四重校验:
executionId与runId必须匹配 receipt(防止串执行);sequence必须等于cursor + 1(连续性,跳号即抛错);idempotencyKey不得重复(幂等性);- 校验通过后才把事件交给 Den 持久化并推进游标。
引擎结果(automationEngineResultSchema)同样有交叉约束:succeeded状态不得携带error,failed/cancelled状态必须携带error。
仓储一致性校验:接入自有存储的正确姿势
自托管或二次开发时若需要实现自己的AutomationRepository,领域包提供了现成的一致性校验器。src/testing.ts 中的verifyAutomationRepositoryConformance(repository)会依次验证:
- 事务化创建即 active:新建的 Automation 状态必须为
active; - 初始修订持久化:
get返回的修订 id 必须等于创建时的修订 id; - 组织隔离:用其他
organizationId查询必须返回null; - 修订不可变:
update后修订版本号必须递增,且新修订 id 不能复用旧修订(不允许原地修改); - 认领去重:同一发生次被 replica A 认领后,replica B 再认领必须返回非
claimed结果,验证 scheduled 与 recovery 两种触发方式的去重。
该函数返回checked: string[],逐条列出通过的检查项,任何一项失败都会抛出异常——这正是 ee/apps/den-api/src/automations/repository.ts 中 MySQL 仓储实现所通过的测试基准。包内同时提供 engine 测试辅助(src/engine-testing.ts)与核心行为测试(src/core.test.ts、src/schedule.test.ts 等)。
在 Den 中的实际接线
虽然领域包本身不提供运行时,但仓库中的云端 Den(EE 部分)展示了完整接线方式:
- ee/apps/den-api/src/app.ts 注册自动化路由,并通过
configureCloudAgentExecutor/configureCloudWorkflowExecutor配置云执行器; - ee/apps/den-api/src/automations/repository.ts 以 MySQL 实现
AutomationRepository端口; - ee/apps/den-api/src/automations/service.ts 使用
AUTOMATION_MIN_CLAIM_WINDOW_MS与desktopRunnerConnected编排认领与在线判断; - ee/apps/den-api/src/routes/automations/index.ts 暴露
/v1/automations系列 REST 路由(创建、列表、激活/停用、手动 Run now、运行记录查询等)。
桌面端通过 SSE 订阅唤醒、以 HTTP 原子认领发生次,并以"认领→心跳→顺序事件→取消→完成"的协议回报执行过程,具体离线与错失行为见 docs/features/automations-desktop-runner/README.md。
小结
@openwork/automations是一个教科书式的"领域层与基础设施解耦"实践:所有调度计算(含 DST)、状态机、幂等身份、顺序事件协议与认领语义都以纯函数和 zod schema 形式沉淀在 packages/automations/src 中,可被云端与自托管 Den 零成本复用;而 MySQL、租赁、模型校验、Connect 与执行引擎全部由 Den 通过AutomationRepository与AutomationEngineAdapter两个端口注入。理解这套契约,既是接入自托管调度的前提,也是审查 OpenWork 自动化可靠性的最佳入口。
【免费下载链接】openworkThe open-source alternative to Claude Cowork (powered by opencode)项目地址: https://gitcode.com/GitHub_Trending/ope/openwork
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考