@x-otto/schedule 0.0.1-alpha.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.
- package/README.md +88 -0
- package/dist/index.d.ts +472 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @x-otto/schedule
|
|
2
|
+
|
|
3
|
+
> 定时任务调度系统——cron 表达式解析、任务持久化、ticker 驱动递归/一次性任务执行、多进程 fire ownership 仲裁。
|
|
4
|
+
|
|
5
|
+
`@x-otto/schedule` 提供完整的定时任务生命周期管理:创建 cron 任务 → 持久化到磁盘/远程 → ticker 心跳触发 → agent job 派发 → 重试/熔断 → 日志。支持本地 O_EXCL 锁与远程 HTTP 两种 fire ownership 模式。
|
|
6
|
+
|
|
7
|
+
## 核心功能
|
|
8
|
+
|
|
9
|
+
| 模块 | 功能 |
|
|
10
|
+
|------|------|
|
|
11
|
+
| `SchedulerService` | 调度器核心:ticker 循环、fire 执行、重试、cancel |
|
|
12
|
+
| `ScheduleRegistry` | 内存中的任务注册表(Map-backed CRUD + findDue) |
|
|
13
|
+
| `scheduleStore` | 任务持久化(本地文件 / 远程 HTTP) |
|
|
14
|
+
| `parseCron` | 5 字段 cron 表达式解析器(DST-safe) |
|
|
15
|
+
| `FireOwnership` | 多进程 fire 锁仲裁(本地 O_EXCL / 远程 lease) |
|
|
16
|
+
| `createScheduleCapability` | 供模型 tool 消费的 schedule_* 能力绑定 |
|
|
17
|
+
| `cron-parser` | cron 字段语法:wildcard/step/range/list/step-range |
|
|
18
|
+
|
|
19
|
+
## 安装
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm add @x-otto/schedule
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 快速开始
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { SchedulerService, createScheduleStore, createLocalFireOwnership, ScheduleRegistry, parseCron } from '@x-otto/schedule'
|
|
29
|
+
import type { AgentJobRegistry } from '@x-otto/runtime'
|
|
30
|
+
|
|
31
|
+
const registry = new ScheduleRegistry()
|
|
32
|
+
const store = createScheduleStore('ws_xxxx')
|
|
33
|
+
const ownership = createLocalFireOwnership('ws_xxxx')
|
|
34
|
+
const scheduler = new SchedulerService({
|
|
35
|
+
registry,
|
|
36
|
+
store,
|
|
37
|
+
ownership,
|
|
38
|
+
jobRegistry,
|
|
39
|
+
startJob: ({ title, prompt, sessionId, origin }) => jobRegistry.start({ title, prompt, sessionId, origin }),
|
|
40
|
+
getSessionId: () => 'current-session-id',
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
await scheduler.start() // 加载任务 + 启动 ticker(fire owner 才启动)
|
|
44
|
+
scheduler.add({ name: 'daily-digest', prompt: 'send daily summary', recurring: true, cronExpression: '0 9 * * 1-5', origin: 'user' })
|
|
45
|
+
scheduler.list() // 所有任务,按 nextFireAt 升序
|
|
46
|
+
scheduler.runNow('task-id') // 立即执行
|
|
47
|
+
scheduler.cancel('task-id') // 取消正在执行的任务
|
|
48
|
+
await scheduler.stop() // 停止 ticker + release 锁
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 目录概览
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
src/
|
|
55
|
+
types.ts # ScheduledTask / ScheduledTaskInput / CancelResult
|
|
56
|
+
cron-parser.ts # 5 字段 cron 解析器
|
|
57
|
+
registry.ts # ScheduleRegistry(Map-backed CRUD + findDue)
|
|
58
|
+
schedule-store.ts # 本地 ScheduleStore(FilePersistence 实现)
|
|
59
|
+
remote-schedule-store.ts # 远程 ScheduleStore(RemotePersistence 实现)
|
|
60
|
+
fire-ownership.ts # 本地 FireOwnership(O_EXCL lockfile + 活度探针)
|
|
61
|
+
remote-fire-ownership.ts # 远程 FireOwnership(HTTP lease + 30s heartbeat)
|
|
62
|
+
schedule-lease-migrations.ts # 远程 lease 表 migration 段
|
|
63
|
+
scheduler.ts # SchedulerService(ticker/fire/重试/cancel/日志)
|
|
64
|
+
schedule-capability.ts # schedule_* 模型 tool 能力适配层
|
|
65
|
+
index.ts
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 关键配置
|
|
69
|
+
|
|
70
|
+
- 最大任务数:`MAX_TASKS = 50`
|
|
71
|
+
- 周期性任务抖窗:30 分钟
|
|
72
|
+
- 一次性任务抖窗:90 秒
|
|
73
|
+
- 本地锁过期:5 分钟
|
|
74
|
+
- 远程心跳:30 秒,远程锁过期 180 秒
|
|
75
|
+
- ticker 间隔:1 秒
|
|
76
|
+
- 单个任务默认超时:5 分钟
|
|
77
|
+
|
|
78
|
+
## 依赖
|
|
79
|
+
|
|
80
|
+
- Internal: `@x-otto/env`, `@x-otto/persistence`, `@x-otto/runtime`
|
|
81
|
+
|
|
82
|
+
## 相关
|
|
83
|
+
|
|
84
|
+
- [Architecture](./ARCHITECTURE.md)
|
|
85
|
+
- RFC-87 调度系统初始设计
|
|
86
|
+
- RFC-169 调度系统多进程(task/resilience/ownership)
|
|
87
|
+
- RFC-229 取消与日志增强
|
|
88
|
+
- RFC-230 重试间隔配置
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { Migration, Persistence } from "@x-otto/persistence";
|
|
2
|
+
import { AgentJobRegistry } from "@x-otto/runtime";
|
|
3
|
+
|
|
4
|
+
//#region src/types.d.ts
|
|
5
|
+
/** A scheduled task persisted to ${OTTO_HOME}/schedules/<id>.json */
|
|
6
|
+
interface ScheduledTask {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
prompt: string;
|
|
10
|
+
cronExpression: string;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
recurring: boolean;
|
|
13
|
+
origin: 'user' | 'model';
|
|
14
|
+
createdAt: number;
|
|
15
|
+
lastFiredAt?: number;
|
|
16
|
+
nextFireAt: number;
|
|
17
|
+
maxDurationMs: number;
|
|
18
|
+
maxRetries: number;
|
|
19
|
+
/**
|
|
20
|
+
* review S6: set when a one-shot task exhausted all retries without success. Previously
|
|
21
|
+
* one-shot tasks were silently deleted on any outcome (success or failure) with no
|
|
22
|
+
* user-visible trace beyond the best-effort schedule-logs file. A failed one-shot is now
|
|
23
|
+
* kept (not deleted) so the user can see it in `/schedule list` and retry via `runNow`.
|
|
24
|
+
*/
|
|
25
|
+
lastFireFailed?: boolean;
|
|
26
|
+
lastFireError?: string;
|
|
27
|
+
/**
|
|
28
|
+
* RFC-229 M1: set when a one-shot task was canceled by the user (via `cancel()`).
|
|
29
|
+
* Distinguished from `lastFireFailed` so the UI renders "canceled" not "failed";
|
|
30
|
+
* the ticker's one-shot guard also checks this to prevent auto-refire after cancel.
|
|
31
|
+
*/
|
|
32
|
+
lastFireCancelled?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* RFC-327 D2:到点投递目标。缺省 = 派发 AgentJob(既有全部任务均如此)。
|
|
35
|
+
* 指定 `plugin-service` 时 fire 改走通知路径,不创建 AgentJob。
|
|
36
|
+
*/
|
|
37
|
+
subscriber?: ScheduleSubscriber;
|
|
38
|
+
}
|
|
39
|
+
/** RFC-229 M1: structured result from SchedulerService.cancel() */
|
|
40
|
+
interface CancelResult {
|
|
41
|
+
ok: boolean;
|
|
42
|
+
reason?: 'task_not_found' | 'not_running' | 'job_not_found' | 'job_is_ready' | 'job_already_applied' | 'job_already_failed' | 'job_already_canceled';
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* RFC-327 D2:到点后的**投递目标**。
|
|
46
|
+
*
|
|
47
|
+
* 缺省(字段不存在)= 既有语义:派发一个跑 `prompt` 的 AgentJob。
|
|
48
|
+
* `plugin-service` = 通知一个已订阅的插件常驻服务,**不派 AgentJob**——服务若要产生工作,
|
|
49
|
+
* 仍须自己走 capability-gated `startJob`(timer 触发不绕过审批/origin/worktree 约束)。
|
|
50
|
+
*
|
|
51
|
+
* 为什么放在 ScheduledTask 而不是另建一套订阅表:cron 解析、持久化、fire ownership、
|
|
52
|
+
* 崩溃恢复这些能力必须**只有一份**(RFC-327 R4 timer 单一真源)。另建订阅表等于复制
|
|
53
|
+
* 整套调度设施,正是 RFC 列为"放弃方案"的路径。
|
|
54
|
+
*/
|
|
55
|
+
interface ScheduleSubscriber {
|
|
56
|
+
kind: 'plugin-service';
|
|
57
|
+
pluginId: string;
|
|
58
|
+
serviceId: string;
|
|
59
|
+
}
|
|
60
|
+
/** Input for creating a task (excludes runtime fields) */
|
|
61
|
+
interface ScheduledTaskInput {
|
|
62
|
+
name: string;
|
|
63
|
+
prompt: string;
|
|
64
|
+
cronExpression: string;
|
|
65
|
+
recurring: boolean;
|
|
66
|
+
origin: 'user' | 'model';
|
|
67
|
+
maxDurationMs?: number;
|
|
68
|
+
maxRetries?: number;
|
|
69
|
+
/** RFC-327 D2:缺省 = 派发 AgentJob(既有语义);指定则改为通知插件服务。 */
|
|
70
|
+
subscriber?: ScheduleSubscriber;
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/registry.d.ts
|
|
74
|
+
declare class ScheduleRegistry {
|
|
75
|
+
private tasks;
|
|
76
|
+
constructor();
|
|
77
|
+
add(task: ScheduledTask): void;
|
|
78
|
+
remove(id: string): boolean;
|
|
79
|
+
update(id: string, patch: Partial<Pick<ScheduledTask, 'enabled' | 'lastFiredAt' | 'nextFireAt' | 'maxDurationMs' | 'maxRetries' | 'lastFireFailed' | 'lastFireError' | 'lastFireCancelled'>>): boolean;
|
|
80
|
+
get(id: string): ScheduledTask | undefined;
|
|
81
|
+
list(): ScheduledTask[];
|
|
82
|
+
/**
|
|
83
|
+
* Find tasks that are due for execution.
|
|
84
|
+
* Returns all enabled tasks (recurring or one-shot) whose nextFireAt <= now.
|
|
85
|
+
* The 'firing' guard is a scheduler concern (managed via a separate Set).
|
|
86
|
+
*/
|
|
87
|
+
findDue(now: number): ScheduledTask[];
|
|
88
|
+
count(): number;
|
|
89
|
+
has(id: string): boolean;
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/cron-parser.d.ts
|
|
93
|
+
/**
|
|
94
|
+
* Parse a 5-field cron expression (minute hour dom month dow) and return the next
|
|
95
|
+
* fire time >= from (inclusive). All times in local timezone.
|
|
96
|
+
*
|
|
97
|
+
* Throws on invalid expressions.
|
|
98
|
+
*
|
|
99
|
+
* Field syntax: wildcard (*), single value (5), step (e.g. \*\/15 or 5\/15),
|
|
100
|
+
* range (1-5), list (1,15,30), range with step (1-10\/2).
|
|
101
|
+
*
|
|
102
|
+
* Unsupported: L, W, ?, #, name aliases (MON, JAN).
|
|
103
|
+
*
|
|
104
|
+
* ## DoM∧DoW semantics
|
|
105
|
+
*
|
|
106
|
+
* If both day-of-month and day-of-week are constrained (not a bare *),
|
|
107
|
+
* a date matches if **EITHER** field matches (standard vixie-cron OR behaviour).
|
|
108
|
+
*
|
|
109
|
+
* ## Day-of-week mapping
|
|
110
|
+
*
|
|
111
|
+
* 0 = Sunday, 1–6 = Monday–Saturday, 7 = also Sunday.
|
|
112
|
+
*/
|
|
113
|
+
declare function parseCron(expr: string, from?: Date): Date;
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/schedule-store.d.ts
|
|
116
|
+
interface ScheduleStore {
|
|
117
|
+
saveTask(task: ScheduledTask): Promise<void>;
|
|
118
|
+
loadAllTasks(): Promise<ScheduledTask[]>;
|
|
119
|
+
deleteTask(id: string): Promise<boolean>;
|
|
120
|
+
}
|
|
121
|
+
interface ScheduleStoreOptions {
|
|
122
|
+
persistence?: Persistence<ScheduledTask>;
|
|
123
|
+
/** Logger (injected, replaces bare console.error). */
|
|
124
|
+
logger?: {
|
|
125
|
+
error: (msg: string | Error, ...args: unknown[]) => void;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Create a ScheduleStore backed by FilePersistence in ${OTTO_HOME}/schedules/<workspaceKey>/.
|
|
130
|
+
*
|
|
131
|
+
* `workspaceKey` is required (RFC-169 D1) — every call site must explicitly state which
|
|
132
|
+
* workspace's schedules it wants, so a caller can never silently fall back to a shared
|
|
133
|
+
* global path. Remote mode (RemoteScheduleStore, RFC-169 D3) is a separate implementation
|
|
134
|
+
* selected by the caller based on `--session-url`, not by this factory.
|
|
135
|
+
*/
|
|
136
|
+
declare function createScheduleStore(workspaceKey: string, options?: ScheduleStoreOptions): ScheduleStore;
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/fire-ownership.d.ts
|
|
139
|
+
interface FireOwnership {
|
|
140
|
+
tryAcquire(): Promise<boolean>;
|
|
141
|
+
renew(): Promise<void>;
|
|
142
|
+
release(): Promise<void>;
|
|
143
|
+
lastFailureReason?(): 'occupied' | 'network-error' | undefined;
|
|
144
|
+
/** Whether this process currently holds the lock (verified against the token). */
|
|
145
|
+
isOwner(): boolean;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Create a local token-based O_EXCL FireOwnership, scoped to `workspaceKey` (RFC-169 D1).
|
|
149
|
+
* Only the lock holder runs the ticker and fires tasks.
|
|
150
|
+
*/
|
|
151
|
+
declare function createLocalFireOwnership(workspaceKey: string): FireOwnership;
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/schedule-lease-migrations.d.ts
|
|
154
|
+
declare const SCHEDULE_LEASE_MIGRATIONS: Migration[];
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/remote-schedule-store.d.ts
|
|
157
|
+
interface RemoteScheduleStoreOptions {
|
|
158
|
+
/**
|
|
159
|
+
* `--session-url` 的原始值(如 `http://host:3001/api/storage/sessions`)——**不是**裸
|
|
160
|
+
* origin,该值本身是完整资源路径(对齐 `otto serve` 后端约定)。内部用
|
|
161
|
+
* `new URL(sessionUrl).origin` 提取协议+主机+端口,再拼接 `/api/storage/schedules`——
|
|
162
|
+
* 与 `remote-fire-ownership.ts` 的同一提取逻辑保持一致,不能直接拿 sessionUrl 当前缀用。
|
|
163
|
+
*/
|
|
164
|
+
sessionUrl: string;
|
|
165
|
+
getAuth: () => Promise<{
|
|
166
|
+
token: string;
|
|
167
|
+
}>;
|
|
168
|
+
fetch?: typeof globalThis.fetch;
|
|
169
|
+
timeoutMs?: number;
|
|
170
|
+
wsKey?: string | (() => string | undefined);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Create a ScheduleStore backed by the persistenced `/api/storage/schedules` namespace's
|
|
174
|
+
* generic storage route (`storage.ts`, `namespace='schedules'`) — distinct from the
|
|
175
|
+
* lease sub-route (`schedule-lease.ts`, `/api/schedules/...`) which handles fire-ownership
|
|
176
|
+
* arbitration only.
|
|
177
|
+
*/
|
|
178
|
+
declare function createRemoteScheduleStore(options: RemoteScheduleStoreOptions): ScheduleStore;
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region src/remote-fire-ownership.d.ts
|
|
181
|
+
interface RemoteFireOwnershipOptions {
|
|
182
|
+
/**
|
|
183
|
+
* `--session-url` 的原始值(如 `http://host:3001/api/storage/sessions`)——**不是**裸
|
|
184
|
+
* origin。该值本身是完整资源路径(对齐 `otto serve` 后端约定,见 RFC-146 §1.3 两条远程
|
|
185
|
+
* 产品线对照表),本构造函数内部用 `new URL(sessionUrl).origin` 提取协议+主机+端口,
|
|
186
|
+
* 再拼接 `/api/schedules/...` lease 路径——不能直接拿 `sessionUrl` 当 baseUrl 使用。
|
|
187
|
+
*/
|
|
188
|
+
sessionUrl: string;
|
|
189
|
+
wsKey: string;
|
|
190
|
+
getAuth: () => Promise<{
|
|
191
|
+
token: string;
|
|
192
|
+
}>;
|
|
193
|
+
fetch?: typeof globalThis.fetch;
|
|
194
|
+
timeoutMs?: number;
|
|
195
|
+
now?: () => number;
|
|
196
|
+
}
|
|
197
|
+
/** Create a server-arbitrated FireOwnership backed by persistenced's lease endpoints. */
|
|
198
|
+
declare function createRemoteFireOwnership(options: RemoteFireOwnershipOptions): FireOwnership;
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/scheduler.d.ts
|
|
201
|
+
interface SchedulerDeps {
|
|
202
|
+
registry: ScheduleRegistry;
|
|
203
|
+
store: ScheduleStore;
|
|
204
|
+
ownership: FireOwnership;
|
|
205
|
+
jobRegistry: AgentJobRegistry;
|
|
206
|
+
/**
|
|
207
|
+
* Dispatch an agent job. Mirrors AgentJobService.start() shape:
|
|
208
|
+
* synchronously returns a record with at least { id }.
|
|
209
|
+
*/
|
|
210
|
+
startJob: (input: {
|
|
211
|
+
title: string;
|
|
212
|
+
prompt: string;
|
|
213
|
+
sessionId: string;
|
|
214
|
+
origin: 'user' | 'main';
|
|
215
|
+
}) => {
|
|
216
|
+
id: string;
|
|
217
|
+
};
|
|
218
|
+
getSessionId: () => string;
|
|
219
|
+
/**
|
|
220
|
+
* RFC-327 D2:向订阅者投递到点通知的窄端口(缺省 = 不支持订阅者,此类任务 fire 时
|
|
221
|
+
* 记日志跳过而非崩溃)。
|
|
222
|
+
*
|
|
223
|
+
* 返回是否真的送达——`false` 表示服务未运行/已停机。scheduler 据此写 fire 日志,
|
|
224
|
+
* 但**不重试、不报错**:定时通知是尽力而为的旁路信号(at-least-once 的"至少"由
|
|
225
|
+
* 下一次 cron 周期保证,不是靠即时重试)。
|
|
226
|
+
*
|
|
227
|
+
* 端口刻意只接受纯数据标识:scheduler 不知道插件服务的存在形式,也不持有其句柄。
|
|
228
|
+
*/
|
|
229
|
+
notifySubscriber?: (subscriber: ScheduleSubscriber, tick: {
|
|
230
|
+
scheduleId: string;
|
|
231
|
+
occurrenceId: string;
|
|
232
|
+
occurredAt: number;
|
|
233
|
+
}) => boolean;
|
|
234
|
+
now?: () => number;
|
|
235
|
+
logger?: {
|
|
236
|
+
error: (msg: string | Error, ...args: unknown[]) => void;
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* RFC-230:任务失败重试之间的固定延迟(ms)注入端口。缺省真实 `setTimeout`,测试可注入假
|
|
240
|
+
* 实现避免真实等待。`retryBackoffMs`(见下)为 0 时仍会调用(`sleep(0)`,一次微任务调度
|
|
241
|
+
* 点而非跳过——与当前"循环内无 await 直接进下一轮"存在细微时序差异,但不影响任何可观察
|
|
242
|
+
* 行为,见 RFC-230 §3 D6 Scheduler 小节)。
|
|
243
|
+
*/
|
|
244
|
+
sleep?: (ms: number) => Promise<void>;
|
|
245
|
+
/**
|
|
246
|
+
* RFC-230:每次重试之间的固定延迟(ms)。默认 0(保持现状:不改变既有 per-task 重试
|
|
247
|
+
* 节奏,除非显式配置非零值)。真实值来自 `app.getResilienceConfig().schedule.retryBackoffMs`,
|
|
248
|
+
* 由真实构造点(`packages/cli/src/commands/interactive-schedule.ts`)注入——不是
|
|
249
|
+
* `packages/coding` App 内部构造 `SchedulerService`(二轮评审 P2 修正的装配点误解)。
|
|
250
|
+
*/
|
|
251
|
+
retryBackoffMs?: number;
|
|
252
|
+
}
|
|
253
|
+
declare class SchedulerService {
|
|
254
|
+
private readonly deps;
|
|
255
|
+
private readonly registry;
|
|
256
|
+
private ticker;
|
|
257
|
+
private recoveryTimer;
|
|
258
|
+
private readonly firing;
|
|
259
|
+
private tickInProgress;
|
|
260
|
+
private running;
|
|
261
|
+
private tickCount;
|
|
262
|
+
private readonly runningJobsByTaskId;
|
|
263
|
+
private readonly cancelRequested;
|
|
264
|
+
private _nextFireToken;
|
|
265
|
+
constructor(deps: SchedulerDeps);
|
|
266
|
+
/**
|
|
267
|
+
* RFC-169 D3d: whether this process currently holds fire ownership (ticker actively
|
|
268
|
+
* fires due tasks) vs read-only (loaded the task list but a different live process —
|
|
269
|
+
* local: another workspace-scoped otto instance; remote: another terminal sharing the
|
|
270
|
+
* same wsKey — is the one that will actually fire). Consumed by `/schedule list` to show
|
|
271
|
+
* a "read-only, another terminal holds fire ownership" hint (RFC-169 D3d TUI indicator).
|
|
272
|
+
*/
|
|
273
|
+
isFireOwner(): boolean;
|
|
274
|
+
/**
|
|
275
|
+
* F2 (RFC-169 终局 review 2026-07-15): when `isFireOwner()` is false, why. `undefined`
|
|
276
|
+
* from the underlying `FireOwnership` (e.g. the local O_EXCL implementation, which never
|
|
277
|
+
* has a "can't reach the lock" failure mode — fs ops either succeed or throw) means the
|
|
278
|
+
* distinction genuinely doesn't apply; callers should fall back to the generic
|
|
279
|
+
* "another terminal holds it" wording. Only the remote implementation can return
|
|
280
|
+
* `'network-error'`.
|
|
281
|
+
*/
|
|
282
|
+
fireOwnerFailureReason(): 'occupied' | 'network-error' | undefined;
|
|
283
|
+
/** Load persisted tasks and start the ticker (if fire owner). */
|
|
284
|
+
start(): Promise<void>;
|
|
285
|
+
/**
|
|
286
|
+
* review S5: incremental re-sync from the store — add tasks the owner doesn't know about
|
|
287
|
+
* yet (created by a non-owner process), remove tasks that vanished from the store
|
|
288
|
+
* (deleted by a non-owner process), and pick up field edits (enable/disable, cron, prompt)
|
|
289
|
+
* made elsewhere. Never touches a task currently `firing` (avoid clobbering in-flight
|
|
290
|
+
* dispatch state) and never re-applies jitter to tasks the owner already knows about
|
|
291
|
+
* (their nextFireAt is the owner's own scheduling decision, not the store's).
|
|
292
|
+
*/
|
|
293
|
+
private reloadFromStore;
|
|
294
|
+
/** Clear ticker, release ownership, and persist current state. */
|
|
295
|
+
stop(): Promise<void>;
|
|
296
|
+
/**
|
|
297
|
+
* N1 (RFC-169 终局复审 2026-07-23): when S4 detects ownership loss mid-session, stop
|
|
298
|
+
* the ticker (prevents double-fire) but enter recovery mode — a low-frequency
|
|
299
|
+
* re-acquire loop that can reclaim ownership when a network partition heals or the
|
|
300
|
+
* competing process exits. Without this, a single transient heartbeat failure in
|
|
301
|
+
* remote mode permanently degrades the process to read-only (contradicting D3d's
|
|
302
|
+
* auto-recovery promise) and local suspend-then-resume recovery requires a full
|
|
303
|
+
* process restart.
|
|
304
|
+
*/
|
|
305
|
+
private startRecovery;
|
|
306
|
+
private recoveryAttempt;
|
|
307
|
+
private stopRecovery;
|
|
308
|
+
/**
|
|
309
|
+
* Create a scheduled task.
|
|
310
|
+
*
|
|
311
|
+
* - Generates an 8-char id via crypto.randomUUID()
|
|
312
|
+
* - Parses the cron expression to compute nextFireAt + jitter
|
|
313
|
+
* - Enforces a max of 50 tasks (throws if exceeded)
|
|
314
|
+
*/
|
|
315
|
+
add(input: ScheduledTaskInput): ScheduledTask;
|
|
316
|
+
/** Remove a task by id. Returns true if the task existed and was removed. */
|
|
317
|
+
remove(id: string): boolean;
|
|
318
|
+
/**
|
|
319
|
+
* Update mutable fields of a task.
|
|
320
|
+
*
|
|
321
|
+
* If cronExpression changes, nextFireAt is recalculated with jitter.
|
|
322
|
+
* Fields not in the patch are left unchanged.
|
|
323
|
+
*/
|
|
324
|
+
update(id: string, patch: Partial<Pick<ScheduledTask, 'name' | 'prompt' | 'cronExpression' | 'enabled'>>): boolean;
|
|
325
|
+
/** Return all tasks, ordered by nextFireAt ascending. */
|
|
326
|
+
list(): ScheduledTask[];
|
|
327
|
+
/**
|
|
328
|
+
* RFC-229 M2: return the set of task IDs that currently have a running job.
|
|
329
|
+
* Used by the plugin panel to show [R] status indicators.
|
|
330
|
+
*/
|
|
331
|
+
getRunningTaskIds(): Set<string>;
|
|
332
|
+
/**
|
|
333
|
+
* RFC-229 M3: read the most recent fire log entries for a task.
|
|
334
|
+
* Returns up to `maxEntries` lines, newest first. Each entry is { timestamp, status }.
|
|
335
|
+
*/
|
|
336
|
+
readFireLogs(taskId: string, maxEntries?: number): Array<{
|
|
337
|
+
timestamp: string;
|
|
338
|
+
status: string;
|
|
339
|
+
}>;
|
|
340
|
+
/**
|
|
341
|
+
* Force-execute a task immediately, regardless of its nextFireAt.
|
|
342
|
+
* Does NOT advance nextFireAt — this is an extra execution.
|
|
343
|
+
*
|
|
344
|
+
* review S3: guarded against a concurrent tick-driven fire() for the same task — without
|
|
345
|
+
* this, a manual runNow() while tick's fire() await is in flight (fire() only adds to
|
|
346
|
+
* `firing` synchronously before awaiting startJob) could dispatch two jobs for one task.
|
|
347
|
+
*/
|
|
348
|
+
runNow(id: string): void;
|
|
349
|
+
/**
|
|
350
|
+
* RFC-229 M1: cancel the currently running job for a schedule task.
|
|
351
|
+
*
|
|
352
|
+
* Only cancels jobs in `running` or `needs_input` status — a job that has already
|
|
353
|
+
* produced a diff (`ready`) is deliberately left untouched so the user can `/job apply`.
|
|
354
|
+
*
|
|
355
|
+
* Does NOT delete the task definition or alter the cron schedule. The task will fire
|
|
356
|
+
* again at its next cron-triggered time.
|
|
357
|
+
*
|
|
358
|
+
* Sets a cancel intent in `cancelRequested` so the in-flight `fire()` loop stops
|
|
359
|
+
* retrying — it does NOT prematurely remove the task from `firing` (that belongs to
|
|
360
|
+
* `fire().finally`), avoiding a race window where the task appears idle but `fire()` is
|
|
361
|
+
* still running (RFC-229 R10).
|
|
362
|
+
*/
|
|
363
|
+
cancel(taskId: string): CancelResult;
|
|
364
|
+
/**
|
|
365
|
+
* Heartbeat: evict expired recurring tasks, find & fire due tasks.
|
|
366
|
+
* Reentry guard (MF-1) prevents overlapping ticks.
|
|
367
|
+
*/
|
|
368
|
+
private tick;
|
|
369
|
+
/**
|
|
370
|
+
* Execute a task: start a job, monitor completion via registry events,
|
|
371
|
+
* retry on failure up to maxRetries. Handles one-shot cleanup.
|
|
372
|
+
*
|
|
373
|
+
* review S4 residual risk (accepted): fire() runs independent of the ticker — if this
|
|
374
|
+
* process is suspended mid-fire and loses ownership (S4 stops the ticker on the next
|
|
375
|
+
* tick, but does not abort in-flight fire() calls), this in-flight call can still resolve
|
|
376
|
+
* and write a stale lastFiredAt/nextFireAt back to the store after a successor process
|
|
377
|
+
* has taken over. Harmless one-time overwrite (this process's ticker is already stopped,
|
|
378
|
+
* so no repeated corruption) — needs 5-min suspend + in-flight fire + a same-window write
|
|
379
|
+
* race with the new owner to manifest. Revisit if it proves to matter in practice.
|
|
380
|
+
*/
|
|
381
|
+
/**
|
|
382
|
+
* RFC-327 D2:向订阅者投递一次到点通知。
|
|
383
|
+
*
|
|
384
|
+
* `occurrenceId` 由 `{taskId, 本次计划触发时刻}` 派生——**同一次计划触发的重复投递
|
|
385
|
+
* 得到同一个 id**,这正是幂等键的意义:service 重启后收到重投也能识别"这次我处理过"。
|
|
386
|
+
* 若用随机 id,重复投递会被当成两次不同触发,幂等失效。
|
|
387
|
+
*
|
|
388
|
+
* 返回是否送达;未送达(服务未运行/宿主未接线)只记日志,不抛错、不重试。
|
|
389
|
+
*/
|
|
390
|
+
private fireSubscriber;
|
|
391
|
+
private fire;
|
|
392
|
+
/**
|
|
393
|
+
* Subscribe to AgentJobRegistry onUpdate / onExit and resolve when the
|
|
394
|
+
* job reaches a conclusion:
|
|
395
|
+
*
|
|
396
|
+
* 'success': ready (diff produced) or applied
|
|
397
|
+
* 'failed': failed
|
|
398
|
+
* 'timeout': maxDurationMs elapsed while still running (triggers cancel)
|
|
399
|
+
* 'canceled': the job was canceled (deliberately, by user via cancel(), not a failure)
|
|
400
|
+
*
|
|
401
|
+
* ready is NOT terminal in the engine, but for the schedule it's a success
|
|
402
|
+
* (the diff exists, the user can /job apply later).
|
|
403
|
+
*
|
|
404
|
+
* needs_input extends the timeout — the agent is blocked on user input
|
|
405
|
+
* and should not be killed while waiting.
|
|
406
|
+
*/
|
|
407
|
+
private waitForCompletion;
|
|
408
|
+
/**
|
|
409
|
+
* Apply a deterministic per-task jitter to spread cron fires. Recurring tasks use a
|
|
410
|
+
* 30-min window (spreads periodic load); one-shot tasks use a 90s window (review S2 —
|
|
411
|
+
* RFC-087 §D3 intends one-shot "in ~30s/~90s", not "delayed by up to 30 min"; a 30-min
|
|
412
|
+
* window made "remind me in 30 minutes" arrive up to ~61 minutes later).
|
|
413
|
+
* Uses djb2 hash of the task id for reproducibility across restarts.
|
|
414
|
+
*/
|
|
415
|
+
private applyJitter;
|
|
416
|
+
/**
|
|
417
|
+
* Persist a single task (fire-and-forget). Snapshot-per-id means we never
|
|
418
|
+
* rewrite the whole collection and never delete-by-diff — that would let one
|
|
419
|
+
* process delete another process's schedules (A2).
|
|
420
|
+
*/
|
|
421
|
+
private saveOne;
|
|
422
|
+
/** Delete a single task snapshot (awaited — must confirm removal to prevent zombies on store reload). */
|
|
423
|
+
private deleteOne;
|
|
424
|
+
/** Flush every in-memory task to the store (used on stop; bounded, no diff-delete). */
|
|
425
|
+
private flushAll;
|
|
426
|
+
/**
|
|
427
|
+
* Write a one-line fire log to ${OTTO_HOME}/schedule-logs/<taskId>-<epoch>.log, then prune
|
|
428
|
+
* older logs for this task beyond MAX_FIRE_LOGS_PER_TASK (review S7: a `* * * * *` task
|
|
429
|
+
* fires 1440 times/day with no prior retention — the directory grew unbounded). Best-effort
|
|
430
|
+
* throughout — swallows errors silently (logging is non-critical).
|
|
431
|
+
*/
|
|
432
|
+
private writeFireLog;
|
|
433
|
+
/** Keep only the newest MAX_FIRE_LOGS_PER_TASK log files for a given taskId. */
|
|
434
|
+
private pruneFireLogs;
|
|
435
|
+
}
|
|
436
|
+
//#endregion
|
|
437
|
+
//#region src/schedule-capability.d.ts
|
|
438
|
+
interface ScheduleCapability {
|
|
439
|
+
create: (input: {
|
|
440
|
+
cron: string;
|
|
441
|
+
prompt: string;
|
|
442
|
+
recurring: boolean;
|
|
443
|
+
name?: string; /** review I2: optional override for the default 5-min single-fire timeout. */
|
|
444
|
+
maxDurationMs?: number;
|
|
445
|
+
}) => {
|
|
446
|
+
taskId: string;
|
|
447
|
+
nextFireAt: number;
|
|
448
|
+
name: string;
|
|
449
|
+
};
|
|
450
|
+
list: () => Array<{
|
|
451
|
+
id: string;
|
|
452
|
+
name: string;
|
|
453
|
+
cronExpression: string;
|
|
454
|
+
enabled: boolean;
|
|
455
|
+
recurring: boolean;
|
|
456
|
+
nextFireAt: number;
|
|
457
|
+
lastFiredAt?: number;
|
|
458
|
+
lastFireFailed?: boolean;
|
|
459
|
+
lastFireError?: string;
|
|
460
|
+
lastFireCancelled?: boolean;
|
|
461
|
+
}>;
|
|
462
|
+
delete: (taskId: string) => boolean;
|
|
463
|
+
run: (taskId: string) => void;
|
|
464
|
+
cancel: (taskId: string) => {
|
|
465
|
+
ok: boolean;
|
|
466
|
+
reason?: string;
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
declare function createScheduleCapability(getScheduler: () => SchedulerService | undefined): ScheduleCapability;
|
|
470
|
+
//#endregion
|
|
471
|
+
export { type CancelResult, type FireOwnership, type RemoteFireOwnershipOptions, type RemoteScheduleStoreOptions, SCHEDULE_LEASE_MIGRATIONS, type ScheduleCapability, ScheduleRegistry, type ScheduleStore, type ScheduleSubscriber, type ScheduledTask, type ScheduledTaskInput, type SchedulerDeps, SchedulerService, createLocalFireOwnership, createRemoteFireOwnership, createRemoteScheduleStore, createScheduleCapability, createScheduleStore, parseCron };
|
|
472
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/registry.ts","../src/cron-parser.ts","../src/schedule-store.ts","../src/fire-ownership.ts","../src/schedule-lease-migrations.ts","../src/remote-schedule-store.ts","../src/remote-fire-ownership.ts","../src/scheduler.ts","../src/schedule-capability.ts"],"mappings":"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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import{RemotePersistence as e,createPersistence as t}from"@x-otto/persistence";import{OTTO_HOME as n,currentHostname as r,decodeLeaseToken as i,encodeLeaseToken as a,isProcessAlive as o,isShadowModeEnabled as s}from"@x-otto/env";import{resolve as c}from"node:path";import{closeSync as l,mkdirSync as u,openSync as d,readFileSync as f,readdirSync as p,renameSync as m,rmSync as h,statSync as g,unlinkSync as _,utimesSync as v,writeFileSync as y,writeSync as b}from"node:fs";import{randomUUID as x}from"node:crypto";var S=class{tasks;constructor(){this.tasks=new Map}add(e){if(this.tasks.has(e.id))throw Error(`ScheduleRegistry: duplicate task id "${e.id}"`);this.tasks.set(e.id,e)}remove(e){return this.tasks.delete(e)}update(e,t){let n=this.tasks.get(e);return n?(t.enabled!==void 0&&(n.enabled=t.enabled),t.lastFiredAt!==void 0&&(n.lastFiredAt=t.lastFiredAt),t.nextFireAt!==void 0&&(n.nextFireAt=t.nextFireAt),t.maxDurationMs!==void 0&&(n.maxDurationMs=t.maxDurationMs),t.maxRetries!==void 0&&(n.maxRetries=t.maxRetries),t.lastFireFailed!==void 0&&(n.lastFireFailed=t.lastFireFailed),t.lastFireError!==void 0&&(n.lastFireError=t.lastFireError),t.lastFireCancelled!==void 0&&(n.lastFireCancelled=t.lastFireCancelled),!0):!1}get(e){return this.tasks.get(e)}list(){return[...this.tasks.values()].sort((e,t)=>e.nextFireAt-t.nextFireAt)}findDue(e){let t=[];for(let n of this.tasks.values())n.enabled&&n.nextFireAt<=e&&t.push(n);return t.sort((e,t)=>e.nextFireAt-t.nextFireAt),t}count(){return this.tasks.size}has(e){return this.tasks.has(e)}};function C(e,t){let{validMinute:n,validHour:r,validDom:i,validMonth:a,validDow:o,domConstrained:s,dowConstrained:c}=w(e),l=t?new Date(t.getTime()):new Date;l.setSeconds(0,0);let u=new Date(l.getTime()+366*24*60*60*1e3),d=new Date(l.getTime());for(;d.getTime()<=u.getTime();){let e=d.getMinutes(),t=A(n,e);if(t===null){d.setMinutes(j(n)),d.setHours(d.getHours()+1),d.setSeconds(0,0);continue}if(t!==e){d.setMinutes(t),d.setSeconds(0,0);continue}let l=d.getHours(),u=A(r,l);if(u===null){d.setHours(j(r)),d.setMinutes(j(n)),d.setDate(d.getDate()+1),d.setSeconds(0,0);continue}if(u!==l){d.setHours(u),d.setMinutes(j(n)),d.setSeconds(0,0);continue}let f=d.getDate(),p=d.getDay(),m=i.has(f),h=o.has(p),g;if(g=s&&c?m||h:s?m:c?h:!0,!g){d.setDate(d.getDate()+1),d.setHours(j(r)),d.setMinutes(j(n)),d.setSeconds(0,0);continue}let _=d.getMonth()+1,v=A(a,_);if(v===null){d.setMonth(j(a)-1),d.setFullYear(d.getFullYear()+1),d.setDate(1),d.setHours(j(r)),d.setMinutes(j(n)),d.setSeconds(0,0);continue}if(v!==_){d.setMonth(v-1),d.setDate(1),d.setHours(j(r)),d.setMinutes(j(n)),d.setSeconds(0,0);continue}return d}throw Error(`No matching time found for cron "${e}" within 366 days of ${l.toISOString()}`)}function w(e){if(!e||!e.trim())throw Error(`Invalid cron expression: empty string`);let t=e.trim().split(/\s+/);if(t.length!==5)throw Error(`Invalid cron expression: expected 5 fields, got ${t.length} (${e})`);let[n,r,i,a,o]=t,s=T(n,0,59),c=T(r,0,23),l=T(i,1,31),u=T(a,1,12),d=T(o,0,7);return d.has(7)&&d.add(0),{validMinute:s,validHour:c,validDom:l,validMonth:u,validDow:d,domConstrained:i!==`*`,dowConstrained:o!==`*`}}function T(e,t,n){E(e);let r=new Set,i=e.split(`,`);for(let a of i){let{range:i,step:o}=D(a);if(O(o,a),i===`*`)for(let e=t;e<=n;e+=o)r.add(e);else if(i.includes(`-`)){let[s,c]=i.split(`-`),l=k(s,a),u=k(c,a);if(l<t||u>n||l>u)throw Error(`Invalid cron field "${e}": range [${l},${u}] out of bounds [${t},${n}]`);for(let e=l;e<=u;e+=o)r.add(e)}else{let s=k(i,a);if(s<t||s>n)throw Error(`Invalid cron field "${e}": value ${s} out of range [${t},${n}]`);if(o>1)for(let e=s;e<=n;e+=o)r.add(e);else r.add(s)}}return r}function E(e){if(e.replace(/[\d*\/\-,]/g,``).length>0)throw Error(`Invalid cron field "${e}": unsupported syntax`)}function D(e){let t=e.indexOf(`/`);return t===-1?{range:e,step:1}:{range:e.substring(0,t),step:parseInt(e.substring(t+1),10)}}function O(e,t){if(isNaN(e)||e<1)throw Error(`Invalid cron field: invalid step in "${t}"`)}function k(e,t){let n=parseInt(e,10);if(isNaN(n))throw Error(`Invalid cron field: expected number, got "${e}" in "${t}"`);return n}function A(e,t){let n=null;for(let r of e)r>=t&&(n===null||r<n)&&(n=r);return n}function j(e){let t=1/0;for(let n of e)n<t&&(t=n);return t}function M(e){return c(n,`schedules`,e)}function N(e,n={}){let r=n.persistence??(s()?t({type:`memory`}):t({type:`file`,baseDir:M(e)})),i=n.logger;return{async saveTask(e){let t={version:1,id:e.id,data:e,metadata:{createdAt:e.createdAt,updatedAt:Date.now(),tags:[`schedule`]}};await r.save(t)},async loadAllTasks(){let e;try{e=await r.list()}catch(e){return i?.error(`[schedules] Failed to list schedule snapshots:`,e),[]}let t=[];for(let n of e)try{let e=await r.load(n);e&&t.push(e.data)}catch(e){i?.error(`[schedules] Failed to load schedule "${n}":`,e)}return t.sort((e,t)=>e.nextFireAt-t.nextFireAt),t},async deleteTask(e){try{return await r.delete(e)}catch(t){return i?.error(`[schedules] Failed to delete schedule "${e}":`,t),!1}}}}function P(e){return c(n,`schedule-owner-${e}.lock`)}function F(e){let t=x(),c=P(e),p=r(),y=!1,S=()=>{try{u(n,{recursive:!0});let e=d(c,`wx`);try{b(e,a(t,process.pid,p))}finally{l(e)}return y=!0,!0}catch(e){if(e.code!==`EEXIST`)throw e;return!1}},C=()=>{try{return i(f(c,`utf-8`).trim())}catch{return null}},w=()=>C()?.token??null,T=e=>e.pid===void 0||e.host===void 0||e.host!==p?!1:!o(e.pid),E=e=>e.pid===void 0||e.host===void 0||e.host!==p?!1:o(e.pid),D=()=>{if(y)return!0;if(s())return!1;if(S())return!0;let e=C(),t;try{t=g(c).mtimeMs}catch{return S()}if(e!==null&&E(e)||!(e!==null&&T(e))&&Date.now()-t<=3e5)return!1;let n=`${c}.stale-${x()}`;try{m(c,n),h(n,{force:!0})}catch{return!1}return S()};return{async tryAcquire(){return D()},async renew(){if(y){if(w()!==t){y=!1;return}try{let e=new Date;v(c,e,e)}catch{y=!1}}},async release(){if(y){if(w()===t)try{_(c)}catch{}y=!1}},isOwner(){return y&&w()===t}}}const I=[{version:1,up:e=>{e.exec(`
|
|
2
|
+
CREATE TABLE IF NOT EXISTS schedule_lease (
|
|
3
|
+
ws_key TEXT PRIMARY KEY,
|
|
4
|
+
token TEXT NOT NULL,
|
|
5
|
+
renewed_at INTEGER NOT NULL
|
|
6
|
+
);
|
|
7
|
+
`)}}];var L=class extends e{get serviceLabel(){return`schedule 远程服务`}get endpointFlag(){return`--session-url`}extractId(e){return e.id}async saveTask(e){await this.save({version:1,id:e.id,data:e})}async loadAllTasks(){let e=await this.list(),t=[];for(let n of e){let e=await this.load(n);e&&t.push(e.data)}return t.sort((e,t)=>e.nextFireAt-t.nextFireAt),t}async deleteTask(e){return this.delete(e)}};function R(e){return new L({baseUrl:`${new URL(e.sessionUrl).origin}/api/storage/schedules`,getAuth:e.getAuth,fetch:e.fetch??globalThis.fetch,timeoutMs:e.timeoutMs??1e4,wsKey:e.wsKey})}function z(e){let t=x(),n=e.fetch??globalThis.fetch,r=e.timeoutMs??1e4,i=new URL(e.sessionUrl).origin,a=e.now??Date.now,o=!1,s=0,c,l,u=async a=>{let o=await e.getAuth(),s=new AbortController,c=setTimeout(()=>s.abort(),r);try{let r=await n(`${i}/api/schedules/${encodeURIComponent(e.wsKey)}/lease/${a}`,{method:`POST`,headers:{"Content-Type":`application/json`,Authorization:`Bearer ${o.token}`},body:JSON.stringify({token:t}),signal:s.signal});return r.ok?{ok:!0,data:await r.json()}:{ok:!1,reason:`network-error`}}catch{return{ok:!1,reason:`network-error`}}finally{clearTimeout(c)}},d=()=>{if(c)return c;let e=(async()=>{s=a();let e=await u(`acquire`);if(!e.ok)return o=!1,l=`network-error`,!1;let t=e.data.acquired===!0;return o=t,l=t?void 0:`occupied`,t})();return c=e,e.finally(()=>{c===e&&(c=void 0)}),e};return{tryAcquire:d,async renew(){if(a()-s<3e4)return;if(!o){await d();return}s=a();let e=await u(`renew`);if(!e.ok){o=!1,l=`network-error`;return}e.data.renewed===!0?l=void 0:(o=!1,l=`occupied`)},async release(){o&&=(await u(`release`),!1)},isOwner(){return o},lastFailureReason(){return o?void 0:l}}}const B=1e3;var V=class{deps;registry;ticker=null;recoveryTimer=null;firing=new Set;tickInProgress=!1;running=!1;tickCount=0;runningJobsByTaskId=new Map;cancelRequested=new Set;_nextFireToken=0;constructor(e){this.deps=e,this.registry=e.registry}isFireOwner(){return this.deps.ownership.isOwner()}fireOwnerFailureReason(){return this.deps.ownership.lastFailureReason?.()}async start(){if(!this.running){if(!await this.deps.ownership.tryAcquire()){try{let e=await this.deps.store.loadAllTasks();for(let t of e)try{this.registry.add(t)}catch{}}catch(e){this.deps.logger?.error(`[schedules] Failed to load schedules (read-only mode):`,e)}return}try{let e=this.deps.now?.()??Date.now(),t=await this.deps.store.loadAllTasks();for(let n of t){if(n.recurring&&n.nextFireAt<e)try{let t=C(n.cronExpression,new Date(e+6e4));n.nextFireAt=this.applyJitter(n.id,t,!0)}catch{}try{this.registry.add(n)}catch{}}}catch(e){this.deps.logger?.error(`[schedules] Failed to load schedules:`,e)}this.running=!0,this.ticker=setInterval(()=>{this.tick()},B),this.ticker.unref()}}async reloadFromStore(){let e;try{e=await this.deps.store.loadAllTasks()}catch(e){this.deps.logger?.error(`[schedules] Failed to reload schedules from store:`,e);return}let t=new Set(e.map(e=>e.id));for(let t of e){if(this.firing.has(t.id))continue;if(!t.recurring&&t.lastFiredAt!=null&&!this.registry.get(t.id)){this.deps.store.deleteTask(t.id).catch(e=>{this.deps.logger?.error(`[schedules] Failed to delete zombie one-shot ${t.id}:`,e)});continue}let e=this.registry.get(t.id);if(e)e.enabled=t.enabled,e.name=t.name,e.prompt=t.prompt,e.cronExpression=t.cronExpression,e.maxDurationMs=t.maxDurationMs,e.maxRetries=t.maxRetries;else try{this.registry.add(t)}catch{}}for(let e of this.registry.list())!t.has(e.id)&&!this.firing.has(e.id)&&this.registry.remove(e.id)}async stop(){!this.running&&!this.deps.ownership.isOwner()||(this.stopRecovery(),this.ticker&&=(clearInterval(this.ticker),null),this.running=!1,await this.deps.ownership.release(),await this.flushAll())}startRecovery(){this.recoveryTimer||(this.deps.logger?.error(`[schedules] Lost fire ownership — entering recovery mode`),this.recoveryTimer=setInterval(()=>{this.recoveryAttempt()},3e4),this.recoveryTimer.unref())}async recoveryAttempt(){try{await this.deps.ownership.tryAcquire()&&this.deps.ownership.isOwner()&&(this.stopRecovery(),this.running=!0,this.ticker=setInterval(()=>{this.tick()},B),this.ticker.unref(),this.deps.logger?.error(`[schedules] Re-acquired fire ownership — recovery complete`))}catch{}}stopRecovery(){this.recoveryTimer&&=(clearInterval(this.recoveryTimer),null)}add(e){if(this.registry.count()>=50)throw Error(`Schedule limit reached (max 50). Remove some tasks before adding more.`);let t=crypto.randomUUID().slice(0,8),n=this.deps.now?.()??Date.now(),r=C(e.cronExpression,new Date(n+6e4)),i=this.applyJitter(t,r,e.recurring),a={id:t,name:e.name,prompt:e.prompt,cronExpression:e.cronExpression,enabled:!0,recurring:e.recurring,origin:e.origin,createdAt:n,nextFireAt:i,maxDurationMs:e.maxDurationMs??3e5,maxRetries:e.maxRetries??0,...e.subscriber?{subscriber:e.subscriber}:{}};return this.registry.add(a),this.saveOne(a),a}remove(e){let t=this.registry.remove(e);return t&&this.deleteOne(e),t}update(e,t){let n=this.registry.get(e);if(!n)return!1;if(t.name!==void 0&&(n.name=t.name),t.prompt!==void 0&&(n.prompt=t.prompt),t.enabled!==void 0&&(n.enabled=t.enabled),t.cronExpression!==void 0){n.cronExpression=t.cronExpression;let e=this.deps.now?.()??Date.now(),r=C(n.cronExpression,new Date(e+6e4));n.nextFireAt=this.applyJitter(n.id,r,n.recurring)}return this.saveOne(n),!0}list(){return this.registry.list()}getRunningTaskIds(){return new Set(this.runningJobsByTaskId.keys())}readFireLogs(e,t=20){let r=c(n,`schedule-logs`),i=`${e}-`;try{let e=p(r).filter(e=>e.startsWith(i)&&e.endsWith(`.log`)).sort().reverse(),n=[];for(let i=0;i<e.length&&n.length<t;i++)try{let t=f(c(r,e[i]),`utf-8`).trim(),a=t.indexOf(` `),o=a>0?t.slice(0,a):t,s=a>0?t.slice(a+1):``;n.push({timestamp:o,status:s})}catch{}return n}catch{return[]}}runNow(e){let t=this.registry.get(e);if(!t)throw Error(`Schedule task "${e}" not found`);if(!t.enabled)throw Error(`Schedule task "${e}" is disabled`);if(this.firing.has(t.id))throw Error(`Schedule task "${e}" is already firing`);this.firing.add(t.id),this.fire(t)}cancel(e){let t=this.registry.get(e);if(!t)return{ok:!1,reason:`task_not_found`};let n,r=this.runningJobsByTaskId.get(e);if(r)n=r.jobId;else{let e=this.deps.jobRegistry.list().filter(e=>e.status===`running`||e.status===`needs_input`);e.length===1&&(n=e[0].id)}if(!n)return{ok:!1,reason:`not_running`};let i=this.deps.jobRegistry.get(n);return i?i.status===`ready`?{ok:!1,reason:`job_is_ready`}:i.status===`applied`?{ok:!1,reason:`job_already_applied`}:i.status===`failed`?{ok:!1,reason:`job_already_failed`}:i.status===`canceled`?{ok:!1,reason:`job_already_canceled`}:(this.cancelRequested.add(t.id),this.deps.jobRegistry.cancel(n),{ok:!0}):{ok:!1,reason:`job_not_found`}}async tick(){if(!this.tickInProgress){this.tickInProgress=!0;try{if(await this.deps.ownership.renew(),!this.deps.ownership.isOwner()){this.running=!1,this.ticker&&=(clearInterval(this.ticker),null),this.startRecovery();return}this.tickCount++,this.tickCount%30==0&&await this.reloadFromStore();let e=this.deps.now?.()??Date.now();for(let t of this.registry.list())t.recurring&&e-t.nextFireAt>6048e5&&(this.registry.remove(t.id),this.deleteOne(t.id));let t=this.registry.findDue(e);for(let n of t){if(this.firing.has(n.id)||!n.recurring&&(n.lastFireFailed||n.lastFireCancelled))continue;let t=n.nextFireAt,r=C(n.cronExpression,new Date(e+6e4)),i=this.applyJitter(n.id,r,n.recurring);this.registry.update(n.id,{nextFireAt:i}),this.saveOne(n),this.firing.add(n.id),this.fire(n,t)}}finally{this.tickInProgress=!1}}}fireSubscriber(e,t){let n=e.subscriber;if(!n)return!1;let r=this.deps.notifySubscriber;if(!r)return this.writeFireLog(e.id,`subscriber tick skipped: host has no notifySubscriber port`),!1;let i=this.deps.now?.()??Date.now(),a=`${e.id}@${t??i}`,o=!1;try{o=r(n,{scheduleId:e.id,occurrenceId:a,occurredAt:i})}catch(t){return this.deps.logger?.error(`[schedules] subscriber notify threw for task ${e.id}:`,t),this.writeFireLog(e.id,`subscriber tick error: ${t instanceof Error?t.message:String(t)}`),!1}return this.writeFireLog(e.id,o?`subscriber tick delivered (${n.pluginId}:${n.serviceId}, occurrence ${a})`:`subscriber tick not delivered (${n.pluginId}:${n.serviceId} not running)`),o}async fire(e,t){let n=`failed`,r=!1,i,a=++this._nextFireToken;try{if(e.subscriber){n=this.fireSubscriber(e,t)?`success`:`failed`;return}let o=e.origin===`model`?`main`:`user`,s=this.deps.getSessionId();for(let t=0;t<=e.maxRetries;t++){if(this.cancelRequested.has(e.id)){this.writeFireLog(e.id,`canceled by user (attempt ${t+1})`),r=!0;break}let c=this.deps.startJob({title:e.name,prompt:e.prompt,sessionId:s,origin:o});this.runningJobsByTaskId.set(e.id,{jobId:c.id,fireToken:a,attempt:t});let l=await this.waitForCompletion(c.id,e.maxDurationMs);if(l===`success`){this.registry.update(e.id,{lastFiredAt:this.deps.now?.()??Date.now(),lastFireFailed:!1,lastFireError:void 0}),this.saveOne(e);let r=this.deps.jobRegistry.get(c.id),i=r?.status===`ready`&&!!r.diff?.trim();this.writeFireLog(e.id,i?`completed (attempt ${t+1}) — diff ready, /job apply ${c.id}`:`completed (attempt ${t+1})`),n=`success`;return}if(l===`canceled`){this.writeFireLog(e.id,`canceled by user (attempt ${t+1})`),r=!0;break}if(i=l===`timeout`?`timed out`:`job failed`,t<e.maxRetries){let e=this.deps.retryBackoffMs??0;await(this.deps.sleep??(e=>new Promise(t=>setTimeout(t,e))))(e)}}r||this.writeFireLog(e.id,`failed after ${e.maxRetries+1} attempt(s)`)}catch(t){i=t instanceof Error?t.message:String(t),this.deps.logger?.error(`[schedules] fire failed for task ${e.id}:`,t),this.writeFireLog(e.id,`error: ${i}`)}finally{this.firing.delete(e.id),this.runningJobsByTaskId.delete(e.id);let t=this.cancelRequested.has(e.id);this.cancelRequested.delete(e.id),e.recurring||(n===`success`?(this.registry.remove(e.id),await this.deleteOne(e.id)):r?(this.registry.update(e.id,{lastFireCancelled:!0,lastFireError:void 0}),this.saveOne(e)):t||(this.registry.update(e.id,{lastFireFailed:!0,lastFireError:i}),this.saveOne(e)))}}waitForCompletion(e,t){return new Promise(n=>{let r=!1,i,a,o,s=(this.deps.now?.()??Date.now())+t*3,c=()=>{i&&clearTimeout(i),a?.(),o?.()},l=e=>{r||(r=!0,c(),n(e))},u=t=>{i&&clearTimeout(i),i=setTimeout(()=>{this.deps.jobRegistry.cancel(e),l(`failed`)},t)};o=this.deps.jobRegistry.onUpdate(n=>{if(n.id===e){if(n.status===`ready`)l(`success`);else if(n.status===`needs_input`){let n=this.deps.now?.()??Date.now(),r=Math.min(t,s-n);if(r<=0){this.deps.jobRegistry.cancel(e),l(`failed`);return}u(r)}}}),a=this.deps.jobRegistry.onExit(t=>{t.id===e&&(t.status===`failed`?l(`failed`):t.status===`canceled`?l(`canceled`):t.status===`applied`&&l(`success`))}),u(t)})}applyJitter(e,t,n){let r=0;for(let t=0;t<e.length;t++)r=(r<<5)-r+e.charCodeAt(t),r|=0;let i=Math.abs(r)%(n?18e5:9e4);return t.getTime()+i}saveOne(e){this.deps.store.saveTask(e).catch(t=>{this.deps.logger?.error(`[schedules] Failed to save schedule ${e.id}:`,t)})}async deleteOne(e){try{return await this.deps.store.deleteTask(e)}catch(t){return this.deps.logger?.error(`[schedules] Failed to delete schedule ${e}:`,t),!1}}async flushAll(){try{for(let e of this.registry.list())await this.deps.store.saveTask(e)}catch(e){this.deps.logger?.error(`[schedules] Failed to flush schedules:`,e)}}writeFireLog(e,t){if(s())return;let r=c(n,`schedule-logs`),i=c(r,`${e}-${Date.now()}.log`);try{u(r,{recursive:!0}),y(i,`${new Date().toISOString()} ${t}\n`,`utf-8`),this.pruneFireLogs(r,e)}catch{}}pruneFireLogs(e,t){try{let n=`${t}-`,r=p(e).filter(e=>e.startsWith(n)&&e.endsWith(`.log`)).sort(),i=r.length-20;if(i<=0)return;for(let t of r.slice(0,i))try{_(c(e,t))}catch{}}catch{}}};function H(e){return{create:t=>{let n=e();if(!n)throw Error(`Schedule scheduler is not available in this session.`);let r=n.add({name:t.name??`schedule`,prompt:t.prompt,cronExpression:t.cron,recurring:t.recurring,origin:`model`,maxDurationMs:t.maxDurationMs});return{taskId:r.id,nextFireAt:r.nextFireAt,name:r.name}},list:()=>(e()?.list()??[]).map(e=>({id:e.id,name:e.name,cronExpression:e.cronExpression,enabled:e.enabled,recurring:e.recurring,nextFireAt:e.nextFireAt,lastFiredAt:e.lastFiredAt,lastFireFailed:e.lastFireFailed,lastFireError:e.lastFireError,lastFireCancelled:e.lastFireCancelled})),delete:t=>e()?.remove(t)??!1,run:t=>{e()?.runNow(t)},cancel:t=>e()?.cancel(t)??{ok:!1,reason:`Schedule scheduler is not available.`}}}export{I as SCHEDULE_LEASE_MIGRATIONS,S as ScheduleRegistry,V as SchedulerService,F as createLocalFireOwnership,z as createRemoteFireOwnership,R as createRemoteScheduleStore,H as createScheduleCapability,N as createScheduleStore,C as parseCron};
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["SNAPSHOT_VERSION"],"sources":["../src/registry.ts","../src/cron-parser.ts","../src/schedule-store.ts","../src/fire-ownership.ts","../src/schedule-lease-migrations.ts","../src/remote-schedule-store.ts","../src/remote-fire-ownership.ts","../src/scheduler.ts","../src/schedule-capability.ts"],"sourcesContent":["import type { ScheduledTask } from './types'\n\n// schedule/src/registry.ts — In-memory schedule task registry (Map-backed CRUD).\nexport class ScheduleRegistry {\n private tasks: Map<string, ScheduledTask>\n\n constructor() {\n this.tasks = new Map()\n }\n\n add(task: ScheduledTask): void {\n if (this.tasks.has(task.id)) {\n throw new Error(`ScheduleRegistry: duplicate task id \"${task.id}\"`)\n }\n this.tasks.set(task.id, task)\n }\n\n remove(id: string): boolean {\n return this.tasks.delete(id)\n }\n\n update(\n id: string,\n patch: Partial<\n Pick<\n ScheduledTask,\n | 'enabled'\n | 'lastFiredAt'\n | 'nextFireAt'\n | 'maxDurationMs'\n | 'maxRetries'\n | 'lastFireFailed'\n | 'lastFireError'\n | 'lastFireCancelled'\n >\n >,\n ): boolean {\n const task = this.tasks.get(id)\n if (!task) return false\n\n if (patch.enabled !== undefined) task.enabled = patch.enabled\n if (patch.lastFiredAt !== undefined) task.lastFiredAt = patch.lastFiredAt\n if (patch.nextFireAt !== undefined) task.nextFireAt = patch.nextFireAt\n if (patch.maxDurationMs !== undefined) task.maxDurationMs = patch.maxDurationMs\n if (patch.maxRetries !== undefined) task.maxRetries = patch.maxRetries\n if (patch.lastFireFailed !== undefined) task.lastFireFailed = patch.lastFireFailed\n if (patch.lastFireError !== undefined) task.lastFireError = patch.lastFireError\n if (patch.lastFireCancelled !== undefined) task.lastFireCancelled = patch.lastFireCancelled\n\n return true\n }\n\n get(id: string): ScheduledTask | undefined {\n return this.tasks.get(id)\n }\n\n list(): ScheduledTask[] {\n return [...this.tasks.values()].sort((a, b) => a.nextFireAt - b.nextFireAt)\n }\n\n /**\n * Find tasks that are due for execution.\n * Returns all enabled tasks (recurring or one-shot) whose nextFireAt <= now.\n * The 'firing' guard is a scheduler concern (managed via a separate Set).\n */\n findDue(now: number): ScheduledTask[] {\n const due: ScheduledTask[] = []\n for (const task of this.tasks.values()) {\n if (task.enabled && task.nextFireAt <= now) {\n due.push(task)\n }\n }\n due.sort((a, b) => a.nextFireAt - b.nextFireAt)\n return due\n }\n\n count(): number {\n return this.tasks.size\n }\n\n has(id: string): boolean {\n return this.tasks.has(id)\n }\n}\n","// schedule/src/cron-parser.ts — 5-field cron expression parser.\n// Uses local Date API (DST-safe). Search capped at 366 days forward.\n\ntype FieldSet = Set<number>\n\n/**\n * Parse a 5-field cron expression (minute hour dom month dow) and return the next\n * fire time >= from (inclusive). All times in local timezone.\n *\n * Throws on invalid expressions.\n *\n * Field syntax: wildcard (*), single value (5), step (e.g. \\*\\/15 or 5\\/15),\n * range (1-5), list (1,15,30), range with step (1-10\\/2).\n *\n * Unsupported: L, W, ?, #, name aliases (MON, JAN).\n *\n * ## DoM∧DoW semantics\n *\n * If both day-of-month and day-of-week are constrained (not a bare *),\n * a date matches if **EITHER** field matches (standard vixie-cron OR behaviour).\n *\n * ## Day-of-week mapping\n *\n * 0 = Sunday, 1–6 = Monday–Saturday, 7 = also Sunday.\n */\nexport function parseCron(expr: string, from?: Date): Date {\n const { validMinute, validHour, validDom, validMonth, validDow, domConstrained, dowConstrained } =\n parseExpression(expr)\n\n const startTime = from ? new Date(from.getTime()) : new Date()\n startTime.setSeconds(0, 0)\n\n const maxTime = new Date(startTime.getTime() + 366 * 24 * 60 * 60 * 1000)\n let current = new Date(startTime.getTime())\n\n while (current.getTime() <= maxTime.getTime()) {\n // --- minute ---\n const minute = current.getMinutes()\n const nextMin = nextInSet(validMinute, minute)\n if (nextMin === null) {\n current.setMinutes(firstInSet(validMinute))\n current.setHours(current.getHours() + 1)\n current.setSeconds(0, 0)\n continue\n }\n if (nextMin !== minute) {\n current.setMinutes(nextMin)\n current.setSeconds(0, 0)\n continue\n }\n\n // --- hour ---\n const hour = current.getHours()\n const nextHr = nextInSet(validHour, hour)\n if (nextHr === null) {\n current.setHours(firstInSet(validHour))\n current.setMinutes(firstInSet(validMinute))\n current.setDate(current.getDate() + 1)\n current.setSeconds(0, 0)\n continue\n }\n if (nextHr !== hour) {\n current.setHours(nextHr)\n current.setMinutes(firstInSet(validMinute))\n current.setSeconds(0, 0)\n continue\n }\n\n // --- day (DoM ∨ DoW) ---\n const dom = current.getDate()\n const dow = current.getDay()\n const domMatch = validDom.has(dom)\n const dowMatch = validDow.has(dow)\n\n let dayMatch: boolean\n if (domConstrained && dowConstrained) {\n dayMatch = domMatch || dowMatch\n } else if (domConstrained) {\n dayMatch = domMatch\n } else if (dowConstrained) {\n dayMatch = dowMatch\n } else {\n dayMatch = true\n }\n\n if (!dayMatch) {\n current.setDate(current.getDate() + 1)\n current.setHours(firstInSet(validHour))\n current.setMinutes(firstInSet(validMinute))\n current.setSeconds(0, 0)\n continue\n }\n\n // --- month ---\n const month = current.getMonth() + 1 // 1-based\n const nextMo = nextInSet(validMonth, month)\n if (nextMo === null) {\n // Wrap to next year\n current.setMonth(firstInSet(validMonth) - 1)\n current.setFullYear(current.getFullYear() + 1)\n current.setDate(1)\n current.setHours(firstInSet(validHour))\n current.setMinutes(firstInSet(validMinute))\n current.setSeconds(0, 0)\n continue\n }\n if (nextMo !== month) {\n current.setMonth(nextMo - 1)\n current.setDate(1)\n current.setHours(firstInSet(validHour))\n current.setMinutes(firstInSet(validMinute))\n current.setSeconds(0, 0)\n continue\n }\n\n // All fields matched\n return current\n }\n\n throw new Error(`No matching time found for cron \"${expr}\" within 366 days of ${startTime.toISOString()}`)\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\ninterface ParsedExpression {\n validMinute: FieldSet\n validHour: FieldSet\n validDom: FieldSet\n validMonth: FieldSet\n validDow: FieldSet\n domConstrained: boolean\n dowConstrained: boolean\n}\n\nfunction parseExpression(expr: string): ParsedExpression {\n if (!expr || !expr.trim()) {\n throw new Error('Invalid cron expression: empty string')\n }\n\n const fields = expr.trim().split(/\\s+/)\n if (fields.length !== 5) {\n throw new Error(`Invalid cron expression: expected 5 fields, got ${fields.length} (${expr})`)\n }\n\n const [minField, hourField, domField, monthField, dowField] = fields as [string, string, string, string, string]\n\n const validMinute = parseField(minField, 0, 59)\n const validHour = parseField(hourField, 0, 23)\n const validDom = parseField(domField, 1, 31)\n const validMonth = parseField(monthField, 1, 12)\n const validDow = parseField(dowField, 0, 7)\n\n // 7 = Sunday, also maps to 0 for Date.getDay() compatibility\n if (validDow.has(7)) {\n validDow.add(0)\n }\n\n // A field is \"constrained\" if the user explicitly specified values (not bare *)\n const domConstrained = domField !== '*'\n const dowConstrained = dowField !== '*'\n\n return { validMinute, validHour, validDom, validMonth, validDow, domConstrained, dowConstrained }\n}\n\n/**\n * Parse a single cron field into a set of valid integer values.\n *\n * Supported sub-syntax:\n * - * -> all values in [min, max]\n * - 5 -> single value\n * - *\\/15 or 5\\/15 -> step (start/step)\n * - 1-5 -> inclusive range\n * - 1,15,30 -> comma-separated list\n * - 1-10/2 -> range with step\n *\n * Throws on unsupported syntax (letters, L, W, ?, # etc.) or out-of-range values.\n */\nfunction parseField(field: string, min: number, max: number): FieldSet {\n validateFieldSyntax(field)\n\n const result = new Set<number>()\n const parts = field.split(',')\n\n for (const part of parts) {\n const { range, step } = splitStep(part)\n validateStep(step, part)\n\n if (range === '*') {\n for (let i = min; i <= max; i += step) {\n result.add(i)\n }\n } else if (range.includes('-')) {\n const [startStr, endStr] = range.split('-') as [string, string]\n const start = parseFieldInt(startStr, part)\n const end = parseFieldInt(endStr, part)\n if (start < min || end > max || start > end) {\n throw new Error(`Invalid cron field \"${field}\": range [${start},${end}] out of bounds [${min},${max}]`)\n }\n for (let i = start; i <= end; i += step) {\n result.add(i)\n }\n } else {\n const val = parseFieldInt(range, part)\n if (val < min || val > max) {\n throw new Error(\n `Invalid cron field \"${field}\": value ${val} out of range [${min},${max}]`,\n )\n }\n if (step > 1) {\n // \"N/step\" — start at N, step to max (vixie-cron semantics, e.g. 5/15 → 5,20,35,50)\n for (let i = val; i <= max; i += step) {\n result.add(i)\n }\n } else {\n result.add(val)\n }\n }\n }\n\n return result\n}\n\nfunction validateFieldSyntax(field: string): void {\n // Allow only digits, *, /, -, comma, and whitespace\n const cleaned = field.replace(/[\\d*\\/\\-,]/g, '')\n if (cleaned.length > 0) {\n throw new Error(`Invalid cron field \"${field}\": unsupported syntax`)\n }\n}\n\nfunction splitStep(part: string): { range: string; step: number } {\n const slashIdx = part.indexOf('/')\n if (slashIdx === -1) {\n return { range: part, step: 1 }\n }\n return {\n range: part.substring(0, slashIdx),\n step: parseInt(part.substring(slashIdx + 1), 10),\n }\n}\n\nfunction validateStep(step: number, part: string): void {\n if (isNaN(step) || step < 1) {\n throw new Error(`Invalid cron field: invalid step in \"${part}\"`)\n }\n}\n\nfunction parseFieldInt(s: string, contextPart: string): number {\n const n = parseInt(s, 10)\n if (isNaN(n)) {\n throw new Error(`Invalid cron field: expected number, got \"${s}\" in \"${contextPart}\"`)\n }\n return n\n}\n\nfunction nextInSet(set: FieldSet, current: number): number | null {\n let best: number | null = null\n for (const v of set) {\n if (v >= current && (best === null || v < best)) {\n best = v\n }\n }\n return best\n}\n\nfunction firstInSet(set: FieldSet): number {\n let best = Infinity\n for (const v of set) {\n if (v < best) best = v\n }\n return best\n}\n","// schedule/src/schedule-store.ts — Snapshot-per-id storage via Persistence<ScheduledTask>.\n// RFC-169 D1: partitioned by workspaceKey. Fail-open: read errors → [].\nimport { createPersistence, type Persistence, type Snapshot } from '@x-otto/persistence'\nimport { OTTO_HOME, isShadowModeEnabled } from '@x-otto/env'\nimport { resolve } from 'node:path'\n\nimport type { ScheduledTask } from './types'\n\nconst SNAPSHOT_VERSION = 1\n\n/** Per-workspace schedules directory: `${OTTO_HOME}/schedules/<workspaceKey>/`. */\nfunction schedulesDir(workspaceKey: string): string {\n return resolve(OTTO_HOME, 'schedules', workspaceKey)\n}\n\nexport interface ScheduleStore {\n saveTask(task: ScheduledTask): Promise<void>\n loadAllTasks(): Promise<ScheduledTask[]>\n deleteTask(id: string): Promise<boolean>\n}\n\nexport interface ScheduleStoreOptions {\n persistence?: Persistence<ScheduledTask>\n /** Logger (injected, replaces bare console.error). */\n logger?: { error: (msg: string | Error, ...args: unknown[]) => void }\n}\n\n/**\n * Create a ScheduleStore backed by FilePersistence in ${OTTO_HOME}/schedules/<workspaceKey>/.\n *\n * `workspaceKey` is required (RFC-169 D1) — every call site must explicitly state which\n * workspace's schedules it wants, so a caller can never silently fall back to a shared\n * global path. Remote mode (RemoteScheduleStore, RFC-169 D3) is a separate implementation\n * selected by the caller based on `--session-url`, not by this factory.\n */\nexport function createScheduleStore(workspaceKey: string, options: ScheduleStoreOptions = {}): ScheduleStore {\n // RFC-345:影子模式(OTTO_SHADOW)下默认持久化切到 memory 后端——`/schedule add`\n // 等交互路径只写内存、零磁盘落盘,符合影子态「写静默吞」(§D3)语义。调用方显式\n // 注入 options.persistence 时不受影子态控制。\n //\n // 已知折衷:影子态下 `/schedule list` 只含本进程内存任务,既有磁盘任务不可见。\n // 读穿透叠加 merge 的复杂度不值得为一个短暂影子会话引入,故不做。\n const store =\n options.persistence ??\n (isShadowModeEnabled()\n ? createPersistence<ScheduledTask>({ type: 'memory' })\n : createPersistence<ScheduledTask>({ type: 'file', baseDir: schedulesDir(workspaceKey) }))\n const logger = options.logger\n\n return {\n async saveTask(task: ScheduledTask): Promise<void> {\n const snapshot: Snapshot<ScheduledTask> = {\n version: SNAPSHOT_VERSION,\n id: task.id,\n data: task,\n metadata: {\n createdAt: task.createdAt,\n updatedAt: Date.now(),\n tags: ['schedule'],\n },\n }\n await store.save(snapshot)\n },\n\n async loadAllTasks(): Promise<ScheduledTask[]> {\n let ids: string[]\n try {\n ids = await store.list()\n } catch (err) {\n logger?.error('[schedules] Failed to list schedule snapshots:', err)\n return []\n }\n\n const tasks: ScheduledTask[] = []\n for (const id of ids) {\n try {\n const snapshot = await store.load(id)\n if (snapshot) {\n tasks.push(snapshot.data)\n }\n } catch (err) {\n logger?.error(`[schedules] Failed to load schedule \"${id}\":`, err)\n // skip corrupted record, continue loading rest\n }\n }\n\n tasks.sort((a, b) => a.nextFireAt - b.nextFireAt)\n return tasks\n },\n\n async deleteTask(id: string): Promise<boolean> {\n try {\n return await store.delete(id)\n } catch (err) {\n logger?.error(`[schedules] Failed to delete schedule \"${id}\":`, err)\n return false\n }\n },\n }\n}\n","// schedule/src/fire-ownership.ts — Per-fire lease acquisition (async tryAcquire).\n// RFC-169 D1: partitioned by workspaceKey.\n// RFC-171 D2/D5: lease format token:pid:hostname + liveness probe.\nimport {\n closeSync,\n mkdirSync,\n openSync,\n writeSync,\n readFileSync,\n statSync,\n renameSync,\n rmSync,\n unlinkSync,\n utimesSync,\n} from 'node:fs'\nimport { resolve } from 'node:path'\nimport { randomUUID } from 'node:crypto'\n\nimport {\n OTTO_HOME,\n currentHostname,\n decodeLeaseToken,\n encodeLeaseToken,\n isProcessAlive,\n isShadowModeEnabled,\n} from '@x-otto/env'\n\n/** Per-workspace lock file: `${OTTO_HOME}/schedule-owner-<workspaceKey>.lock`. */\nfunction lockFilePath(workspaceKey: string): string {\n return resolve(OTTO_HOME, `schedule-owner-${workspaceKey}.lock`)\n}\n\nconst STALE_MS = 5 * 60_000 // 5-minute stale threshold (must exceed renew interval)\n\nexport interface FireOwnership {\n tryAcquire(): Promise<boolean>\n\n renew(): Promise<void>\n\n release(): Promise<void>\n\n lastFailureReason?(): 'occupied' | 'network-error' | undefined\n\n /** Whether this process currently holds the lock (verified against the token). */\n isOwner(): boolean\n}\n\n/**\n * Create a local token-based O_EXCL FireOwnership, scoped to `workspaceKey` (RFC-169 D1).\n * Only the lock holder runs the ticker and fires tasks.\n */\nexport function createLocalFireOwnership(workspaceKey: string): FireOwnership {\n const token = randomUUID()\n const LOCK_FILE = lockFilePath(workspaceKey)\n const myHostname = currentHostname()\n let owned = false\n\n /** Create the lockfile with our token (encoded as `token:pid:hostname`, RFC-171 D2). Returns false if it already exists. */\n const writeLock = (): boolean => {\n try {\n // OTTO_HOME may not exist yet on a fresh install — otto doesn't self-create it\n // ahead of first write. mkdirSync recursive is a cheap no-op once it does.\n mkdirSync(OTTO_HOME, { recursive: true })\n const fd = openSync(LOCK_FILE, 'wx') // O_EXCL\n try {\n writeSync(fd, encodeLeaseToken(token, process.pid, myHostname))\n } finally {\n closeSync(fd)\n }\n owned = true\n return true\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n return false\n }\n }\n\n /** Read + decode the lockfile currently written, or null if unreadable. */\n const readDecoded = (): { token: string; pid?: number; host?: string } | null => {\n try {\n const raw = readFileSync(LOCK_FILE, 'utf-8').trim()\n return decodeLeaseToken(raw)\n } catch {\n return null\n }\n }\n\n /** Read only the token field (fencing comparisons). */\n const readToken = (): string | null => readDecoded()?.token ?? null\n\n /** RFC-171 D2: confirmed-dead check — same hostname + pid not alive. Cross-host/malformed → false (safe fallback to stale-timeout). */\n const isConfirmedDead = (decoded: { pid?: number; host?: string }): boolean => {\n if (decoded.pid === undefined || decoded.host === undefined) return false\n if (decoded.host !== myHostname) return false\n return !isProcessAlive(decoded.pid)\n }\n\n /** RFC-171 D2: confirmed-alive check — same hostname + pid alive. Used to reject takeover even past STALE_MS. */\n const isConfirmedAlive = (decoded: { pid?: number; host?: string }): boolean => {\n if (decoded.pid === undefined || decoded.host === undefined) return false\n if (decoded.host !== myHostname) return false\n return isProcessAlive(decoded.pid)\n }\n\n // RFC-169 D3/D3b: FireOwnership methods are Promise-returning so this local O_EXCL\n // implementation and the remote HTTP one (remote-fire-ownership.ts) satisfy the same\n // interface. All work below is genuinely synchronous fs I/O — wrapped in an\n // immediately-resolved Promise, not an actual async boundary.\n const tryAcquireSync = (): boolean => {\n if (owned) return true\n // RFC-345:影子态不参与 fire ownership 竞选(§D9)——不建 schedule-owner-*.lock、\n // 不报失败原因。影子会话加载任务列表只读展示,fire 由真实 owner(或影子会话内部的\n // 其它路径)承担。这样影子进程既零写盘,也不会因竞选失败而降级走 STALE_MS 等\n // 既有的健康检查路径。\n if (isShadowModeEnabled()) return false\n if (writeLock()) return true\n\n // Lock exists — try liveness probe first (RFC-171 D2), fall back to mtime stale-timeout.\n const decoded = readDecoded()\n let mtime: number\n try {\n mtime = statSync(LOCK_FILE).mtimeMs\n } catch {\n // Vanished between calls — retry create.\n return writeLock()\n }\n\n if (decoded !== null && isConfirmedAlive(decoded)) {\n // Confirmed same-host owner is alive — reject even past STALE_MS (hung-process\n // trade-off; see module header + session-write-lease.ts D2 for the rationale).\n return false\n }\n\n const confirmedDead = decoded !== null && isConfirmedDead(decoded)\n if (!confirmedDead && Date.now() - mtime <= STALE_MS) return false\n\n // Stale or confirmed dead: atomically claim it away, then recreate with our token.\n const claimPath = `${LOCK_FILE}.stale-${randomUUID()}`\n try {\n renameSync(LOCK_FILE, claimPath)\n rmSync(claimPath, { force: true })\n } catch {\n return false // raced another taker — let the winner keep it\n }\n return writeLock()\n }\n\n return {\n async tryAcquire(): Promise<boolean> {\n return tryAcquireSync()\n },\n\n async renew(): Promise<void> {\n if (!owned) return\n // Only touch the lock if it is still ours (guards against a takeover).\n if (readToken() !== token) {\n owned = false\n return\n }\n try {\n const t = new Date()\n utimesSync(LOCK_FILE, t, t)\n } catch {\n owned = false // lock vanished under us\n }\n },\n\n async release(): Promise<void> {\n if (!owned) return\n // Only unlink if the lock is still ours (never delete a successor's lock).\n if (readToken() === token) {\n try {\n unlinkSync(LOCK_FILE)\n } catch {\n // best-effort cleanup\n }\n }\n owned = false\n },\n\n isOwner(): boolean {\n return owned && readToken() === token\n },\n }\n}\n","/**\n * schedule-lease-migrations.ts —— RFC-169 D3a: schedule_lease 表的独立 migration 段.\n *\n * `schedule_lease` is a schedule-domain table (server-arbitrated fire-ownership lease,\n * RFC-169 D3), not a generic blob-snapshot table — it must not join `BLOB_MIGRATIONS`\n * (@x-otto/persistence's generic storage kernel schema), per the \"generic storage kernel\n * does not hardcode domain schema\" principle already established by RFC-074 M5-02b\n * (session tables were carved out of the shared migration set for the same reason).\n *\n * Consumed by `persistenced-app.ts` via `acquireDb(leaseDbPath, wal, SCHEDULE_LEASE_MIGRATIONS)`\n * — deliberately a SEPARATE db file from the blob-snapshot `storage.db` (acquireDb's\n * fail-fast guard rejects injecting two different migration sets into the same db path,\n * `sqlite-db-cache.ts:134-140`).\n */\nimport type { Migration } from '@x-otto/persistence'\n\nexport const SCHEDULE_LEASE_MIGRATIONS: Migration[] = [\n {\n version: 1,\n up: (db) => {\n db.exec(`\n CREATE TABLE IF NOT EXISTS schedule_lease (\n ws_key TEXT PRIMARY KEY,\n token TEXT NOT NULL,\n renewed_at INTEGER NOT NULL\n );\n `)\n },\n },\n]\n","/**\n * remote-schedule-store.ts —— RFC-169 D3: 远程模式的 ScheduleStore 实现。\n *\n * `extends RemotePersistence<ScheduledTask>`(@x-otto/persistence 既有抽象基类,已有\n * RemoteSessionPersistence/PanelState 两个先例)——复用 wsKey 路径分区、鉴权 header 注入、\n * 超时/连接失败错误文案,不重新实现 HTTP 客户端。`ScheduleStore` 接口形状(saveTask/\n * loadAllTasks/deleteTask)与本地 `createScheduleStore` 一致,`setupScheduleService` 可\n * 按 remoteSessionConfigured 无痛切换两种实现(RFC-169 §4 重要事项规则 6)。\n */\nimport { RemotePersistence } from '@x-otto/persistence'\nimport type { RemotePersistenceOptions } from '@x-otto/persistence'\n\nimport type { ScheduleStore } from './schedule-store'\nimport type { ScheduledTask } from './types'\n\nconst SNAPSHOT_VERSION = 1\n\ninterface ScheduleSnapshot {\n version: number\n id: string\n data: ScheduledTask\n}\n\nclass RemoteScheduleStoreImpl extends RemotePersistence<ScheduleSnapshot> implements ScheduleStore {\n protected override get serviceLabel(): string {\n return 'schedule 远程服务'\n }\n protected override get endpointFlag(): string {\n return '--session-url'\n }\n\n protected extractId(snapshot: ScheduleSnapshot): string {\n return snapshot.id\n }\n\n async saveTask(task: ScheduledTask): Promise<void> {\n await this.save({ version: SNAPSHOT_VERSION, id: task.id, data: task })\n }\n\n /**\n * M3 里程碑完成审核记录的已知架构成本:list()+load() 是 1+N 次 HTTP 请求(先拿 id 列表,\n * 再逐个拉快照),非单次批量端点。可接受——schedule 任务集合天然小(MAX_TASKS=50,\n * scheduler.ts),且 loadAllTasks 只在 SchedulerService.start()/定期 reloadFromStore()\n * 调用,不在 tick 热路径。真要优化需要 persistenced 加批量读端点,超出本 RFC 范围。\n */\n async loadAllTasks(): Promise<ScheduledTask[]> {\n const ids = await this.list()\n const tasks: ScheduledTask[] = []\n for (const id of ids) {\n const snapshot = await this.load(id)\n if (snapshot) tasks.push(snapshot.data)\n }\n tasks.sort((a, b) => a.nextFireAt - b.nextFireAt)\n return tasks\n }\n\n async deleteTask(id: string): Promise<boolean> {\n return this.delete(id)\n }\n}\n\nexport interface RemoteScheduleStoreOptions {\n /**\n * `--session-url` 的原始值(如 `http://host:3001/api/storage/sessions`)——**不是**裸\n * origin,该值本身是完整资源路径(对齐 `otto serve` 后端约定)。内部用\n * `new URL(sessionUrl).origin` 提取协议+主机+端口,再拼接 `/api/storage/schedules`——\n * 与 `remote-fire-ownership.ts` 的同一提取逻辑保持一致,不能直接拿 sessionUrl 当前缀用。\n */\n sessionUrl: string\n getAuth: () => Promise<{ token: string }>\n fetch?: typeof globalThis.fetch\n timeoutMs?: number\n wsKey?: string | (() => string | undefined)\n}\n\n/**\n * Create a ScheduleStore backed by the persistenced `/api/storage/schedules` namespace's\n * generic storage route (`storage.ts`, `namespace='schedules'`) — distinct from the\n * lease sub-route (`schedule-lease.ts`, `/api/schedules/...`) which handles fire-ownership\n * arbitration only.\n */\nexport function createRemoteScheduleStore(options: RemoteScheduleStoreOptions): ScheduleStore {\n const origin = new URL(options.sessionUrl).origin\n const remoteOptions: RemotePersistenceOptions<ScheduleSnapshot> = {\n baseUrl: `${origin}/api/storage/schedules`,\n getAuth: options.getAuth,\n fetch: options.fetch ?? globalThis.fetch,\n timeoutMs: options.timeoutMs ?? 10_000,\n wsKey: options.wsKey,\n }\n return new RemoteScheduleStoreImpl(remoteOptions)\n}\n","// schedule/src/remote-fire-ownership.ts — Remote fire ownership via HTTP storage host.\n// RFC-169 D3c: 30s heartbeat, 180s stale.\n// D3d: fallback to tryAcquire on renew failure.\n// F1: in-flight dedup via inFlightAcquire.\nimport { randomUUID } from 'node:crypto'\n\nimport type { FireOwnership } from './fire-ownership'\n\nconst HEARTBEAT_MS = 30_000\nconst STALE_MS = 180_000\n\nexport interface RemoteFireOwnershipOptions {\n /**\n * `--session-url` 的原始值(如 `http://host:3001/api/storage/sessions`)——**不是**裸\n * origin。该值本身是完整资源路径(对齐 `otto serve` 后端约定,见 RFC-146 §1.3 两条远程\n * 产品线对照表),本构造函数内部用 `new URL(sessionUrl).origin` 提取协议+主机+端口,\n * 再拼接 `/api/schedules/...` lease 路径——不能直接拿 `sessionUrl` 当 baseUrl 使用。\n */\n sessionUrl: string\n wsKey: string\n getAuth: () => Promise<{ token: string }>\n fetch?: typeof globalThis.fetch\n timeoutMs?: number\n now?: () => number\n}\n\ntype LeaseAction = 'acquire' | 'renew' | 'release'\n\n/**\n * F2 (RFC-169 终局 review 2026-07-15): `callLease` 此前把\"服务端明确拒绝\"(HTTP 200 但\n * `acquired:false`/`renewed:false`)和\"请求本身没打到/没打通\"(网络错误/超时/非 2xx HTTP\n * 状态)统一折叠成同一个 `null` 返回值——消费方因此无法区分\"另一个终端真的持有租约\"和\n * \"我这边压根没连上服务端、连判定结果都没拿到\",导致 TUI 只读提示在网络故障时显示一句\n * 具体但错误的诊断(\"另一终端持有触发权\")。现在用一个可辨识返回类型显式区分三态:\n * `{ ok:true, data }`(服务端已应答,无论 acquired/renewed 是 true 还是 false,这本身\n * 就是一次成功的判定往返)、`{ ok:false, reason:'network-error' }`(请求本身失败)。\n * `response.ok===false`(如 403/500)也归为 network-error——那不是\"lease 被占用\"这个\n * 业务语义上的拒绝,是请求层面出了问题(鉴权/服务端错误),同样不该说成\"被占用\"。\n *\n * 已知精度局限(本次 review 复核发现,暂不展开为独立修复):`network-error` 这个名字\n * 把\"真连不上网络\"和\"鉴权失败(403 token 不对)\"两种不同性质的请求层失败合并成一类\n * ——两者的正确排查方向不同(前者查网络连通性,后者查 token 配置),但当前 TUI 提示\n * 文案统一说\"连接远程服务失败\",对鉴权失败场景不够精确。之所以不现在拆出独立的\n * `'auth-error'` 三态:① token 由 `getAuth()` 单一配置源注入,生产场景下 401/403 出现\n * 概率极低(配置错误通常在首次连接就会暴露,不会隐蔽到运行时才触发);② 拆分收益不足以\n * 覆盖新增一个枚举值分支的代码/文档维护成本,属于本次 review 判断\"可保留观察\"的一项。\n */\ntype LeaseCallResult =\n | { ok: true; data: Record<string, unknown> }\n | { ok: false; reason: 'network-error' }\n\n/** Create a server-arbitrated FireOwnership backed by persistenced's lease endpoints. */\nexport function createRemoteFireOwnership(options: RemoteFireOwnershipOptions): FireOwnership {\n const token = randomUUID()\n const fetchImpl = options.fetch ?? globalThis.fetch\n const timeoutMs = options.timeoutMs ?? 10_000\n const baseUrl = new URL(options.sessionUrl).origin\n const now = options.now ?? Date.now\n let owned = false\n /** Last time an actual HTTP renew/acquire heartbeat was sent (throttling — see header). */\n let lastHeartbeatAt = 0\n /** F1: dedupe concurrent tryAcquire() calls onto one in-flight request/state-write. */\n let inFlightAcquire: Promise<boolean> | undefined\n /** F2: reason for the most recent non-owner outcome, for TUI diagnostics. */\n let lastFailureReason: 'occupied' | 'network-error' | undefined\n\n const callLease = async (action: LeaseAction): Promise<LeaseCallResult> => {\n const auth = await options.getAuth()\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetchImpl(\n `${baseUrl}/api/schedules/${encodeURIComponent(options.wsKey)}/lease/${action}`,\n {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${auth.token}`,\n },\n body: JSON.stringify({ token }),\n signal: controller.signal,\n },\n )\n if (!response.ok) return { ok: false, reason: 'network-error' }\n return { ok: true, data: (await response.json()) as Record<string, unknown> }\n } catch {\n // Network error / timeout / abort — treated as \"did not succeed\", never thrown.\n // The scheduler's tick loop tolerates transient failures and retries next heartbeat.\n return { ok: false, reason: 'network-error' }\n } finally {\n clearTimeout(timeout)\n }\n }\n\n const tryAcquire = (): Promise<boolean> => {\n // F1: if a tryAcquire() is already in flight, share its result instead of firing a\n // second concurrent HTTP request whose (possibly stale-ordered) response could\n // clobber `owned` after the first call already set it. Both callers get the same\n // outcome, which is the only sane semantics for \"try to acquire\" called concurrently.\n if (inFlightAcquire) return inFlightAcquire\n\n const promise = (async (): Promise<boolean> => {\n lastHeartbeatAt = now()\n const result = await callLease('acquire')\n if (!result.ok) {\n owned = false\n lastFailureReason = 'network-error'\n return false\n }\n const acquired = result.data['acquired'] === true\n owned = acquired\n lastFailureReason = acquired ? undefined : 'occupied'\n return acquired\n })()\n\n inFlightAcquire = promise\n promise.finally(() => {\n if (inFlightAcquire === promise) inFlightAcquire = undefined\n })\n return promise\n }\n\n return {\n tryAcquire,\n\n async renew(): Promise<void> {\n // Throttle: SchedulerService.tick() calls renew() every second, but a real HTTP\n // heartbeat only needs to fire every HEARTBEAT_MS (30s, D3c) — everything in between\n // is a free no-op. The very first call after construction always fires (lastHeartbeatAt\n // starts at 0), matching the local implementation's \"renew is safe to call immediately\".\n if (now() - lastHeartbeatAt < HEARTBEAT_MS) return\n\n if (!owned) {\n // D3d: not currently owner (lost it, or never acquired) — every heartbeat is an\n // acquire attempt so a healed network partition or an expired competitor's lease\n // can be reclaimed without waiting for a full process restart.\n await tryAcquire()\n return\n }\n lastHeartbeatAt = now()\n const result = await callLease('renew')\n if (!result.ok) {\n // F2: renew failed at the transport level (not a server-side \"superseded\"\n // rejection) — still downgrade to non-owner (D3d semantics unchanged), but record\n // the accurate reason so the TUI doesn't claim \"another terminal holds it\".\n owned = false\n lastFailureReason = 'network-error'\n return\n }\n const renewed = result.data['renewed'] === true\n if (!renewed) {\n // D3d: lost the lease (superseded, or this renew call itself failed/timed out).\n // Don't distinguish \"genuinely superseded\" from \"network blip\" here — both cases\n // downgrade to non-owner and let the next heartbeat's tryAcquire() sort it out.\n owned = false\n lastFailureReason = 'occupied'\n } else {\n lastFailureReason = undefined\n }\n },\n\n async release(): Promise<void> {\n if (!owned) return\n await callLease('release')\n owned = false\n },\n\n isOwner(): boolean {\n return owned\n },\n\n lastFailureReason(): 'occupied' | 'network-error' | undefined {\n return owned ? undefined : lastFailureReason\n },\n }\n}\n\nexport { HEARTBEAT_MS as REMOTE_FIRE_OWNERSHIP_HEARTBEAT_MS, STALE_MS as REMOTE_FIRE_OWNERSHIP_STALE_MS }\n","// schedule/src/scheduler.ts — Schedule lifecycle: start→tick→fire→stop.\n// Fire logs → ${OTTO_HOME}/schedule-logs/. Clock: Date.now() by default, injectable for testing.\n\nimport { writeFileSync, readFileSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'\nimport { resolve } from 'node:path'\n\nimport { OTTO_HOME, isShadowModeEnabled } from '@x-otto/env'\nimport type { AgentJobRegistry } from '@x-otto/runtime'\n\nimport { ScheduleRegistry } from './registry'\nimport { parseCron } from './cron-parser'\nimport type { ScheduleStore } from './schedule-store'\nimport type { FireOwnership } from './fire-ownership'\nimport type { CancelResult, ScheduledTask, ScheduledTaskInput, ScheduleSubscriber } from './types'\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MAX_TASKS = 50\nconst SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000\nconst JITTER_WINDOW_MS = 30 * 60 * 1000 // 30 minutes (recurring)\nconst ONE_SHOT_JITTER_WINDOW_MS = 90 * 1000 // 90 seconds (one-shot, RFC-087 §D3 — review S2)\nconst DEFAULT_MAX_DURATION_MS = 300_000 // 5 minutes\nconst DEFAULT_MAX_RETRIES = 0\nconst TICK_INTERVAL_MS = 1000\nconst NEEDS_INPUT_MAX_EXTENSION_FACTOR = 3 // review S8: hard cap on needs_input total wait\nconst MAX_FIRE_LOGS_PER_TASK = 20 // review S7: cap schedule-logs/<taskId>-*.log retention per task\nconst RELOAD_INTERVAL_TICKS = 30 // review S5: owner re-syncs from store every ~30s\nconst RECOVERY_RETRY_INTERVAL_MS = 30_000 // N1: re-acquire retry cadence when ownership lost mid-session\n\n// ---------------------------------------------------------------------------\n// Dependencies\n// ---------------------------------------------------------------------------\n\nexport interface SchedulerDeps {\n registry: ScheduleRegistry\n store: ScheduleStore\n ownership: FireOwnership\n jobRegistry: AgentJobRegistry\n /**\n * Dispatch an agent job. Mirrors AgentJobService.start() shape:\n * synchronously returns a record with at least { id }.\n */\n startJob: (input: { title: string; prompt: string; sessionId: string; origin: 'user' | 'main' }) => { id: string }\n getSessionId: () => string\n /**\n * RFC-327 D2:向订阅者投递到点通知的窄端口(缺省 = 不支持订阅者,此类任务 fire 时\n * 记日志跳过而非崩溃)。\n *\n * 返回是否真的送达——`false` 表示服务未运行/已停机。scheduler 据此写 fire 日志,\n * 但**不重试、不报错**:定时通知是尽力而为的旁路信号(at-least-once 的\"至少\"由\n * 下一次 cron 周期保证,不是靠即时重试)。\n *\n * 端口刻意只接受纯数据标识:scheduler 不知道插件服务的存在形式,也不持有其句柄。\n */\n notifySubscriber?: (\n subscriber: ScheduleSubscriber,\n tick: { scheduleId: string; occurrenceId: string; occurredAt: number },\n ) => boolean\n now?: () => number\n logger?: { error: (msg: string | Error, ...args: unknown[]) => void }\n /**\n * RFC-230:任务失败重试之间的固定延迟(ms)注入端口。缺省真实 `setTimeout`,测试可注入假\n * 实现避免真实等待。`retryBackoffMs`(见下)为 0 时仍会调用(`sleep(0)`,一次微任务调度\n * 点而非跳过——与当前\"循环内无 await 直接进下一轮\"存在细微时序差异,但不影响任何可观察\n * 行为,见 RFC-230 §3 D6 Scheduler 小节)。\n */\n sleep?: (ms: number) => Promise<void>\n /**\n * RFC-230:每次重试之间的固定延迟(ms)。默认 0(保持现状:不改变既有 per-task 重试\n * 节奏,除非显式配置非零值)。真实值来自 `app.getResilienceConfig().schedule.retryBackoffMs`,\n * 由真实构造点(`packages/cli/src/commands/interactive-schedule.ts`)注入——不是\n * `packages/coding` App 内部构造 `SchedulerService`(二轮评审 P2 修正的装配点误解)。\n */\n retryBackoffMs?: number\n}\n\n// ---------------------------------------------------------------------------\n// SchedulerService\n// ---------------------------------------------------------------------------\n\nexport class SchedulerService {\n private readonly deps: SchedulerDeps\n private readonly registry: ScheduleRegistry\n private ticker: ReturnType<typeof setInterval> | null = null\n private recoveryTimer: ReturnType<typeof setInterval> | null = null\n private readonly firing: Set<string> = new Set()\n private tickInProgress = false\n private running = false\n private tickCount = 0\n\n // RFC-229 M1: cancel support\n private readonly runningJobsByTaskId: Map<string, { jobId: string; fireToken: number; attempt: number }> = new Map()\n private readonly cancelRequested: Set<string> = new Set()\n private _nextFireToken = 0\n\n constructor(deps: SchedulerDeps) {\n this.deps = deps\n this.registry = deps.registry\n }\n\n /**\n * RFC-169 D3d: whether this process currently holds fire ownership (ticker actively\n * fires due tasks) vs read-only (loaded the task list but a different live process —\n * local: another workspace-scoped otto instance; remote: another terminal sharing the\n * same wsKey — is the one that will actually fire). Consumed by `/schedule list` to show\n * a \"read-only, another terminal holds fire ownership\" hint (RFC-169 D3d TUI indicator).\n */\n isFireOwner(): boolean {\n return this.deps.ownership.isOwner()\n }\n\n /**\n * F2 (RFC-169 终局 review 2026-07-15): when `isFireOwner()` is false, why. `undefined`\n * from the underlying `FireOwnership` (e.g. the local O_EXCL implementation, which never\n * has a \"can't reach the lock\" failure mode — fs ops either succeed or throw) means the\n * distinction genuinely doesn't apply; callers should fall back to the generic\n * \"another terminal holds it\" wording. Only the remote implementation can return\n * `'network-error'`.\n */\n fireOwnerFailureReason(): 'occupied' | 'network-error' | undefined {\n return this.deps.ownership.lastFailureReason?.()\n }\n\n // ── Lifecycle ──\n\n /** Load persisted tasks and start the ticker (if fire owner). */\n async start(): Promise<void> {\n if (this.running) return\n\n // Try to acquire fire ownership (MF-5: only owner fires)\n const isOwner = await this.deps.ownership.tryAcquire()\n if (!isOwner) {\n // Not the owner: load tasks for read-only list but don't start ticker\n try {\n const tasks = await this.deps.store.loadAllTasks()\n for (const task of tasks) {\n try { this.registry.add(task) } catch { /* skip duplicate */ }\n }\n } catch (err) {\n this.deps.logger?.error('[schedules] Failed to load schedules (read-only mode):', err)\n }\n return\n }\n\n // This process is the fire owner — load tasks and start ticker\n try {\n const now = this.deps.now?.() ?? Date.now()\n const tasks = await this.deps.store.loadAllTasks()\n for (const task of tasks) {\n // H1: recompute a missed recurring nextFireAt from cron so a downtime\n // gap doesn't fire every missed occurrence at once on startup.\n if (task.recurring && task.nextFireAt < now) {\n try {\n const nextBase = parseCron(task.cronExpression, new Date(now + 60_000))\n task.nextFireAt = this.applyJitter(task.id, nextBase, true)\n } catch {\n /* keep stored nextFireAt if cron no longer parses */\n }\n }\n try { this.registry.add(task) } catch { /* skip duplicate */ }\n }\n } catch (err) {\n this.deps.logger?.error('[schedules] Failed to load schedules:', err)\n }\n\n this.running = true\n this.ticker = setInterval(() => {\n void this.tick()\n }, TICK_INTERVAL_MS)\n this.ticker.unref()\n }\n\n /**\n * review S5: incremental re-sync from the store — add tasks the owner doesn't know about\n * yet (created by a non-owner process), remove tasks that vanished from the store\n * (deleted by a non-owner process), and pick up field edits (enable/disable, cron, prompt)\n * made elsewhere. Never touches a task currently `firing` (avoid clobbering in-flight\n * dispatch state) and never re-applies jitter to tasks the owner already knows about\n * (their nextFireAt is the owner's own scheduling decision, not the store's).\n */\n private async reloadFromStore(): Promise<void> {\n let tasks: ScheduledTask[]\n try {\n tasks = await this.deps.store.loadAllTasks()\n } catch (err) {\n this.deps.logger?.error('[schedules] Failed to reload schedules from store:', err)\n return\n }\n\n const storeIds = new Set(tasks.map((t) => t.id))\n\n // Pick up new tasks + field edits from other processes.\n // Safety net: non-recurring tasks that have already fired (lastFiredAt set) are\n // zombies — a prior deleteOne failed, leaving the task in the store after the\n // in-memory registry already removed it. Delete them from the store instead of\n // re-adding to the registry, preventing perpetual resurrection.\n for (const task of tasks) {\n if (this.firing.has(task.id)) continue // don't clobber in-flight dispatch state\n if (!task.recurring && task.lastFiredAt != null && !this.registry.get(task.id)) {\n void this.deps.store.deleteTask(task.id).catch((err) => {\n this.deps.logger?.error(`[schedules] Failed to delete zombie one-shot ${task.id}:`, err)\n })\n continue\n }\n const existing = this.registry.get(task.id)\n if (!existing) {\n try { this.registry.add(task) } catch { /* raced with a concurrent add */ }\n } else {\n // Merge remote edits (enable/disable, cron, prompt, name) without touching\n // nextFireAt/lastFiredAt — those are the owner's own scheduling state.\n //\n // Known trade-off (N7, RFC-169 终局复审 2026-07-23): when a non-owner process\n // changes cronExpression, the owner accepts the new expression but does NOT\n // recalculate nextFireAt from it — that stays on the old cron's schedule for\n // one more fire. The task will self-correct on the next tick's findDue+fire\n // cycle (scheduler.ts parseCron fires from the expression now stored in\n // `task.cronExpression`). One-stale-fire window is an accepted trade-off\n // vs the risk of the owner's scheduling state being clobbered by a stale\n // store read.\n existing.enabled = task.enabled\n existing.name = task.name\n existing.prompt = task.prompt\n existing.cronExpression = task.cronExpression\n existing.maxDurationMs = task.maxDurationMs\n existing.maxRetries = task.maxRetries\n }\n }\n\n // Remove tasks deleted elsewhere (but never one currently firing).\n for (const task of this.registry.list()) {\n if (!storeIds.has(task.id) && !this.firing.has(task.id)) {\n this.registry.remove(task.id)\n }\n }\n }\n\n /** Clear ticker, release ownership, and persist current state. */\n async stop(): Promise<void> {\n if (!this.running && !this.deps.ownership.isOwner()) return\n\n this.stopRecovery()\n\n if (this.ticker) {\n clearInterval(this.ticker)\n this.ticker = null\n }\n\n // Release the lock before flushAll() so a fast process exit still frees it — otherwise\n // the next start waits out the stale window (A4, local=5min / remote=180s per D3c).\n // RFC-169 D3: release() is now Promise-returning (remote implementation genuinely\n // awaits an HTTP round-trip; local implementation's body is still fully synchronous,\n // so awaiting here changes nothing for that path but is required for the remote one).\n this.running = false\n await this.deps.ownership.release()\n await this.flushAll()\n }\n\n /**\n * N1 (RFC-169 终局复审 2026-07-23): when S4 detects ownership loss mid-session, stop\n * the ticker (prevents double-fire) but enter recovery mode — a low-frequency\n * re-acquire loop that can reclaim ownership when a network partition heals or the\n * competing process exits. Without this, a single transient heartbeat failure in\n * remote mode permanently degrades the process to read-only (contradicting D3d's\n * auto-recovery promise) and local suspend-then-resume recovery requires a full\n * process restart.\n */\n private startRecovery(): void {\n if (this.recoveryTimer) return // already in recovery\n this.deps.logger?.error('[schedules] Lost fire ownership — entering recovery mode')\n this.recoveryTimer = setInterval(() => {\n void this.recoveryAttempt()\n }, RECOVERY_RETRY_INTERVAL_MS)\n this.recoveryTimer.unref()\n }\n\n private async recoveryAttempt(): Promise<void> {\n try {\n const acquired = await this.deps.ownership.tryAcquire()\n if (acquired && this.deps.ownership.isOwner()) {\n this.stopRecovery()\n this.running = true\n this.ticker = setInterval(() => {\n void this.tick()\n }, TICK_INTERVAL_MS)\n this.ticker.unref()\n this.deps.logger?.error('[schedules] Re-acquired fire ownership — recovery complete')\n }\n } catch {\n // tryAcquire failures during recovery are expected — suppress.\n }\n }\n\n private stopRecovery(): void {\n if (this.recoveryTimer) {\n clearInterval(this.recoveryTimer)\n this.recoveryTimer = null\n }\n }\n\n // ── Public CRUD ──\n\n /**\n * Create a scheduled task.\n *\n * - Generates an 8-char id via crypto.randomUUID()\n * - Parses the cron expression to compute nextFireAt + jitter\n * - Enforces a max of 50 tasks (throws if exceeded)\n */\n add(input: ScheduledTaskInput): ScheduledTask {\n if (this.registry.count() >= MAX_TASKS) {\n throw new Error(`Schedule limit reached (max ${MAX_TASKS}). Remove some tasks before adding more.`)\n }\n\n const id = crypto.randomUUID().slice(0, 8)\n const now = this.deps.now?.() ?? Date.now()\n const nextBase = parseCron(input.cronExpression, new Date(now + 60_000))\n const nextFireAt = this.applyJitter(id, nextBase, input.recurring)\n\n const task: ScheduledTask = {\n id,\n name: input.name,\n prompt: input.prompt,\n cronExpression: input.cronExpression,\n enabled: true,\n recurring: input.recurring,\n origin: input.origin,\n createdAt: now,\n nextFireAt,\n maxDurationMs: input.maxDurationMs ?? DEFAULT_MAX_DURATION_MS,\n maxRetries: input.maxRetries ?? DEFAULT_MAX_RETRIES,\n // RFC-327 D2:缺省不写该字段——既有任务与其序列化形态零变化(前向兼容)。\n ...(input.subscriber ? { subscriber: input.subscriber } : {}),\n }\n\n this.registry.add(task)\n this.saveOne(task)\n return task\n }\n\n /** Remove a task by id. Returns true if the task existed and was removed. */\n remove(id: string): boolean {\n const removed = this.registry.remove(id)\n if (removed) {\n void this.deleteOne(id)\n }\n return removed\n }\n\n /**\n * Update mutable fields of a task.\n *\n * If cronExpression changes, nextFireAt is recalculated with jitter.\n * Fields not in the patch are left unchanged.\n */\n update(\n id: string,\n patch: Partial<Pick<ScheduledTask, 'name' | 'prompt' | 'cronExpression' | 'enabled'>>,\n ): boolean {\n const task = this.registry.get(id)\n if (!task) return false\n\n if (patch.name !== undefined) {\n task.name = patch.name\n }\n if (patch.prompt !== undefined) {\n task.prompt = patch.prompt\n }\n if (patch.enabled !== undefined) {\n task.enabled = patch.enabled\n }\n if (patch.cronExpression !== undefined) {\n task.cronExpression = patch.cronExpression\n const now = this.deps.now?.() ?? Date.now()\n const nextBase = parseCron(task.cronExpression, new Date(now + 60_000))\n task.nextFireAt = this.applyJitter(task.id, nextBase, task.recurring)\n }\n\n this.saveOne(task)\n return true\n }\n\n /** Return all tasks, ordered by nextFireAt ascending. */\n list(): ScheduledTask[] {\n return this.registry.list()\n }\n\n /**\n * RFC-229 M2: return the set of task IDs that currently have a running job.\n * Used by the plugin panel to show [R] status indicators.\n */\n getRunningTaskIds(): Set<string> {\n return new Set(this.runningJobsByTaskId.keys())\n }\n\n /**\n * RFC-229 M3: read the most recent fire log entries for a task.\n * Returns up to `maxEntries` lines, newest first. Each entry is { timestamp, status }.\n */\n readFireLogs(taskId: string, maxEntries: number = 20): Array<{ timestamp: string; status: string }> {\n const logDir = resolve(OTTO_HOME, 'schedule-logs')\n const prefix = `${taskId}-`\n try {\n const files = readdirSync(logDir)\n .filter((f) => f.startsWith(prefix) && f.endsWith('.log'))\n .sort()\n .reverse() // newest first\n const entries: Array<{ timestamp: string; status: string }> = []\n for (let i = 0; i < files.length && entries.length < maxEntries; i++) {\n try {\n const content = readFileSync(resolve(logDir, files[i]!), 'utf-8').trim()\n // Format: \"2026-07-23T09:23:12.000Z completed (attempt 1)\"\n const spaceIdx = content.indexOf(' ')\n const timestamp = spaceIdx > 0 ? content.slice(0, spaceIdx) : content\n const status = spaceIdx > 0 ? content.slice(spaceIdx + 1) : ''\n entries.push({ timestamp, status })\n } catch {\n // skip unreadable file\n }\n }\n return entries\n } catch {\n return []\n }\n }\n\n /**\n * Force-execute a task immediately, regardless of its nextFireAt.\n * Does NOT advance nextFireAt — this is an extra execution.\n *\n * review S3: guarded against a concurrent tick-driven fire() for the same task — without\n * this, a manual runNow() while tick's fire() await is in flight (fire() only adds to\n * `firing` synchronously before awaiting startJob) could dispatch two jobs for one task.\n */\n runNow(id: string): void {\n const task = this.registry.get(id)\n if (!task) {\n throw new Error(`Schedule task \"${id}\" not found`)\n }\n if (!task.enabled) {\n throw new Error(`Schedule task \"${id}\" is disabled`)\n }\n if (this.firing.has(task.id)) {\n throw new Error(`Schedule task \"${id}\" is already firing`)\n }\n this.firing.add(task.id)\n void this.fire(task)\n }\n\n /**\n * RFC-229 M1: cancel the currently running job for a schedule task.\n *\n * Only cancels jobs in `running` or `needs_input` status — a job that has already\n * produced a diff (`ready`) is deliberately left untouched so the user can `/job apply`.\n *\n * Does NOT delete the task definition or alter the cron schedule. The task will fire\n * again at its next cron-triggered time.\n *\n * Sets a cancel intent in `cancelRequested` so the in-flight `fire()` loop stops\n * retrying — it does NOT prematurely remove the task from `firing` (that belongs to\n * `fire().finally`), avoiding a race window where the task appears idle but `fire()` is\n * still running (RFC-229 R10).\n */\n cancel(taskId: string): CancelResult {\n const task = this.registry.get(taskId)\n if (!task) return { ok: false, reason: 'task_not_found' }\n\n // Find the running job — prefer the in-memory tracking map, fallback to scanning\n // jobRegistry (the map is empty after a process restart, RFC-229 D9).\n let jobId: string | undefined\n const record = this.runningJobsByTaskId.get(taskId)\n if (record) {\n jobId = record.jobId\n } else {\n // Fallback: scan jobRegistry for a still-running job. We can't reliably\n // associate a job back to its taskId, so only use this if we find at most one\n // running job — a best-effort recovery after process restart.\n const runningJobs = this.deps.jobRegistry.list().filter(\n j => j.status === 'running' || j.status === 'needs_input',\n )\n if (runningJobs.length === 1) {\n jobId = runningJobs[0]!.id\n }\n }\n if (!jobId) return { ok: false, reason: 'not_running' }\n\n // Guard: only cancel jobs that are genuinely still running or waiting for input.\n // A job that already produced a diff (ready) must not be canceled — the user may\n // still want to `/job apply` (RFC-229 R9).\n const job = this.deps.jobRegistry.get(jobId)\n if (!job) return { ok: false, reason: 'job_not_found' }\n if (job.status === 'ready') return { ok: false, reason: 'job_is_ready' }\n if (job.status === 'applied') return { ok: false, reason: 'job_already_applied' }\n if (job.status === 'failed') return { ok: false, reason: 'job_already_failed' }\n if (job.status === 'canceled') return { ok: false, reason: 'job_already_canceled' }\n\n // Set cancel intent — the in-flight `fire()` loop checks this before each retry\n // attempt and will stop without calling jobRegistry.cancel() itself (RFC-229 R10).\n this.cancelRequested.add(task.id)\n\n // Cancel the job. This calls e.abort() + removeWorktree + update(status:'canceled')\n // inside AgentJobRegistry.cancel(), which will trigger onExit('canceled') that\n // waitForCompletion picks up as the 'canceled' outcome.\n this.deps.jobRegistry.cancel(jobId)\n\n return { ok: true }\n }\n\n // ── Internal: tick ──\n\n /**\n * Heartbeat: evict expired recurring tasks, find & fire due tasks.\n * Reentry guard (MF-1) prevents overlapping ticks.\n */\n private async tick(): Promise<void> {\n if (this.tickInProgress) return\n this.tickInProgress = true\n\n try {\n // A3: heartbeat the ownership lock so a live owner never looks stale.\n // RFC-169 D3d: the remote FireOwnership implementation internally switches this call\n // to an acquire-attempt once it has lost ownership (network partition healed, etc.) —\n // SchedulerService stays uniform across local/remote, no branching here.\n await this.deps.ownership.renew()\n\n // review S4: if renew() detected a takeover (lock token no longer ours — e.g. this\n // process was suspended past STALE_MS and a successor claimed the lock), stop firing\n // but enter recovery mode — periodically re-attempt acquire so the process can\n // reclaim ownership when the competitor exits or a network partition heals (N1,\n // RFC-169 终局复审 2026-07-23: the original S4 fix permanently killed the ticker\n // with no re-acquire path, contradicting D3d's auto-recovery promise for remote\n // mode and making local-mode suspend-then-resume recovery impossible).\n if (!this.deps.ownership.isOwner()) {\n this.running = false\n if (this.ticker) {\n clearInterval(this.ticker)\n this.ticker = null\n }\n this.startRecovery()\n return\n }\n\n // review S5: the owner only loaded tasks once at start() — a task added/removed via\n // a non-owner process's `/schedule add` (writing directly to the store) was invisible\n // to the owner's in-memory registry until restart, so it could never fire. Periodically\n // re-sync from the store (every RELOAD_INTERVAL_TICKS ticks — cheap relative to\n // TICK_INTERVAL_MS=1s, and store reads are fail-open/best-effort).\n this.tickCount++\n if (this.tickCount % RELOAD_INTERVAL_TICKS === 0) {\n await this.reloadFromStore()\n }\n\n const now = this.deps.now?.() ?? Date.now()\n\n // Evict recurring tasks whose *next* fire is still >7 days stale (review S1: using\n // lastFiredAt/createdAt as the basis wrongly evicted low-frequency recurring tasks —\n // e.g. a monthly cron got deleted 7 days after its first fire, before it could ever\n // fire again. nextFireAt reflects the task's own cadence, so a monthly task's\n // nextFireAt is always <30 days out and never spuriously exceeds the 7-day threshold).\n for (const task of this.registry.list()) {\n if (task.recurring && now - task.nextFireAt > SEVEN_DAYS) {\n this.registry.remove(task.id)\n void this.deleteOne(task.id)\n }\n }\n\n // Fire due tasks\n const due = this.registry.findDue(now)\n for (const task of due) {\n if (this.firing.has(task.id)) continue\n\n // S6 regression guard: a failed one-shot is kept (not deleted) with a *stale*\n // nextFireAt in the past, so on the very next tick findDue() would see it as \"due\"\n // again and the ticker would auto-refire it — turning a single failure into an\n // infinite auto-retry loop. One-shot tasks only ever get ONE ticker-driven attempt;\n // once marked lastFireFailed, only an explicit runNow() may retry it.\n //\n // RFC-229 M1: same guard applies to one-shot tasks canceled by the user —\n // lastFireCancelled is set instead of lastFireFailed (cancel is not a failure),\n // but the ticker still must not auto-refire.\n if (!task.recurring && (task.lastFireFailed || task.lastFireCancelled)) continue\n\n // Advance nextFireAt before dispatching (MF-1); persist just this one (A2)\n //\n // RFC-327 D2:**先捕获本次触发时刻再推进**——`registry.update` 就地修改 task 对象,\n // 推进后 `task.nextFireAt` 已是下一次的时刻。订阅者的 occurrenceId 必须由**本次**\n // 计划触发时刻派生,否则同一次触发的重投会拿到\"下一次\"的 id,且相邻两次触发的\n // id 会串位——幂等键彻底失效。\n const scheduledFor = task.nextFireAt\n const nextBase = parseCron(task.cronExpression, new Date(now + 60_000))\n const jittered = this.applyJitter(task.id, nextBase, task.recurring)\n this.registry.update(task.id, { nextFireAt: jittered })\n this.saveOne(task)\n\n this.firing.add(task.id)\n void this.fire(task, scheduledFor)\n }\n } finally {\n this.tickInProgress = false\n }\n }\n\n // ── Internal: fire ──\n\n /**\n * Execute a task: start a job, monitor completion via registry events,\n * retry on failure up to maxRetries. Handles one-shot cleanup.\n *\n * review S4 residual risk (accepted): fire() runs independent of the ticker — if this\n * process is suspended mid-fire and loses ownership (S4 stops the ticker on the next\n * tick, but does not abort in-flight fire() calls), this in-flight call can still resolve\n * and write a stale lastFiredAt/nextFireAt back to the store after a successor process\n * has taken over. Harmless one-time overwrite (this process's ticker is already stopped,\n * so no repeated corruption) — needs 5-min suspend + in-flight fire + a same-window write\n * race with the new owner to manifest. Revisit if it proves to matter in practice.\n */\n /**\n * RFC-327 D2:向订阅者投递一次到点通知。\n *\n * `occurrenceId` 由 `{taskId, 本次计划触发时刻}` 派生——**同一次计划触发的重复投递\n * 得到同一个 id**,这正是幂等键的意义:service 重启后收到重投也能识别\"这次我处理过\"。\n * 若用随机 id,重复投递会被当成两次不同触发,幂等失效。\n *\n * 返回是否送达;未送达(服务未运行/宿主未接线)只记日志,不抛错、不重试。\n */\n private fireSubscriber(task: ScheduledTask, scheduledFor?: number): boolean {\n const subscriber = task.subscriber\n if (!subscriber) return false\n\n const notify = this.deps.notifySubscriber\n if (!notify) {\n this.writeFireLog(task.id, 'subscriber tick skipped: host has no notifySubscriber port')\n return false\n }\n\n const occurredAt = this.deps.now?.() ?? Date.now()\n // 以**本次**计划触发时刻(非实际投递时刻、非已推进的 nextFireAt)派生,保证重投同 id。\n // ticker 路径由调用方传入(推进 nextFireAt 之前捕获);runNow 是计划外的额外执行,\n // 没有\"计划时刻\",故用实际触发时刻——它天然唯一,符合\"额外执行不参与幂等去重\"的语义。\n const occurrenceId = `${task.id}@${scheduledFor ?? occurredAt}`\n\n let delivered = false\n try {\n delivered = notify(subscriber, { scheduleId: task.id, occurrenceId, occurredAt })\n } catch (err) {\n // 插件侧异常绝不能让调度器崩溃或让本任务卡在 firing 集合里。\n this.deps.logger?.error(`[schedules] subscriber notify threw for task ${task.id}:`, err)\n this.writeFireLog(task.id, `subscriber tick error: ${err instanceof Error ? err.message : String(err)}`)\n return false\n }\n\n this.writeFireLog(\n task.id,\n delivered\n ? `subscriber tick delivered (${subscriber.pluginId}:${subscriber.serviceId}, occurrence ${occurrenceId})`\n : `subscriber tick not delivered (${subscriber.pluginId}:${subscriber.serviceId} not running)`,\n )\n return delivered\n }\n\n private async fire(task: ScheduledTask, scheduledFor?: number): Promise<void> {\n let outcome: 'success' | 'failed' = 'failed'\n let outcomeCancelled = false\n let lastError: string | undefined\n const fireToken = ++this._nextFireToken\n\n try {\n // RFC-327 D2:订阅者任务不派 AgentJob——到点只投递一条通知,插件服务若要产生工作\n // 须自己走 capability-gated startJob(timer 不绕过审批/origin/worktree 约束)。\n //\n // 不进重试循环:通知是 at-least-once 的旁路信号,\"至少送到\"由下一个 cron 周期承担,\n // 即时重试只会在服务未起来时空转(且重复投递同一 occurrenceId 反而加重幂等负担)。\n if (task.subscriber) {\n outcome = this.fireSubscriber(task, scheduledFor) ? 'success' : 'failed'\n return\n }\n\n const origin: 'user' | 'main' = task.origin === 'model' ? 'main' : 'user'\n const sessionId = this.deps.getSessionId()\n\n for (let attempt = 0; attempt <= task.maxRetries; attempt++) {\n // RFC-229 M1: cancel() sets a cancel intent in `cancelRequested` and calls\n // jobRegistry.cancel() — the canceled job will resolve waitForCompletion as\n // 'canceled'. But if this loop hasn't yet entered waitForCompletion (e.g. the\n // cancel happened between retry attempts), the cancel intent flag blocks the next\n // attempt entirely without launching a new job.\n if (this.cancelRequested.has(task.id)) {\n this.writeFireLog(task.id, `canceled by user (attempt ${attempt + 1})`)\n outcomeCancelled = true\n break\n }\n\n const job = this.deps.startJob({\n title: task.name,\n prompt: task.prompt,\n sessionId,\n origin,\n })\n\n // RFC-229 M1: record the running job so cancel() can look it up\n this.runningJobsByTaskId.set(task.id, { jobId: job.id, fireToken, attempt })\n\n const result = await this.waitForCompletion(job.id, task.maxDurationMs)\n\n if (result === 'success') {\n this.registry.update(task.id, {\n lastFiredAt: this.deps.now?.() ?? Date.now(),\n lastFireFailed: false,\n lastFireError: undefined,\n })\n this.saveOne(task)\n // review I3: when the job produced a diff (status 'ready'), the schedule fire log\n // reads \"completed\" while a human may see nothing has actually landed — surface\n // the pending review explicitly so the log itself explains \"why nothing changed\".\n const finishedJob = this.deps.jobRegistry.get(job.id)\n const pendingApply = finishedJob?.status === 'ready' && !!finishedJob.diff?.trim()\n this.writeFireLog(\n task.id,\n pendingApply\n ? `completed (attempt ${attempt + 1}) — diff ready, /job apply ${job.id}`\n : `completed (attempt ${attempt + 1})`,\n )\n outcome = 'success'\n return\n }\n\n // RFC-229 M1: canceled is distinct from failed — the user deliberately aborted the\n // job, so we must NOT enter the retry loop. Log it and stop.\n if (result === 'canceled') {\n this.writeFireLog(task.id, `canceled by user (attempt ${attempt + 1})`)\n outcomeCancelled = true\n break\n }\n\n lastError = result === 'timeout' ? 'timed out' : 'job failed'\n // Failed/timeout — will retry if attempts remain\n\n // RFC-230:重试之间的固定延迟——只在还有剩余 attempt 时等待(非最后一次失败,\n // 避免多余等待)。retryBackoffMs 默认 0(保持现状行为,`sleep(0)` 仍是一次\n // 微任务调度点)。\n if (attempt < task.maxRetries) {\n const backoffMs = this.deps.retryBackoffMs ?? 0\n const sleep = this.deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))\n await sleep(backoffMs)\n }\n }\n\n // Exhausted all retries (only reached when outcomeCancelled is false — the cancel\n // branch breaks without reaching here)\n if (!outcomeCancelled) {\n this.writeFireLog(task.id, `failed after ${task.maxRetries + 1} attempt(s)`)\n }\n } catch (err) {\n lastError = err instanceof Error ? err.message : String(err)\n this.deps.logger?.error(`[schedules] fire failed for task ${task.id}:`, err)\n this.writeFireLog(task.id, `error: ${lastError}`)\n } finally {\n this.firing.delete(task.id)\n this.runningJobsByTaskId.delete(task.id)\n\n // RFC-229 M1: `cancelRequested` prevents the finally block from overwriting the\n // cancel state with conflicting data (e.g. lastFireFailed: true). When cancel() is\n // called, it sets cancelRequested and calls jobRegistry.cancel() — the finally block\n // must honour that intent and skip the regular failed-case saveOne.\n const cancelled = this.cancelRequested.has(task.id)\n this.cancelRequested.delete(task.id)\n\n if (!task.recurring) {\n if (outcome === 'success') {\n this.registry.remove(task.id)\n await this.deleteOne(task.id)\n } else if (outcomeCancelled) {\n // RFC-229 M1: a canceled one-shot is kept (not deleted) so the user can see it\n // and optionally retry via runNow. Mark lastFireCancelled (not lastFireFailed)\n // so the ticker's one-shot guard skips it and the UI renders \"canceled\" not \"failed\".\n this.registry.update(task.id, {\n lastFireCancelled: true,\n lastFireError: undefined,\n })\n this.saveOne(task)\n } else if (cancelled) {\n // Cancel intent was set (by cancel()) but the job never emitted 'canceled'\n // through onExit — e.g. it was killed externally. Don't write any conflicting\n // state; leave the task as-is.\n } else {\n // review S6: a failed one-shot is no longer silently deleted — keep it (marked\n // lastFireFailed) so it's visible in `/schedule list`/`schedule_list` and can be\n // retried via runNow. Previously the finally block unconditionally removed\n // one-shot tasks regardless of outcome, leaving only a best-effort log file as\n // evidence of failure.\n this.registry.update(task.id, { lastFireFailed: true, lastFireError: lastError })\n this.saveOne(task)\n }\n }\n }\n }\n\n // ── Internal: event-based completion monitor ──\n\n /**\n * Subscribe to AgentJobRegistry onUpdate / onExit and resolve when the\n * job reaches a conclusion:\n *\n * 'success': ready (diff produced) or applied\n * 'failed': failed\n * 'timeout': maxDurationMs elapsed while still running (triggers cancel)\n * 'canceled': the job was canceled (deliberately, by user via cancel(), not a failure)\n *\n * ready is NOT terminal in the engine, but for the schedule it's a success\n * (the diff exists, the user can /job apply later).\n *\n * needs_input extends the timeout — the agent is blocked on user input\n * and should not be killed while waiting.\n */\n private waitForCompletion(jobId: string, maxDurationMs: number): Promise<'success' | 'failed' | 'timeout' | 'canceled'> {\n return new Promise((resolve) => {\n let settled = false\n let timeout: ReturnType<typeof setTimeout> | undefined\n let unsubExit: (() => void) | undefined\n let unsubUpdate: (() => void) | undefined\n\n // review S8: needs_input previously reset the *full* maxDurationMs window on every\n // occurrence — an agent that repeatedly hits needs_input (e.g. a tool that keeps\n // asking for confirmation) could extend the wait indefinitely and never be canceled.\n // Cap the total wall-clock budget at a hard multiple of maxDurationMs; needs_input\n // still extends the window, but only up to this ceiling.\n const startedAt = this.deps.now?.() ?? Date.now()\n const hardDeadline = startedAt + maxDurationMs * NEEDS_INPUT_MAX_EXTENSION_FACTOR\n\n const cleanup = () => {\n if (timeout) clearTimeout(timeout)\n unsubExit?.()\n unsubUpdate?.()\n }\n\n const finish = (result: 'success' | 'failed' | 'timeout' | 'canceled') => {\n if (settled) return\n settled = true\n cleanup()\n resolve(result)\n }\n\n const armTimeout = (ms: number) => {\n if (timeout) clearTimeout(timeout)\n timeout = setTimeout(() => {\n this.deps.jobRegistry.cancel(jobId)\n finish('failed')\n }, ms)\n }\n\n // onUpdate: watch for ready (terminal for schedule) and needs_input (extend timeout)\n unsubUpdate = this.deps.jobRegistry.onUpdate((job) => {\n if (job.id !== jobId) return\n\n if (job.status === 'ready') {\n // Diff produced — success. Don't cancel; user may /job apply.\n finish('success')\n } else if (job.status === 'needs_input') {\n // Agent blocked on user input — extend timeout instead of killing, but never\n // past hardDeadline (S8).\n const now = this.deps.now?.() ?? Date.now()\n const remaining = Math.min(maxDurationMs, hardDeadline - now)\n if (remaining <= 0) {\n this.deps.jobRegistry.cancel(jobId)\n finish('failed')\n return\n }\n armTimeout(remaining)\n }\n })\n\n // onExit: terminal states\n unsubExit = this.deps.jobRegistry.onExit((job) => {\n if (job.id !== jobId) return\n\n if (job.status === 'failed') {\n finish('failed')\n } else if (job.status === 'canceled') {\n // RFC-229 M1: distinguish deliberate cancel from a genuine failure — the\n // fire() retry loop must not retry a canceled job.\n finish('canceled')\n } else if (job.status === 'applied') {\n finish('success')\n }\n })\n\n // Only a still-running job hits the timeout: cancel it and resolve as\n // failed (retry path), not relying solely on the cancel→onExit event.\n armTimeout(maxDurationMs)\n })\n }\n\n // ── Internal: jitter ──\n\n /**\n * Apply a deterministic per-task jitter to spread cron fires. Recurring tasks use a\n * 30-min window (spreads periodic load); one-shot tasks use a 90s window (review S2 —\n * RFC-087 §D3 intends one-shot \"in ~30s/~90s\", not \"delayed by up to 30 min\"; a 30-min\n * window made \"remind me in 30 minutes\" arrive up to ~61 minutes later).\n * Uses djb2 hash of the task id for reproducibility across restarts.\n */\n private applyJitter(taskId: string, baseTime: Date, recurring: boolean): number {\n let hash = 0\n for (let i = 0; i < taskId.length; i++) {\n hash = ((hash << 5) - hash) + taskId.charCodeAt(i)\n hash |= 0\n }\n const window = recurring ? JITTER_WINDOW_MS : ONE_SHOT_JITTER_WINDOW_MS\n const jitter = Math.abs(hash) % window\n return baseTime.getTime() + jitter\n }\n\n // ── Internal: persistence ──\n\n /**\n * Persist a single task (fire-and-forget). Snapshot-per-id means we never\n * rewrite the whole collection and never delete-by-diff — that would let one\n * process delete another process's schedules (A2).\n */\n private saveOne(task: ScheduledTask): void {\n void this.deps.store.saveTask(task).catch((err) => {\n this.deps.logger?.error(`[schedules] Failed to save schedule ${task.id}:`, err)\n })\n }\n\n /** Delete a single task snapshot (awaited — must confirm removal to prevent zombies on store reload). */\n private async deleteOne(id: string): Promise<boolean> {\n try {\n return await this.deps.store.deleteTask(id)\n } catch (err) {\n this.deps.logger?.error(`[schedules] Failed to delete schedule ${id}:`, err)\n return false\n }\n }\n\n /** Flush every in-memory task to the store (used on stop; bounded, no diff-delete). */\n private async flushAll(): Promise<void> {\n try {\n for (const task of this.registry.list()) {\n await this.deps.store.saveTask(task)\n }\n } catch (err) {\n this.deps.logger?.error('[schedules] Failed to flush schedules:', err)\n }\n }\n\n // ── Internal: fire log ──\n\n /**\n * Write a one-line fire log to ${OTTO_HOME}/schedule-logs/<taskId>-<epoch>.log, then prune\n * older logs for this task beyond MAX_FIRE_LOGS_PER_TASK (review S7: a `* * * * *` task\n * fires 1440 times/day with no prior retention — the directory grew unbounded). Best-effort\n * throughout — swallows errors silently (logging is non-critical).\n */\n private writeFireLog(taskId: string, status: string): void {\n // RFC-345:影子态(OTTO_SHADOW)零磁盘写入——fire log 是纯本机持久化产物,\n // 影子进程跳过不批。读回放(readFireLogs)不受影响。\n if (isShadowModeEnabled()) return\n const logDir = resolve(OTTO_HOME, 'schedule-logs')\n const logFile = resolve(logDir, `${taskId}-${Date.now()}.log`)\n try {\n mkdirSync(logDir, { recursive: true })\n writeFileSync(logFile, `${new Date().toISOString()} ${status}\\n`, 'utf-8')\n this.pruneFireLogs(logDir, taskId)\n } catch {\n // best-effort logging\n }\n }\n\n /** Keep only the newest MAX_FIRE_LOGS_PER_TASK log files for a given taskId. */\n private pruneFireLogs(logDir: string, taskId: string): void {\n try {\n const prefix = `${taskId}-`\n const files = readdirSync(logDir)\n .filter((f) => f.startsWith(prefix) && f.endsWith('.log'))\n .sort() // filenames embed epoch ms — lexical sort == chronological\n const excess = files.length - MAX_FIRE_LOGS_PER_TASK\n if (excess <= 0) return\n for (const f of files.slice(0, excess)) {\n try {\n unlinkSync(resolve(logDir, f))\n } catch {\n // best-effort — a single stale/locked file must not block the rest\n }\n }\n } catch {\n // best-effort — directory listing failure is non-critical\n }\n }\n}\n","/**\n * schedule-capability.ts —— Binds a SchedulerService to the shape the schedule_* model\n * tools consume (ToolCallbacksDeps.schedule).\n *\n * `origin` is pinned to 'model' HERE (the model-vs-user distinction lives in\n * wiring, not tool execute — ToolCallContext has no origin field). The scheduler\n * is resolved lazily so this capability object can exist at tool-registration\n * time (the `when: Boolean(c.schedule)` gate) even before the interactive scheduler\n * is constructed; it is populated via App.setScheduleService during interactive\n * setup (RFC-087 A1).\n */\nimport type { SchedulerService } from './scheduler'\n\nexport interface ScheduleCapability {\n create: (input: {\n cron: string\n prompt: string\n recurring: boolean\n name?: string\n /** review I2: optional override for the default 5-min single-fire timeout. */\n maxDurationMs?: number\n }) => {\n taskId: string\n nextFireAt: number\n name: string\n }\n list: () => Array<{\n id: string\n name: string\n cronExpression: string\n enabled: boolean\n recurring: boolean\n nextFireAt: number\n lastFiredAt?: number\n lastFireFailed?: boolean\n lastFireError?: string\n lastFireCancelled?: boolean\n }>\n delete: (taskId: string) => boolean\n run: (taskId: string) => void\n cancel: (taskId: string) => { ok: boolean; reason?: string }\n}\n\nexport function createScheduleCapability(\n getScheduler: () => SchedulerService | undefined,\n): ScheduleCapability {\n return {\n create: (input) => {\n const scheduler = getScheduler()\n if (!scheduler) {\n throw new Error('Schedule scheduler is not available in this session.')\n }\n const task = scheduler.add({\n name: input.name ?? 'schedule',\n prompt: input.prompt,\n cronExpression: input.cron,\n recurring: input.recurring,\n origin: 'model', // pinned by wiring — schedule_create is always model-originated\n maxDurationMs: input.maxDurationMs,\n })\n return { taskId: task.id, nextFireAt: task.nextFireAt, name: task.name }\n },\n list: () =>\n (getScheduler()?.list() ?? []).map((t) => ({\n id: t.id,\n name: t.name,\n cronExpression: t.cronExpression,\n enabled: t.enabled,\n recurring: t.recurring,\n nextFireAt: t.nextFireAt,\n lastFiredAt: t.lastFiredAt,\n lastFireFailed: t.lastFireFailed,\n lastFireError: t.lastFireError,\n lastFireCancelled: t.lastFireCancelled,\n })),\n delete: (taskId) => getScheduler()?.remove(taskId) ?? false,\n run: (taskId) => {\n getScheduler()?.runNow(taskId)\n },\n cancel: (taskId) => {\n return getScheduler()?.cancel(taskId) ?? { ok: false, reason: 'Schedule scheduler is not available.' }\n },\n }\n}\n"],"mappings":"kgBAGA,IAAa,EAAb,KAA8B,CAC5B,MAEA,aAAc,CACZ,KAAK,MAAQ,IAAI,IAGnB,IAAI,EAA2B,CAC7B,GAAI,KAAK,MAAM,IAAI,EAAK,GAAG,CACzB,MAAU,MAAM,wCAAwC,EAAK,GAAG,GAAG,CAErE,KAAK,MAAM,IAAI,EAAK,GAAI,EAAK,CAG/B,OAAO,EAAqB,CAC1B,OAAO,KAAK,MAAM,OAAO,EAAG,CAG9B,OACE,EACA,EAaS,CACT,IAAM,EAAO,KAAK,MAAM,IAAI,EAAG,CAY/B,OAXK,GAED,EAAM,UAAY,IAAA,KAAW,EAAK,QAAU,EAAM,SAClD,EAAM,cAAgB,IAAA,KAAW,EAAK,YAAc,EAAM,aAC1D,EAAM,aAAe,IAAA,KAAW,EAAK,WAAa,EAAM,YACxD,EAAM,gBAAkB,IAAA,KAAW,EAAK,cAAgB,EAAM,eAC9D,EAAM,aAAe,IAAA,KAAW,EAAK,WAAa,EAAM,YACxD,EAAM,iBAAmB,IAAA,KAAW,EAAK,eAAiB,EAAM,gBAChE,EAAM,gBAAkB,IAAA,KAAW,EAAK,cAAgB,EAAM,eAC9D,EAAM,oBAAsB,IAAA,KAAW,EAAK,kBAAoB,EAAM,mBAEnE,IAXW,GAcpB,IAAI,EAAuC,CACzC,OAAO,KAAK,MAAM,IAAI,EAAG,CAG3B,MAAwB,CACtB,MAAO,CAAC,GAAG,KAAK,MAAM,QAAQ,CAAC,CAAC,MAAM,EAAG,IAAM,EAAE,WAAa,EAAE,WAAW,CAQ7E,QAAQ,EAA8B,CACpC,IAAM,EAAuB,EAAE,CAC/B,IAAK,IAAM,KAAQ,KAAK,MAAM,QAAQ,CAChC,EAAK,SAAW,EAAK,YAAc,GACrC,EAAI,KAAK,EAAK,CAIlB,OADA,EAAI,MAAM,EAAG,IAAM,EAAE,WAAa,EAAE,WAAW,CACxC,EAGT,OAAgB,CACd,OAAO,KAAK,MAAM,KAGpB,IAAI,EAAqB,CACvB,OAAO,KAAK,MAAM,IAAI,EAAG,GCxD7B,SAAgB,EAAU,EAAc,EAAmB,CACzD,GAAM,CAAE,cAAa,YAAW,WAAU,aAAY,WAAU,iBAAgB,kBAC9E,EAAgB,EAAK,CAEjB,EAAY,EAAO,IAAI,KAAK,EAAK,SAAS,CAAC,CAAG,IAAI,KACxD,EAAU,WAAW,EAAG,EAAE,CAE1B,IAAM,EAAU,IAAI,KAAK,EAAU,SAAS,CAAG,IAAM,GAAK,GAAK,GAAK,IAAK,CACrE,EAAU,IAAI,KAAK,EAAU,SAAS,CAAC,CAE3C,KAAO,EAAQ,SAAS,EAAI,EAAQ,SAAS,EAAE,CAE7C,IAAM,EAAS,EAAQ,YAAY,CAC7B,EAAU,EAAU,EAAa,EAAO,CAC9C,GAAI,IAAY,KAAM,CACpB,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,SAAS,EAAQ,UAAU,CAAG,EAAE,CACxC,EAAQ,WAAW,EAAG,EAAE,CACxB,SAEF,GAAI,IAAY,EAAQ,CACtB,EAAQ,WAAW,EAAQ,CAC3B,EAAQ,WAAW,EAAG,EAAE,CACxB,SAIF,IAAM,EAAO,EAAQ,UAAU,CACzB,EAAS,EAAU,EAAW,EAAK,CACzC,GAAI,IAAW,KAAM,CACnB,EAAQ,SAAS,EAAW,EAAU,CAAC,CACvC,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,QAAQ,EAAQ,SAAS,CAAG,EAAE,CACtC,EAAQ,WAAW,EAAG,EAAE,CACxB,SAEF,GAAI,IAAW,EAAM,CACnB,EAAQ,SAAS,EAAO,CACxB,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,WAAW,EAAG,EAAE,CACxB,SAIF,IAAM,EAAM,EAAQ,SAAS,CACvB,EAAM,EAAQ,QAAQ,CACtB,EAAW,EAAS,IAAI,EAAI,CAC5B,EAAW,EAAS,IAAI,EAAI,CAE9B,EAWJ,GAVA,AAOE,EAPE,GAAkB,EACT,GAAY,EACd,EACE,EACF,EACE,EAEA,GAGT,CAAC,EAAU,CACb,EAAQ,QAAQ,EAAQ,SAAS,CAAG,EAAE,CACtC,EAAQ,SAAS,EAAW,EAAU,CAAC,CACvC,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,WAAW,EAAG,EAAE,CACxB,SAIF,IAAM,EAAQ,EAAQ,UAAU,CAAG,EAC7B,EAAS,EAAU,EAAY,EAAM,CAC3C,GAAI,IAAW,KAAM,CAEnB,EAAQ,SAAS,EAAW,EAAW,CAAG,EAAE,CAC5C,EAAQ,YAAY,EAAQ,aAAa,CAAG,EAAE,CAC9C,EAAQ,QAAQ,EAAE,CAClB,EAAQ,SAAS,EAAW,EAAU,CAAC,CACvC,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,WAAW,EAAG,EAAE,CACxB,SAEF,GAAI,IAAW,EAAO,CACpB,EAAQ,SAAS,EAAS,EAAE,CAC5B,EAAQ,QAAQ,EAAE,CAClB,EAAQ,SAAS,EAAW,EAAU,CAAC,CACvC,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,WAAW,EAAG,EAAE,CACxB,SAIF,OAAO,EAGT,MAAU,MAAM,oCAAoC,EAAK,uBAAuB,EAAU,aAAa,GAAG,CAiB5G,SAAS,EAAgB,EAAgC,CACvD,GAAI,CAAC,GAAQ,CAAC,EAAK,MAAM,CACvB,MAAU,MAAM,wCAAwC,CAG1D,IAAM,EAAS,EAAK,MAAM,CAAC,MAAM,MAAM,CACvC,GAAI,EAAO,SAAW,EACpB,MAAU,MAAM,mDAAmD,EAAO,OAAO,IAAI,EAAK,GAAG,CAG/F,GAAM,CAAC,EAAU,EAAW,EAAU,EAAY,GAAY,EAExD,EAAc,EAAW,EAAU,EAAG,GAAG,CACzC,EAAY,EAAW,EAAW,EAAG,GAAG,CACxC,EAAW,EAAW,EAAU,EAAG,GAAG,CACtC,EAAa,EAAW,EAAY,EAAG,GAAG,CAC1C,EAAW,EAAW,EAAU,EAAG,EAAE,CAW3C,OARI,EAAS,IAAI,EAAE,EACjB,EAAS,IAAI,EAAE,CAOV,CAAE,cAAa,YAAW,WAAU,aAAY,WAAU,eAH1C,IAAa,IAG6C,eAF1D,IAAa,IAE6D,CAgBnG,SAAS,EAAW,EAAe,EAAa,EAAuB,CACrE,EAAoB,EAAM,CAE1B,IAAM,EAAS,IAAI,IACb,EAAQ,EAAM,MAAM,IAAI,CAE9B,IAAK,IAAM,KAAQ,EAAO,CACxB,GAAM,CAAE,QAAO,QAAS,EAAU,EAAK,CAGvC,GAFA,EAAa,EAAM,EAAK,CAEpB,IAAU,IACZ,IAAK,IAAI,EAAI,EAAK,GAAK,EAAK,GAAK,EAC/B,EAAO,IAAI,EAAE,SAEN,EAAM,SAAS,IAAI,CAAE,CAC9B,GAAM,CAAC,EAAU,GAAU,EAAM,MAAM,IAAI,CACrC,EAAQ,EAAc,EAAU,EAAK,CACrC,EAAM,EAAc,EAAQ,EAAK,CACvC,GAAI,EAAQ,GAAO,EAAM,GAAO,EAAQ,EACtC,MAAU,MAAM,uBAAuB,EAAM,YAAY,EAAM,GAAG,EAAI,mBAAmB,EAAI,GAAG,EAAI,GAAG,CAEzG,IAAK,IAAI,EAAI,EAAO,GAAK,EAAK,GAAK,EACjC,EAAO,IAAI,EAAE,KAEV,CACL,IAAM,EAAM,EAAc,EAAO,EAAK,CACtC,GAAI,EAAM,GAAO,EAAM,EACrB,MAAU,MACR,uBAAuB,EAAM,WAAW,EAAI,iBAAiB,EAAI,GAAG,EAAI,GACzE,CAEH,GAAI,EAAO,EAET,IAAK,IAAI,EAAI,EAAK,GAAK,EAAK,GAAK,EAC/B,EAAO,IAAI,EAAE,MAGf,EAAO,IAAI,EAAI,EAKrB,OAAO,EAGT,SAAS,EAAoB,EAAqB,CAGhD,GADgB,EAAM,QAAQ,cAAe,GAAG,CACpC,OAAS,EACnB,MAAU,MAAM,uBAAuB,EAAM,uBAAuB,CAIxE,SAAS,EAAU,EAA+C,CAChE,IAAM,EAAW,EAAK,QAAQ,IAAI,CAIlC,OAHI,IAAa,GACR,CAAE,MAAO,EAAM,KAAM,EAAG,CAE1B,CACL,MAAO,EAAK,UAAU,EAAG,EAAS,CAClC,KAAM,SAAS,EAAK,UAAU,EAAW,EAAE,CAAE,GAAG,CACjD,CAGH,SAAS,EAAa,EAAc,EAAoB,CACtD,GAAI,MAAM,EAAK,EAAI,EAAO,EACxB,MAAU,MAAM,wCAAwC,EAAK,GAAG,CAIpE,SAAS,EAAc,EAAW,EAA6B,CAC7D,IAAM,EAAI,SAAS,EAAG,GAAG,CACzB,GAAI,MAAM,EAAE,CACV,MAAU,MAAM,6CAA6C,EAAE,QAAQ,EAAY,GAAG,CAExF,OAAO,EAGT,SAAS,EAAU,EAAe,EAAgC,CAChE,IAAI,EAAsB,KAC1B,IAAK,IAAM,KAAK,EACV,GAAK,IAAY,IAAS,MAAQ,EAAI,KACxC,EAAO,GAGX,OAAO,EAGT,SAAS,EAAW,EAAuB,CACzC,IAAI,EAAO,IACX,IAAK,IAAM,KAAK,EACV,EAAI,IAAM,EAAO,GAEvB,OAAO,ECrQT,SAAS,EAAa,EAA8B,CAClD,OAAO,EAAQ,EAAW,YAAa,EAAa,CAuBtD,SAAgB,EAAoB,EAAsB,EAAgC,EAAE,CAAiB,CAO3G,IAAM,EACJ,EAAQ,cACP,GAAqB,CAClB,EAAiC,CAAE,KAAM,SAAU,CAAC,CACpD,EAAiC,CAAE,KAAM,OAAQ,QAAS,EAAa,EAAa,CAAE,CAAC,EACvF,EAAS,EAAQ,OAEvB,MAAO,CACL,MAAM,SAAS,EAAoC,CACjD,IAAM,EAAoC,CACxC,QAASA,EACT,GAAI,EAAK,GACT,KAAM,EACN,SAAU,CACR,UAAW,EAAK,UAChB,UAAW,KAAK,KAAK,CACrB,KAAM,CAAC,WAAW,CACnB,CACF,CACD,MAAM,EAAM,KAAK,EAAS,EAG5B,MAAM,cAAyC,CAC7C,IAAI,EACJ,GAAI,CACF,EAAM,MAAM,EAAM,MAAM,OACjB,EAAK,CAEZ,OADA,GAAQ,MAAM,iDAAkD,EAAI,CAC7D,EAAE,CAGX,IAAM,EAAyB,EAAE,CACjC,IAAK,IAAM,KAAM,EACf,GAAI,CACF,IAAM,EAAW,MAAM,EAAM,KAAK,EAAG,CACjC,GACF,EAAM,KAAK,EAAS,KAAK,OAEpB,EAAK,CACZ,GAAQ,MAAM,wCAAwC,EAAG,IAAK,EAAI,CAMtE,OADA,EAAM,MAAM,EAAG,IAAM,EAAE,WAAa,EAAE,WAAW,CAC1C,GAGT,MAAM,WAAW,EAA8B,CAC7C,GAAI,CACF,OAAO,MAAM,EAAM,OAAO,EAAG,OACtB,EAAK,CAEZ,OADA,GAAQ,MAAM,0CAA0C,EAAG,IAAK,EAAI,CAC7D,KAGZ,CCtEH,SAAS,EAAa,EAA8B,CAClD,OAAO,EAAQ,EAAW,kBAAkB,EAAa,OAAO,CAsBlE,SAAgB,EAAyB,EAAqC,CAC5E,IAAM,EAAQ,GAAY,CACpB,EAAY,EAAa,EAAa,CACtC,EAAa,GAAiB,CAChC,EAAQ,GAGN,MAA2B,CAC/B,GAAI,CAGF,EAAU,EAAW,CAAE,UAAW,GAAM,CAAC,CACzC,IAAM,EAAK,EAAS,EAAW,KAAK,CACpC,GAAI,CACF,EAAU,EAAI,EAAiB,EAAO,QAAQ,IAAK,EAAW,CAAC,QACvD,CACR,EAAU,EAAG,CAGf,MADA,GAAQ,GACD,SACA,EAAK,CACZ,GAAK,EAA8B,OAAS,SAAU,MAAM,EAC5D,MAAO,KAKL,MAA2E,CAC/E,GAAI,CAEF,OAAO,EADK,EAAa,EAAW,QAAQ,CAAC,MAAM,CACvB,MACtB,CACN,OAAO,OAKL,MAAiC,GAAa,EAAE,OAAS,KAGzD,EAAmB,GACnB,EAAQ,MAAQ,IAAA,IAAa,EAAQ,OAAS,IAAA,IAC9C,EAAQ,OAAS,EAAmB,GACjC,CAAC,EAAe,EAAQ,IAAI,CAI/B,EAAoB,GACpB,EAAQ,MAAQ,IAAA,IAAa,EAAQ,OAAS,IAAA,IAC9C,EAAQ,OAAS,EAAmB,GACjC,EAAe,EAAQ,IAAI,CAO9B,MAAgC,CACpC,GAAI,EAAO,MAAO,GAKlB,GAAI,GAAqB,CAAE,MAAO,GAClC,GAAI,GAAW,CAAE,MAAO,GAGxB,IAAM,EAAU,GAAa,CACzB,EACJ,GAAI,CACF,EAAQ,EAAS,EAAU,CAAC,aACtB,CAEN,OAAO,GAAW,CAUpB,GAPI,IAAY,MAAQ,EAAiB,EAAQ,EAO7C,EADkB,IAAY,MAAQ,EAAgB,EAAQ,GAC5C,KAAK,KAAK,CAAG,GAAS,IAAU,MAAO,GAG7D,IAAM,EAAY,GAAG,EAAU,SAAS,GAAY,GACpD,GAAI,CACF,EAAW,EAAW,EAAU,CAChC,EAAO,EAAW,CAAE,MAAO,GAAM,CAAC,MAC5B,CACN,MAAO,GAET,OAAO,GAAW,EAGpB,MAAO,CACL,MAAM,YAA+B,CACnC,OAAO,GAAgB,EAGzB,MAAM,OAAuB,CACtB,KAEL,IAAI,GAAW,GAAK,EAAO,CACzB,EAAQ,GACR,OAEF,GAAI,CACF,IAAM,EAAI,IAAI,KACd,EAAW,EAAW,EAAG,EAAE,MACrB,CACN,EAAQ,MAIZ,MAAM,SAAyB,CACxB,KAEL,IAAI,GAAW,GAAK,EAClB,GAAI,CACF,EAAW,EAAU,MACf,EAIV,EAAQ,KAGV,SAAmB,CACjB,OAAO,GAAS,GAAW,GAAK,GAEnC,CCvKH,MAAa,EAAyC,CACpD,CACE,QAAS,EACT,GAAK,GAAO,CACV,EAAG,KAAK;;;;;;QAMN,EAEL,CACF,CCND,IAAM,EAAN,cAAsC,CAA6D,CACjG,IAAuB,cAAuB,CAC5C,MAAO,gBAET,IAAuB,cAAuB,CAC5C,MAAO,gBAGT,UAAoB,EAAoC,CACtD,OAAO,EAAS,GAGlB,MAAM,SAAS,EAAoC,CACjD,MAAM,KAAK,KAAK,CAAE,QAAS,EAAkB,GAAI,EAAK,GAAI,KAAM,EAAM,CAAC,CASzE,MAAM,cAAyC,CAC7C,IAAM,EAAM,MAAM,KAAK,MAAM,CACvB,EAAyB,EAAE,CACjC,IAAK,IAAM,KAAM,EAAK,CACpB,IAAM,EAAW,MAAM,KAAK,KAAK,EAAG,CAChC,GAAU,EAAM,KAAK,EAAS,KAAK,CAGzC,OADA,EAAM,MAAM,EAAG,IAAM,EAAE,WAAa,EAAE,WAAW,CAC1C,EAGT,MAAM,WAAW,EAA8B,CAC7C,OAAO,KAAK,OAAO,EAAG,GAwB1B,SAAgB,EAA0B,EAAoD,CAS5F,OAAO,IAAI,EAPuD,CAChE,QAAS,GAFI,IAAI,IAAI,EAAQ,WAAW,CAAC,OAEtB,wBACnB,QAAS,EAAQ,QACjB,MAAO,EAAQ,OAAS,WAAW,MACnC,UAAW,EAAQ,WAAa,IAChC,MAAO,EAAQ,MAChB,CACgD,CCtCnD,SAAgB,EAA0B,EAAoD,CAC5F,IAAM,EAAQ,GAAY,CACpB,EAAY,EAAQ,OAAS,WAAW,MACxC,EAAY,EAAQ,WAAa,IACjC,EAAU,IAAI,IAAI,EAAQ,WAAW,CAAC,OACtC,EAAM,EAAQ,KAAO,KAAK,IAC5B,EAAQ,GAER,EAAkB,EAElB,EAEA,EAEE,EAAY,KAAO,IAAkD,CACzE,IAAM,EAAO,MAAM,EAAQ,SAAS,CAC9B,EAAa,IAAI,gBACjB,EAAU,eAAiB,EAAW,OAAO,CAAE,EAAU,CAC/D,GAAI,CACF,IAAM,EAAW,MAAM,EACrB,GAAG,EAAQ,iBAAiB,mBAAmB,EAAQ,MAAM,CAAC,SAAS,IACvE,CACE,OAAQ,OACR,QAAS,CACP,eAAgB,mBAChB,cAAe,UAAU,EAAK,QAC/B,CACD,KAAM,KAAK,UAAU,CAAE,QAAO,CAAC,CAC/B,OAAQ,EAAW,OACpB,CACF,CAED,OADK,EAAS,GACP,CAAE,GAAI,GAAM,KAAO,MAAM,EAAS,MAAM,CAA8B,CADpD,CAAE,GAAI,GAAO,OAAQ,gBAAiB,MAEzD,CAGN,MAAO,CAAE,GAAI,GAAO,OAAQ,gBAAiB,QACrC,CACR,aAAa,EAAQ,GAInB,MAAqC,CAKzC,GAAI,EAAiB,OAAO,EAE5B,IAAM,GAAW,SAA8B,CAC7C,EAAkB,GAAK,CACvB,IAAM,EAAS,MAAM,EAAU,UAAU,CACzC,GAAI,CAAC,EAAO,GAGV,MAFA,GAAQ,GACR,EAAoB,gBACb,GAET,IAAM,EAAW,EAAO,KAAK,WAAgB,GAG7C,MAFA,GAAQ,EACR,EAAoB,EAAW,IAAA,GAAY,WACpC,KACL,CAMJ,MAJA,GAAkB,EAClB,EAAQ,YAAc,CAChB,IAAoB,IAAS,EAAkB,IAAA,KACnD,CACK,GAGT,MAAO,CACL,aAEA,MAAM,OAAuB,CAK3B,GAAI,GAAK,CAAG,EAAA,IAAgC,OAE5C,GAAI,CAAC,EAAO,CAIV,MAAM,GAAY,CAClB,OAEF,EAAkB,GAAK,CACvB,IAAM,EAAS,MAAM,EAAU,QAAQ,CACvC,GAAI,CAAC,EAAO,GAAI,CAId,EAAQ,GACR,EAAoB,gBACpB,OAEc,EAAO,KAAK,UAAe,GAQzC,EAAoB,IAAA,IAHpB,EAAQ,GACR,EAAoB,aAMxB,MAAM,SAAyB,CACxB,AAEL,KADA,MAAM,EAAU,UAAU,CAClB,KAGV,SAAmB,CACjB,OAAO,GAGT,mBAA8D,CAC5D,OAAO,EAAQ,IAAA,GAAY,GAE9B,CC3JH,MAMM,EAAmB,IAyDzB,IAAa,EAAb,KAA8B,CAC5B,KACA,SACA,OAAwD,KACxD,cAA+D,KAC/D,OAAuC,IAAI,IAC3C,eAAyB,GACzB,QAAkB,GAClB,UAAoB,EAGpB,oBAA2G,IAAI,IAC/G,gBAAgD,IAAI,IACpD,eAAyB,EAEzB,YAAY,EAAqB,CAC/B,KAAK,KAAO,EACZ,KAAK,SAAW,EAAK,SAUvB,aAAuB,CACrB,OAAO,KAAK,KAAK,UAAU,SAAS,CAWtC,wBAAmE,CACjE,OAAO,KAAK,KAAK,UAAU,qBAAqB,CAMlD,MAAM,OAAuB,CACvB,SAAK,QAIT,IAAI,CADY,MAAM,KAAK,KAAK,UAAU,YAAY,CACxC,CAEZ,GAAI,CACF,IAAM,EAAQ,MAAM,KAAK,KAAK,MAAM,cAAc,CAClD,IAAK,IAAM,KAAQ,EACjB,GAAI,CAAE,KAAK,SAAS,IAAI,EAAK,MAAS,SAEjC,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,yDAA0D,EAAI,CAExF,OAIF,GAAI,CACF,IAAM,EAAM,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CACrC,EAAQ,MAAM,KAAK,KAAK,MAAM,cAAc,CAClD,IAAK,IAAM,KAAQ,EAAO,CAGxB,GAAI,EAAK,WAAa,EAAK,WAAa,EACtC,GAAI,CACF,IAAM,EAAW,EAAU,EAAK,eAAgB,IAAI,KAAK,EAAM,IAAO,CAAC,CACvE,EAAK,WAAa,KAAK,YAAY,EAAK,GAAI,EAAU,GAAK,MACrD,EAIV,GAAI,CAAE,KAAK,SAAS,IAAI,EAAK,MAAS,UAEjC,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,wCAAyC,EAAI,CAGvE,KAAK,QAAU,GACf,KAAK,OAAS,gBAAkB,CACzB,KAAK,MAAM,EACf,EAAiB,CACpB,KAAK,OAAO,OAAO,EAWrB,MAAc,iBAAiC,CAC7C,IAAI,EACJ,GAAI,CACF,EAAQ,MAAM,KAAK,KAAK,MAAM,cAAc,OACrC,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,qDAAsD,EAAI,CAClF,OAGF,IAAM,EAAW,IAAI,IAAI,EAAM,IAAK,GAAM,EAAE,GAAG,CAAC,CAOhD,IAAK,IAAM,KAAQ,EAAO,CACxB,GAAI,KAAK,OAAO,IAAI,EAAK,GAAG,CAAE,SAC9B,GAAI,CAAC,EAAK,WAAa,EAAK,aAAe,MAAQ,CAAC,KAAK,SAAS,IAAI,EAAK,GAAG,CAAE,CACzE,KAAK,KAAK,MAAM,WAAW,EAAK,GAAG,CAAC,MAAO,GAAQ,CACtD,KAAK,KAAK,QAAQ,MAAM,gDAAgD,EAAK,GAAG,GAAI,EAAI,EACxF,CACF,SAEF,IAAM,EAAW,KAAK,SAAS,IAAI,EAAK,GAAG,CAC3C,GAAK,EAcH,EAAS,QAAU,EAAK,QACxB,EAAS,KAAO,EAAK,KACrB,EAAS,OAAS,EAAK,OACvB,EAAS,eAAiB,EAAK,eAC/B,EAAS,cAAgB,EAAK,cAC9B,EAAS,WAAa,EAAK,gBAlB3B,GAAI,CAAE,KAAK,SAAS,IAAI,EAAK,MAAS,GAuB1C,IAAK,IAAM,KAAQ,KAAK,SAAS,MAAM,CACjC,CAAC,EAAS,IAAI,EAAK,GAAG,EAAI,CAAC,KAAK,OAAO,IAAI,EAAK,GAAG,EACrD,KAAK,SAAS,OAAO,EAAK,GAAG,CAMnC,MAAM,MAAsB,CACtB,CAAC,KAAK,SAAW,CAAC,KAAK,KAAK,UAAU,SAAS,GAEnD,KAAK,cAAc,CAEnB,AAEE,KAAK,UADL,cAAc,KAAK,OAAO,CACZ,MAQhB,KAAK,QAAU,GACf,MAAM,KAAK,KAAK,UAAU,SAAS,CACnC,MAAM,KAAK,UAAU,EAYvB,eAA8B,CACxB,KAAK,gBACT,KAAK,KAAK,QAAQ,MAAM,2DAA2D,CACnF,KAAK,cAAgB,gBAAkB,CAChC,KAAK,iBAAiB,EAC1B,IAA2B,CAC9B,KAAK,cAAc,OAAO,EAG5B,MAAc,iBAAiC,CAC7C,GAAI,CACe,MAAM,KAAK,KAAK,UAAU,YAAY,EACvC,KAAK,KAAK,UAAU,SAAS,GAC3C,KAAK,cAAc,CACnB,KAAK,QAAU,GACf,KAAK,OAAS,gBAAkB,CACzB,KAAK,MAAM,EACf,EAAiB,CACpB,KAAK,OAAO,OAAO,CACnB,KAAK,KAAK,QAAQ,MAAM,6DAA6D,OAEjF,GAKV,cAA6B,CAC3B,AAEE,KAAK,iBADL,cAAc,KAAK,cAAc,CACZ,MAazB,IAAI,EAA0C,CAC5C,GAAI,KAAK,SAAS,OAAO,EAAI,GAC3B,MAAU,MAAM,yEAAmF,CAGrG,IAAM,EAAK,OAAO,YAAY,CAAC,MAAM,EAAG,EAAE,CACpC,EAAM,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CACrC,EAAW,EAAU,EAAM,eAAgB,IAAI,KAAK,EAAM,IAAO,CAAC,CAClE,EAAa,KAAK,YAAY,EAAI,EAAU,EAAM,UAAU,CAE5D,EAAsB,CAC1B,KACA,KAAM,EAAM,KACZ,OAAQ,EAAM,OACd,eAAgB,EAAM,eACtB,QAAS,GACT,UAAW,EAAM,UACjB,OAAQ,EAAM,OACd,UAAW,EACX,aACA,cAAe,EAAM,eAAiB,IACtC,WAAY,EAAM,YAAc,EAEhC,GAAI,EAAM,WAAa,CAAE,WAAY,EAAM,WAAY,CAAG,EAAE,CAC7D,CAID,OAFA,KAAK,SAAS,IAAI,EAAK,CACvB,KAAK,QAAQ,EAAK,CACX,EAIT,OAAO,EAAqB,CAC1B,IAAM,EAAU,KAAK,SAAS,OAAO,EAAG,CAIxC,OAHI,GACG,KAAK,UAAU,EAAG,CAElB,EAST,OACE,EACA,EACS,CACT,IAAM,EAAO,KAAK,SAAS,IAAI,EAAG,CAClC,GAAI,CAAC,EAAM,MAAO,GAWlB,GATI,EAAM,OAAS,IAAA,KACjB,EAAK,KAAO,EAAM,MAEhB,EAAM,SAAW,IAAA,KACnB,EAAK,OAAS,EAAM,QAElB,EAAM,UAAY,IAAA,KACpB,EAAK,QAAU,EAAM,SAEnB,EAAM,iBAAmB,IAAA,GAAW,CACtC,EAAK,eAAiB,EAAM,eAC5B,IAAM,EAAM,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CACrC,EAAW,EAAU,EAAK,eAAgB,IAAI,KAAK,EAAM,IAAO,CAAC,CACvE,EAAK,WAAa,KAAK,YAAY,EAAK,GAAI,EAAU,EAAK,UAAU,CAIvE,OADA,KAAK,QAAQ,EAAK,CACX,GAIT,MAAwB,CACtB,OAAO,KAAK,SAAS,MAAM,CAO7B,mBAAiC,CAC/B,OAAO,IAAI,IAAI,KAAK,oBAAoB,MAAM,CAAC,CAOjD,aAAa,EAAgB,EAAqB,GAAkD,CAClG,IAAM,EAAS,EAAQ,EAAW,gBAAgB,CAC5C,EAAS,GAAG,EAAO,GACzB,GAAI,CACF,IAAM,EAAQ,EAAY,EAAO,CAC9B,OAAQ,GAAM,EAAE,WAAW,EAAO,EAAI,EAAE,SAAS,OAAO,CAAC,CACzD,MAAM,CACN,SAAS,CACN,EAAwD,EAAE,CAChE,IAAK,IAAI,EAAI,EAAG,EAAI,EAAM,QAAU,EAAQ,OAAS,EAAY,IAC/D,GAAI,CACF,IAAM,EAAU,EAAa,EAAQ,EAAQ,EAAM,GAAI,CAAE,QAAQ,CAAC,MAAM,CAElE,EAAW,EAAQ,QAAQ,IAAI,CAC/B,EAAY,EAAW,EAAI,EAAQ,MAAM,EAAG,EAAS,CAAG,EACxD,EAAS,EAAW,EAAI,EAAQ,MAAM,EAAW,EAAE,CAAG,GAC5D,EAAQ,KAAK,CAAE,YAAW,SAAQ,CAAC,MAC7B,EAIV,OAAO,OACD,CACN,MAAO,EAAE,EAYb,OAAO,EAAkB,CACvB,IAAM,EAAO,KAAK,SAAS,IAAI,EAAG,CAClC,GAAI,CAAC,EACH,MAAU,MAAM,kBAAkB,EAAG,aAAa,CAEpD,GAAI,CAAC,EAAK,QACR,MAAU,MAAM,kBAAkB,EAAG,eAAe,CAEtD,GAAI,KAAK,OAAO,IAAI,EAAK,GAAG,CAC1B,MAAU,MAAM,kBAAkB,EAAG,qBAAqB,CAE5D,KAAK,OAAO,IAAI,EAAK,GAAG,CACnB,KAAK,KAAK,EAAK,CAiBtB,OAAO,EAA8B,CACnC,IAAM,EAAO,KAAK,SAAS,IAAI,EAAO,CACtC,GAAI,CAAC,EAAM,MAAO,CAAE,GAAI,GAAO,OAAQ,iBAAkB,CAIzD,IAAI,EACE,EAAS,KAAK,oBAAoB,IAAI,EAAO,CACnD,GAAI,EACF,EAAQ,EAAO,UACV,CAIL,IAAM,EAAc,KAAK,KAAK,YAAY,MAAM,CAAC,OAC/C,GAAK,EAAE,SAAW,WAAa,EAAE,SAAW,cAC7C,CACG,EAAY,SAAW,IACzB,EAAQ,EAAY,GAAI,IAG5B,GAAI,CAAC,EAAO,MAAO,CAAE,GAAI,GAAO,OAAQ,cAAe,CAKvD,IAAM,EAAM,KAAK,KAAK,YAAY,IAAI,EAAM,CAgB5C,OAfK,EACD,EAAI,SAAW,QAAkB,CAAE,GAAI,GAAO,OAAQ,eAAgB,CACtE,EAAI,SAAW,UAAkB,CAAE,GAAI,GAAO,OAAQ,sBAAuB,CAC7E,EAAI,SAAW,SAAkB,CAAE,GAAI,GAAO,OAAQ,qBAAsB,CAC5E,EAAI,SAAW,WAAmB,CAAE,GAAI,GAAO,OAAQ,uBAAwB,EAInF,KAAK,gBAAgB,IAAI,EAAK,GAAG,CAKjC,KAAK,KAAK,YAAY,OAAO,EAAM,CAE5B,CAAE,GAAI,GAAM,EAfF,CAAE,GAAI,GAAO,OAAQ,gBAAiB,CAwBzD,MAAc,MAAsB,CAC9B,SAAK,eACT,MAAK,eAAiB,GAEtB,GAAI,CAcF,GATA,MAAM,KAAK,KAAK,UAAU,OAAO,CAS7B,CAAC,KAAK,KAAK,UAAU,SAAS,CAAE,CAClC,KAAK,QAAU,GACf,AAEE,KAAK,UADL,cAAc,KAAK,OAAO,CACZ,MAEhB,KAAK,eAAe,CACpB,OAQF,KAAK,YACD,KAAK,UAAY,IAA0B,GAC7C,MAAM,KAAK,iBAAiB,CAG9B,IAAM,EAAM,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CAO3C,IAAK,IAAM,KAAQ,KAAK,SAAS,MAAM,CACjC,EAAK,WAAa,EAAM,EAAK,WAAa,SAC5C,KAAK,SAAS,OAAO,EAAK,GAAG,CACxB,KAAK,UAAU,EAAK,GAAG,EAKhC,IAAM,EAAM,KAAK,SAAS,QAAQ,EAAI,CACtC,IAAK,IAAM,KAAQ,EAAK,CAYtB,GAXI,KAAK,OAAO,IAAI,EAAK,GAAG,EAWxB,CAAC,EAAK,YAAc,EAAK,gBAAkB,EAAK,mBAAoB,SAQxE,IAAM,EAAe,EAAK,WACpB,EAAW,EAAU,EAAK,eAAgB,IAAI,KAAK,EAAM,IAAO,CAAC,CACjE,EAAW,KAAK,YAAY,EAAK,GAAI,EAAU,EAAK,UAAU,CACpE,KAAK,SAAS,OAAO,EAAK,GAAI,CAAE,WAAY,EAAU,CAAC,CACvD,KAAK,QAAQ,EAAK,CAElB,KAAK,OAAO,IAAI,EAAK,GAAG,CACnB,KAAK,KAAK,EAAM,EAAa,SAE5B,CACR,KAAK,eAAiB,KA2B1B,eAAuB,EAAqB,EAAgC,CAC1E,IAAM,EAAa,EAAK,WACxB,GAAI,CAAC,EAAY,MAAO,GAExB,IAAM,EAAS,KAAK,KAAK,iBACzB,GAAI,CAAC,EAEH,OADA,KAAK,aAAa,EAAK,GAAI,6DAA6D,CACjF,GAGT,IAAM,EAAa,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CAI5C,EAAe,GAAG,EAAK,GAAG,GAAG,GAAgB,IAE/C,EAAY,GAChB,GAAI,CACF,EAAY,EAAO,EAAY,CAAE,WAAY,EAAK,GAAI,eAAc,aAAY,CAAC,OAC1E,EAAK,CAIZ,OAFA,KAAK,KAAK,QAAQ,MAAM,gDAAgD,EAAK,GAAG,GAAI,EAAI,CACxF,KAAK,aAAa,EAAK,GAAI,0BAA0B,aAAe,MAAQ,EAAI,QAAU,OAAO,EAAI,GAAG,CACjG,GAST,OANA,KAAK,aACH,EAAK,GACL,EACI,8BAA8B,EAAW,SAAS,GAAG,EAAW,UAAU,eAAe,EAAa,GACtG,kCAAkC,EAAW,SAAS,GAAG,EAAW,UAAU,eACnF,CACM,EAGT,MAAc,KAAK,EAAqB,EAAsC,CAC5E,IAAI,EAAgC,SAChC,EAAmB,GACnB,EACE,EAAY,EAAE,KAAK,eAEzB,GAAI,CAMF,GAAI,EAAK,WAAY,CACnB,EAAU,KAAK,eAAe,EAAM,EAAa,CAAG,UAAY,SAChE,OAGF,IAAM,EAA0B,EAAK,SAAW,QAAU,OAAS,OAC7D,EAAY,KAAK,KAAK,cAAc,CAE1C,IAAK,IAAI,EAAU,EAAG,GAAW,EAAK,WAAY,IAAW,CAM3D,GAAI,KAAK,gBAAgB,IAAI,EAAK,GAAG,CAAE,CACrC,KAAK,aAAa,EAAK,GAAI,6BAA6B,EAAU,EAAE,GAAG,CACvE,EAAmB,GACnB,MAGF,IAAM,EAAM,KAAK,KAAK,SAAS,CAC7B,MAAO,EAAK,KACZ,OAAQ,EAAK,OACb,YACA,SACD,CAAC,CAGF,KAAK,oBAAoB,IAAI,EAAK,GAAI,CAAE,MAAO,EAAI,GAAI,YAAW,UAAS,CAAC,CAE5E,IAAM,EAAS,MAAM,KAAK,kBAAkB,EAAI,GAAI,EAAK,cAAc,CAEvE,GAAI,IAAW,UAAW,CACxB,KAAK,SAAS,OAAO,EAAK,GAAI,CAC5B,YAAa,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CAC5C,eAAgB,GAChB,cAAe,IAAA,GAChB,CAAC,CACF,KAAK,QAAQ,EAAK,CAIlB,IAAM,EAAc,KAAK,KAAK,YAAY,IAAI,EAAI,GAAG,CAC/C,EAAe,GAAa,SAAW,SAAW,CAAC,CAAC,EAAY,MAAM,MAAM,CAClF,KAAK,aACH,EAAK,GACL,EACI,sBAAsB,EAAU,EAAE,6BAA6B,EAAI,KACnE,sBAAsB,EAAU,EAAE,GACvC,CACD,EAAU,UACV,OAKF,GAAI,IAAW,WAAY,CACzB,KAAK,aAAa,EAAK,GAAI,6BAA6B,EAAU,EAAE,GAAG,CACvE,EAAmB,GACnB,MASF,GANA,EAAY,IAAW,UAAY,YAAc,aAM7C,EAAU,EAAK,WAAY,CAC7B,IAAM,EAAY,KAAK,KAAK,gBAAkB,EAE9C,MADc,KAAK,KAAK,QAAW,GAAe,IAAI,QAAe,GAAY,WAAW,EAAS,EAAG,CAAC,GAC7F,EAAU,EAMrB,GACH,KAAK,aAAa,EAAK,GAAI,gBAAgB,EAAK,WAAa,EAAE,aAAa,OAEvE,EAAK,CACZ,EAAY,aAAe,MAAQ,EAAI,QAAU,OAAO,EAAI,CAC5D,KAAK,KAAK,QAAQ,MAAM,oCAAoC,EAAK,GAAG,GAAI,EAAI,CAC5E,KAAK,aAAa,EAAK,GAAI,UAAU,IAAY,QACzC,CACR,KAAK,OAAO,OAAO,EAAK,GAAG,CAC3B,KAAK,oBAAoB,OAAO,EAAK,GAAG,CAMxC,IAAM,EAAY,KAAK,gBAAgB,IAAI,EAAK,GAAG,CACnD,KAAK,gBAAgB,OAAO,EAAK,GAAG,CAE/B,EAAK,YACJ,IAAY,WACd,KAAK,SAAS,OAAO,EAAK,GAAG,CAC7B,MAAM,KAAK,UAAU,EAAK,GAAG,EACpB,GAIT,KAAK,SAAS,OAAO,EAAK,GAAI,CAC5B,kBAAmB,GACnB,cAAe,IAAA,GAChB,CAAC,CACF,KAAK,QAAQ,EAAK,EACT,IAUT,KAAK,SAAS,OAAO,EAAK,GAAI,CAAE,eAAgB,GAAM,cAAe,EAAW,CAAC,CACjF,KAAK,QAAQ,EAAK,IAuB1B,kBAA0B,EAAe,EAA+E,CACtH,OAAO,IAAI,QAAS,GAAY,CAC9B,IAAI,EAAU,GACV,EACA,EACA,EAQE,GADY,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,EAChB,EAAgB,EAE3C,MAAgB,CAChB,GAAS,aAAa,EAAQ,CAClC,KAAa,CACb,KAAe,EAGX,EAAU,GAA0D,CACpE,IACJ,EAAU,GACV,GAAS,CACT,EAAQ,EAAO,GAGX,EAAc,GAAe,CAC7B,GAAS,aAAa,EAAQ,CAClC,EAAU,eAAiB,CACzB,KAAK,KAAK,YAAY,OAAO,EAAM,CACnC,EAAO,SAAS,EACf,EAAG,EAIR,EAAc,KAAK,KAAK,YAAY,SAAU,GAAQ,CAChD,KAAI,KAAO,EAEf,IAAI,EAAI,SAAW,QAEjB,EAAO,UAAU,SACR,EAAI,SAAW,cAAe,CAGvC,IAAM,EAAM,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CACrC,EAAY,KAAK,IAAI,EAAe,EAAe,EAAI,CAC7D,GAAI,GAAa,EAAG,CAClB,KAAK,KAAK,YAAY,OAAO,EAAM,CACnC,EAAO,SAAS,CAChB,OAEF,EAAW,EAAU,IAEvB,CAGF,EAAY,KAAK,KAAK,YAAY,OAAQ,GAAQ,CAC5C,EAAI,KAAO,IAEX,EAAI,SAAW,SACjB,EAAO,SAAS,CACP,EAAI,SAAW,WAGxB,EAAO,WAAW,CACT,EAAI,SAAW,WACxB,EAAO,UAAU,GAEnB,CAIF,EAAW,EAAc,EACzB,CAYJ,YAAoB,EAAgB,EAAgB,EAA4B,CAC9E,IAAI,EAAO,EACX,IAAK,IAAI,EAAI,EAAG,EAAI,EAAO,OAAQ,IACjC,GAAS,GAAQ,GAAK,EAAQ,EAAO,WAAW,EAAE,CAClD,GAAQ,EAGV,IAAM,EAAS,KAAK,IAAI,EAAK,EADd,EAAY,KAAmB,KAE9C,OAAO,EAAS,SAAS,CAAG,EAU9B,QAAgB,EAA2B,CACpC,KAAK,KAAK,MAAM,SAAS,EAAK,CAAC,MAAO,GAAQ,CACjD,KAAK,KAAK,QAAQ,MAAM,uCAAuC,EAAK,GAAG,GAAI,EAAI,EAC/E,CAIJ,MAAc,UAAU,EAA8B,CACpD,GAAI,CACF,OAAO,MAAM,KAAK,KAAK,MAAM,WAAW,EAAG,OACpC,EAAK,CAEZ,OADA,KAAK,KAAK,QAAQ,MAAM,yCAAyC,EAAG,GAAI,EAAI,CACrE,IAKX,MAAc,UAA0B,CACtC,GAAI,CACF,IAAK,IAAM,KAAQ,KAAK,SAAS,MAAM,CACrC,MAAM,KAAK,KAAK,MAAM,SAAS,EAAK,OAE/B,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,yCAA0C,EAAI,EAY1E,aAAqB,EAAgB,EAAsB,CAGzD,GAAI,GAAqB,CAAE,OAC3B,IAAM,EAAS,EAAQ,EAAW,gBAAgB,CAC5C,EAAU,EAAQ,EAAQ,GAAG,EAAO,GAAG,KAAK,KAAK,CAAC,MAAM,CAC9D,GAAI,CACF,EAAU,EAAQ,CAAE,UAAW,GAAM,CAAC,CACtC,EAAc,EAAS,GAAG,IAAI,MAAM,CAAC,aAAa,CAAC,GAAG,EAAO,IAAK,QAAQ,CAC1E,KAAK,cAAc,EAAQ,EAAO,MAC5B,GAMV,cAAsB,EAAgB,EAAsB,CAC1D,GAAI,CACF,IAAM,EAAS,GAAG,EAAO,GACnB,EAAQ,EAAY,EAAO,CAC9B,OAAQ,GAAM,EAAE,WAAW,EAAO,EAAI,EAAE,SAAS,OAAO,CAAC,CACzD,MAAM,CACH,EAAS,EAAM,OAAS,GAC9B,GAAI,GAAU,EAAG,OACjB,IAAK,IAAM,KAAK,EAAM,MAAM,EAAG,EAAO,CACpC,GAAI,CACF,EAAW,EAAQ,EAAQ,EAAE,CAAC,MACxB,QAIJ,KC/6BZ,SAAgB,EACd,EACoB,CACpB,MAAO,CACL,OAAS,GAAU,CACjB,IAAM,EAAY,GAAc,CAChC,GAAI,CAAC,EACH,MAAU,MAAM,uDAAuD,CAEzE,IAAM,EAAO,EAAU,IAAI,CACzB,KAAM,EAAM,MAAQ,WACpB,OAAQ,EAAM,OACd,eAAgB,EAAM,KACtB,UAAW,EAAM,UACjB,OAAQ,QACR,cAAe,EAAM,cACtB,CAAC,CACF,MAAO,CAAE,OAAQ,EAAK,GAAI,WAAY,EAAK,WAAY,KAAM,EAAK,KAAM,EAE1E,UACG,GAAc,EAAE,MAAM,EAAI,EAAE,EAAE,IAAK,IAAO,CACzC,GAAI,EAAE,GACN,KAAM,EAAE,KACR,eAAgB,EAAE,eAClB,QAAS,EAAE,QACX,UAAW,EAAE,UACb,WAAY,EAAE,WACd,YAAa,EAAE,YACf,eAAgB,EAAE,eAClB,cAAe,EAAE,cACjB,kBAAmB,EAAE,kBACtB,EAAE,CACL,OAAS,GAAW,GAAc,EAAE,OAAO,EAAO,EAAI,GACtD,IAAM,GAAW,CACf,GAAc,EAAE,OAAO,EAAO,EAEhC,OAAS,GACA,GAAc,EAAE,OAAO,EAAO,EAAI,CAAE,GAAI,GAAO,OAAQ,uCAAwC,CAEzG"}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@x-otto/schedule",
|
|
3
|
+
"version": "0.0.1-alpha.0",
|
|
4
|
+
"files": [
|
|
5
|
+
"dist"
|
|
6
|
+
],
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@x-otto/env": "0.1.0-alpha.1",
|
|
18
|
+
"@x-otto/persistence": "0.0.1-alpha.0",
|
|
19
|
+
"@x-otto/runtime": "0.0.1-alpha.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "6.0.2",
|
|
23
|
+
"vitest": "^3.2.4"
|
|
24
|
+
},
|
|
25
|
+
"private": false,
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"registry": "https://registry.npmjs.org",
|
|
29
|
+
"tag": "alpha"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsdown",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"clean": "rm -rf dist"
|
|
36
|
+
}
|
|
37
|
+
}
|