dsh-plugin-t-expert 0.3.90 → 0.3.92

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/lib/schedule.js DELETED
@@ -1,509 +0,0 @@
1
- // @ts-check
2
- /**
3
- * T专家 定时事项引擎:按 cron 表达式把提示词交给一个**全新的** agent 会话执行。
4
- *
5
- * 为什么自建而不是直接依赖 `@weibaohui/dsh-tasks`:用户明确要求两个插件可以同时安装、
6
- * 同时可用。所以这里与它**零共享**——
7
- * · 数据不落它的 `dsh_tasks` 存储域,而是 T专家 自己的数据目录(`<root 同级>/schedule.json`);
8
- * · 服务名 `tTeamSchedule`、错误码前缀 `tTeam/schedule-*` 全部独立,两个插件的任务互不可见、互不干扰。
9
- * 提交给会话的提示词用 `source.kind: "user"`(聊天右侧才渲染用户气泡;`plugin` 源会被收成 inject)。
10
- *
11
- * 三件事在这里闭环:
12
- * 1. 持久化:一个 JSON 文件,原子写(与名册数据同风格);
13
- * 2. 调度:每个启用项一个 croner job,宿主启动时全量重建,卸载时全部 stop;
14
- * 3. 执行:新建 agent 会话 → 挂部署默认 preset(不挂就没有工具)→ 提交提示词。
15
- *
16
- * 开关语义(用户口径):`setArmed(false)` = 定时事项功能整体停用——**停止全部定时触发**
17
- * 且拒绝手动执行;任务与执行记录完整保留,重新 armed 后自动恢复调度。
18
- */
19
- import { randomUUID } from "node:crypto";
20
- import { readFileSync } from "node:fs";
21
- import { Cron } from "croner";
22
- import { writeFileAtomic } from "./catalog.js";
23
-
24
- /** 定时事项服务名(remote 侧的服务访问器用它)。 */
25
- export const SCHEDULE_SERVICE = "tTeamSchedule";
26
- /** 数据文件名(落在 T专家 数据根下)。 */
27
- export const SCHEDULE_FILE_NAME = "schedule.json";
28
- /** 每条事项保留的执行记录条数(最旧的先丢)。 */
29
- export const MAX_RUNS = 20;
30
- /** 事项条数上限(防止一次误操作把设置文件写成天文数字)。 */
31
- export const MAX_ITEMS = 200;
32
- /** 标题 / 提示词的字符上限。 */
33
- export const MAX_TITLE_CHARS = 200;
34
- export const MAX_PROMPT_CHARS = 20000;
35
- /** 写进会话消息来源的插件名(与 dsh-tasks 区分开,两个插件的任务是两回事)。 */
36
- export const PLUGIN_ID = "dsh-plugin-t-expert";
37
-
38
- /**
39
- * cron 表达式校验:构造一个 `paused` 的探测 job(只解析、不真的挂定时器),
40
- * 随即 stop 掉,保证探测本身不会让事件循环多活一秒。
41
- * @param {unknown} expression - 待校验的表达式。
42
- * @param {(key: string, params?: Record<string, unknown>) => string} t - host 侧文案函数。
43
- */
44
- export function validateCron(expression, t) {
45
- const text = typeof expression === "string" ? expression.trim() : "";
46
- if (text === "") throw new Error(t("schedule.cronEmpty"));
47
- try {
48
- const probe = new Cron(text, { paused: true }, () => undefined);
49
- probe.stop();
50
- } catch (error) {
51
- const detail = error instanceof Error ? error.message : String(error);
52
- throw new Error(t("schedule.invalidCron", { detail }));
53
- }
54
- return text;
55
- }
56
-
57
- /** 该表达式接下来的若干次触发时间(ISO 串),供面板在保存前预览。 */
58
- export function previewRuns(expression, t, count = 5) {
59
- const text = validateCron(expression, t);
60
- const probe = new Cron(text, { paused: true });
61
- try {
62
- return probe.nextRuns(count).map((date) => date.toISOString());
63
- } finally {
64
- probe.stop();
65
- }
66
- }
67
-
68
- /** 短本地时间戳(`MM-DD HH:mm`):同一个事项的多次运行在侧栏里要能区分开。 */
69
- export function runStamp(iso) {
70
- const date = new Date(iso);
71
- const pad = (value) => String(value).padStart(2, "0");
72
- return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
73
- }
74
-
75
- /** 追加一条执行记录(超过上限丢最旧的),并刷新最近一次执行摘要字段。 */
76
- export function withRun(item, entry) {
77
- const runs = [...(Array.isArray(item.runs) ? item.runs : []), entry].slice(-MAX_RUNS);
78
- const next = { ...item, runs, lastRunAt: entry.at };
79
- // 成功时**删掉**失败字段而不是置 undefined:线上(remote 严格 schema)与 JSON 都不需要
80
- // 一个"值为 undefined 的键",留着只会让序列化前后形状不一致。
81
- if (entry.ok === true) delete next.lastRunError;
82
- else next.lastRunError = entry.error || "failed";
83
- return next;
84
- }
85
-
86
- /** 校验一条事项的入参(面板提交的形状);返回规范化后的字段。 */
87
- export function normalizeInput(input, t) {
88
- if (input === null || typeof input !== "object") throw new Error(t("schedule.inputInvalid"));
89
- const raw = /** @type {Record<string, unknown>} */ (input);
90
- const title = typeof raw.title === "string" ? raw.title.trim() : "";
91
- const prompt = typeof raw.prompt === "string" ? raw.prompt : "";
92
- if (title === "") throw new Error(t("schedule.titleEmpty"));
93
- if (title.length > MAX_TITLE_CHARS) throw new Error(t("schedule.titleTooLong", { limit: MAX_TITLE_CHARS }));
94
- if (prompt.trim() === "") throw new Error(t("schedule.promptEmpty"));
95
- if (prompt.length > MAX_PROMPT_CHARS) throw new Error(t("schedule.promptTooLong", { limit: MAX_PROMPT_CHARS }));
96
- const cron = validateCron(raw.cron, t);
97
- const enabled = raw.enabled !== false;
98
- const workspaceId = typeof raw.workspaceId === "string" && raw.workspaceId !== "" ? raw.workspaceId : undefined;
99
- const workspacePath = typeof raw.workspacePath === "string" && raw.workspacePath !== "" ? raw.workspacePath : undefined;
100
- return {
101
- title, prompt, cron, enabled,
102
- ...(workspaceId === undefined ? {} : { workspaceId }),
103
- ...(workspacePath === undefined ? {} : { workspacePath }),
104
- };
105
- }
106
-
107
- /**
108
- * 创建定时事项引擎。
109
- *
110
- * @param {object} options
111
- * @param {any} options.ctx - 插件上下文(用 `ctx.get(...)` 懒查可选服务)。
112
- * @param {string} options.file - 数据文件绝对路径。
113
- * @param {any} options.logger - cordis logger(可缺省)。
114
- * @param {(key: string, params?: Record<string, unknown>) => string} options.t - host 文案函数。
115
- * @param {string} options.defaultCwd - 不绑定工作区时的会话工作目录。
116
- * @param {boolean} [options.armed] - 初始是否启用调度(= 界面开关的当前值)。
117
- * @param {(path: string, title?: string) => Promise<{ id: string, title: string, path: string }>} [options.ensureWorkspace]
118
- * - 准备定时任务的工作区(默认「定时任务」工作区,或面板里填的自定义目录)。缺省则面板该项不可用。
119
- */
120
- export function createScheduleEngine(options) {
121
- const { ctx, file, logger, t, defaultCwd } = options;
122
- /** 工作区准备(默认「定时任务」工作区 / 自定义目录),由 host 注入。 */
123
- const ensureWorkspace = typeof options.ensureWorkspace === "function" ? options.ensureWorkspace : undefined;
124
- /** @type {Map<string, any>} id → 事项 */
125
- const items = new Map();
126
- /** @type {Map<string, any>} id → croner job */
127
- const jobs = new Map();
128
- /** 正在执行中的事项 id:同一事项不并发跑两次。 */
129
- const running = new Set();
130
- let armed = options.armed !== false;
131
- /** 写盘串行链:并发变更不能交错写同一个文件。 */
132
- let writeChain = Promise.resolve();
133
-
134
- /** 读盘(同步,apply 期就要拿到数据)。读不动时**出声**并按空列表继续。 */
135
- function load() {
136
- let text;
137
- try {
138
- text = readFileSync(file, "utf8");
139
- } catch (error) {
140
- const code = /** @type {any} */ (error)?.code;
141
- if (code !== "ENOENT") {
142
- logger?.warn?.(`[t-team] 定时事项数据文件读取失败(按空列表继续):${file}:${error instanceof Error ? error.message : String(error)}`);
143
- }
144
- return;
145
- }
146
- try {
147
- const parsed = JSON.parse(text);
148
- const list = Array.isArray(parsed?.items) ? parsed.items : [];
149
- for (const entry of list) {
150
- if (entry === null || typeof entry !== "object") continue;
151
- if (typeof entry.id !== "string" || typeof entry.cron !== "string") continue;
152
- items.set(entry.id, {
153
- id: entry.id,
154
- title: typeof entry.title === "string" ? entry.title : "",
155
- prompt: typeof entry.prompt === "string" ? entry.prompt : "",
156
- cron: entry.cron,
157
- enabled: entry.enabled !== false,
158
- ...(typeof entry.workspaceId === "string" && entry.workspaceId !== "" ? { workspaceId: entry.workspaceId } : {}),
159
- // 绑定时同步记下当时的工作区目录:工作区被删后用它「重建」回到原路径,避免要用户重填。
160
- ...(typeof entry.workspacePath === "string" && entry.workspacePath !== "" ? { workspacePath: entry.workspacePath } : {}),
161
- createdAt: typeof entry.createdAt === "string" ? entry.createdAt : new Date().toISOString(),
162
- updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt : new Date().toISOString(),
163
- ...(typeof entry.lastRunAt === "string" ? { lastRunAt: entry.lastRunAt } : {}),
164
- ...(typeof entry.lastRunError === "string" ? { lastRunError: entry.lastRunError } : {}),
165
- runs: Array.isArray(entry.runs) ? entry.runs.slice(-MAX_RUNS) : [],
166
- });
167
- }
168
- } catch (error) {
169
- logger?.warn?.(`[t-team] 定时事项数据文件解析失败(按空列表继续):${file}:${error instanceof Error ? error.message : String(error)}`);
170
- }
171
- }
172
-
173
- /** 落盘:串行 + 原子写(临时文件 + rename),失败只告警不打断内存态。 */
174
- function persist() {
175
- const payload = `${JSON.stringify({ version: 1, items: [...items.values()] }, null, 2)}\n`;
176
- writeChain = writeChain
177
- .then(() => writeFileAtomic(file, payload))
178
- .catch((error) => {
179
- logger?.warn?.(`[t-team] 定时事项写盘失败:${file}:${error instanceof Error ? error.message : String(error)}`);
180
- });
181
- return writeChain;
182
- }
183
-
184
- /** 停掉一个 job(幂等)。 */
185
- function stopJob(id) {
186
- const job = jobs.get(id);
187
- if (job === undefined) return;
188
- try {
189
- job.stop();
190
- } catch {
191
- /* 已经停掉的 job 再 stop 无所谓 */
192
- }
193
- jobs.delete(id);
194
- }
195
-
196
- /** 按当前 armed 状态与 enabled 标记给一条事项挂/摘定时器。 */
197
- function scheduleOne(item) {
198
- stopJob(item.id);
199
- if (!armed || item.enabled !== true) return;
200
- try {
201
- // `unref: true`:不让定时器把宿主进程的事件循环钉住(进程想退出就能退出)。
202
- // 它是 croner 的**构造选项**,不是 job 上的方法(写成 job.unref() 会静默无效)。
203
- const job = new Cron(item.cron, { unref: true }, () => {
204
- void runScheduled(item.id);
205
- });
206
- jobs.set(item.id, job);
207
- } catch (error) {
208
- // 表达式在写入时已校验过,这里失败只可能是运行期异常(例如 croner 版本差异):
209
- // 记一条 warn 并保持该事项不调度,别让一个坏 job 拖垮整张表。
210
- logger?.warn?.(`[t-team] 定时事项「${item.title}」调度失败(该事项不会自动触发):${error instanceof Error ? error.message : String(error)}`);
211
- }
212
- }
213
-
214
- /** 全量重建调度表(启动、开关切换、批量变更后调用)。 */
215
- function rescheduleAll() {
216
- for (const id of [...jobs.keys()]) stopJob(id);
217
- if (!armed) return;
218
- for (const item of items.values()) scheduleOne(item);
219
- }
220
-
221
- /** 定时触发的入口:跑完更新记录并落盘。 */
222
- async function runScheduled(id) {
223
- const current = items.get(id);
224
- if (current === undefined || !armed) return;
225
- if (running.has(id)) return; // 上一轮还没跑完,这一轮跳过(不排队、不叠加)
226
- const updated = await execute(current);
227
- items.set(id, updated);
228
- await persist();
229
- }
230
-
231
- /**
232
- * 执行一条事项:新建会话 + 提交提示词,把结果记进 runs。
233
- * 失败**不抛**(定时路径没人接异常),而是把原因写进 `lastRunError`。
234
- */
235
- async function execute(item) {
236
- const startedAt = new Date().toISOString();
237
- running.add(item.id);
238
- try {
239
- const agents = ctx.get("agents");
240
- if (agents === undefined || typeof agents.create !== "function") {
241
- throw new Error(t("schedule.agentsMissing"));
242
- }
243
- // 工作区解析:绑过 → 查 registry;查不到 → 按 task 记的路径自动 mkdir + create。
244
- // 这是用户口径"工作区目录没了就自动新建"的实现点。recover 已经在内部把 workspaceId 写回 items。
245
- let workspace = item.workspaceId === undefined
246
- ? undefined
247
- : ctx.get("workspaceRegistry")?.get?.(item.workspaceId);
248
- if (item.workspaceId !== undefined && workspace === undefined) {
249
- const recovered = await tryAutoRecoverWorkspace(item);
250
- if (recovered === null || recovered === undefined) {
251
- // 实在没路径可恢复(老数据迁移后没记路径):给个明确报错让面板呈现。
252
- const hint = typeof item.workspacePath === "string" && item.workspacePath !== ""
253
- ? t("schedule.workspaceMissingWithPath", { id: item.workspaceId, path: item.workspacePath })
254
- : t("schedule.workspaceMissing", { id: item.workspaceId });
255
- throw new Error(hint);
256
- }
257
- workspace = recovered;
258
- }
259
- const selection = ctx.get("agentDefaultModel")?.currentSelection?.() ?? {};
260
- const sessionId = `session-${randomUUID()}`;
261
- const handle = await agents.create({
262
- sessionId,
263
- meta: { cwd: workspace ? workspace.path : defaultCwd },
264
- ...(typeof selection.provider === "string" && typeof selection.model === "string"
265
- ? { agentOptions: { provider: selection.provider, model: selection.model } }
266
- : {}),
267
- // 没有 preset 挂载的新会话**没有任何工具**(模型只能空口回答),所以这里按 web 网关
268
- // 建会话的同款做法解析并挂上部署的默认 preset。
269
- setup: async (agentCtx) => {
270
- const presets = ctx.get("agentPresets");
271
- if (!presets || typeof presets.resolve !== "function" || typeof presets.mount !== "function") return;
272
- const resolved = await presets.resolve(undefined);
273
- if (resolved && resolved.id) await presets.mount(agentCtx, resolved.id);
274
- },
275
- });
276
- if (workspace !== undefined && typeof workspace.attachSession === "function") {
277
- await workspace.attachSession(sessionId);
278
- }
279
- // 标题钉成「事项标题 · 时间」:同一事项的多次运行在侧栏里能一一区分。
280
- // 以 `user` 来源直接写日志,避免自动标题生成把它覆盖掉(失败不致命)。
281
- try {
282
- handle.agent.session.append("session/title", {
283
- title: `${item.title} · ${runStamp(startedAt)}`,
284
- messageSeqs: [],
285
- source: { kind: "user" },
286
- });
287
- } catch {
288
- /* 标题没钉上也照样执行 */
289
- }
290
- // 提示词**原样提交**:定时任务开的新会话与手动会话挂同一份 system prompt,里面已经写着
291
- // 「看到 `@专家名` 就 summon_t_expert FIRST」。这里再做一次改写只会让同一个任务在定时任务
292
- // 里显示成一长段模板、在手动对话里却只是 `@名字`,两条入口观感不一致(用户实测反馈)。
293
- const promptText = item.prompt;
294
- // 必须用 source.kind: "user":DSH 聊天把非 user 的 user/message 收成左侧
295
- // inject/context(plugin 源只显示插件名,正文不进右侧气泡)。定时任务的指令
296
- // 本身就是用户写的提示词,应当像手打那条一样出现在对话右侧。
297
- handle.agent.followup({
298
- id: randomUUID(),
299
- role: "user",
300
- content: [{ type: "text", text: promptText }],
301
- source: { kind: "user" },
302
- });
303
- return withRun(item, { at: startedAt, ok: true, sessionId });
304
- } catch (error) {
305
- return withRun(item, {
306
- at: startedAt,
307
- ok: false,
308
- error: error instanceof Error ? error.message : String(error),
309
- });
310
- } finally {
311
- running.delete(item.id);
312
- }
313
- }
314
-
315
- load();
316
- // 读盘之后必须**立刻**按当前开关挂上定时器:少了这一句,插件每次启动都是"任务在、但永远不触发",
317
- // 直到用户碰一下开关或增删一条才恢复 —— 那种"看着正常、其实没在跑"的失效最难发现,所以这里
318
- // 也有对应自检(verify [20c] 的「重启后 enabled 的任务自动进入调度」)。
319
- rescheduleAll();
320
-
321
- /** 面板要的全部数据:事项 + 开关状态 + 可选工作区(一次调用拿全,省一次往返)。 */
322
- function snapshot() {
323
- const registry = ctx.get("workspaceRegistry") ?? ctx.get("workspace");
324
- const list = typeof registry?.list === "function" ? registry.list() : [];
325
- return {
326
- items: [...items.values()],
327
- armed,
328
- file,
329
- running: [...running],
330
- capabilities: {
331
- agents: typeof ctx.get("agents")?.create === "function",
332
- presets: typeof ctx.get("agentPresets")?.mount === "function",
333
- },
334
- workspaces: list.map((workspace) => ({
335
- id: String(workspace.id ?? ""),
336
- title: String(workspace.title ?? workspace.path ?? ""),
337
- // 目录:面板把它显示在表单里,用户能看清这条任务落在哪个文件夹。
338
- path: typeof workspace.path === "string" ? workspace.path : "",
339
- })).filter((workspace) => workspace.id !== ""),
340
- };
341
- }
342
-
343
- /** 新建一条事项。 */
344
- async function create(input) {
345
- if (items.size >= MAX_ITEMS) throw new Error(t("schedule.tooManyItems", { limit: MAX_ITEMS }));
346
- const fields = normalizeInput(input, t);
347
- const now = new Date().toISOString();
348
- const item = {
349
- id: `item-${randomUUID()}`,
350
- ...fields,
351
- createdAt: now,
352
- updatedAt: now,
353
- runs: [],
354
- };
355
- items.set(item.id, item);
356
- scheduleOne(item);
357
- await persist();
358
- return item;
359
- }
360
-
361
- /** 改一条事项(只覆盖传进来的字段)。 */
362
- async function update(id, patch) {
363
- const current = items.get(id);
364
- if (current === undefined) throw new Error(t("schedule.itemMissing", { id }));
365
- const source = patch === null || typeof patch !== "object" ? {} : patch;
366
- const merged = {
367
- title: source.title === undefined ? current.title : source.title,
368
- prompt: source.prompt === undefined ? current.prompt : source.prompt,
369
- cron: source.cron === undefined ? current.cron : source.cron,
370
- enabled: source.enabled === undefined ? current.enabled : source.enabled,
371
- workspaceId: source.workspaceId === undefined ? current.workspaceId : source.workspaceId,
372
- // workspacePath 与 workspaceId 一同从表单过来;老数据没有也不补(创建时记的是绑定那一刻的目录)。
373
- workspacePath: source.workspacePath === undefined ? current.workspacePath : source.workspacePath,
374
- };
375
- const fields = normalizeInput(merged, t);
376
- const next = { ...current, ...fields, updatedAt: new Date().toISOString() };
377
- // 展开旧记录会把旧的 workspaceId 一起带过来:用户把绑定改回「不绑定」时
378
- // fields 里没有这个键,必须显式删掉,否则界面上看着解绑了、执行时还挂在旧工作区。
379
- if (fields.workspaceId === undefined) delete next.workspaceId;
380
- if (fields.workspacePath === undefined) delete next.workspacePath;
381
- items.set(id, next);
382
- scheduleOne(next);
383
- await persist();
384
- return next;
385
- }
386
-
387
- /** 删一条事项(顺带摘掉它的定时器)。 */
388
- async function remove(id) {
389
- if (!items.has(id)) throw new Error(t("schedule.itemMissing", { id }));
390
- stopJob(id);
391
- items.delete(id);
392
- await persist();
393
- return { removed: true };
394
- }
395
-
396
- /** 立即执行一条事项(面板上的「立即执行」)。 */
397
- async function runNow(id) {
398
- if (!armed) throw new Error(t("schedule.disabled"));
399
- const current = items.get(id);
400
- if (current === undefined) throw new Error(t("schedule.itemMissing", { id }));
401
- if (running.has(id)) return current; // 正在跑就原样返回,面板保持「执行中…」
402
- const updated = await execute(current);
403
- items.set(id, updated);
404
- await persist();
405
- return updated;
406
- }
407
-
408
- /**
409
- * 开关联动:关掉 = 停止全部定时触发并拒绝手动执行;打开 = 按 stored enabled 全量重建。
410
- * 只改运行态,不写数据文件(开关本身是界面偏好,存在宿主设置里)。
411
- */
412
- function setArmed(next) {
413
- const value = next !== false;
414
- if (value === armed) return { armed };
415
- armed = value;
416
- rescheduleAll();
417
- return { armed };
418
- }
419
-
420
- /**
421
- * 自动恢复:执行路径发现工作区不见了,但任务里记着目录 → 顺着把工作区建回来。
422
- * 这是用户口径"工作区目录没了就自动新建"的实现点:
423
- * - execute 拿到 registry.get 拿不到的 workspace 时,先看 item.workspacePath:
424
- * · 路径已记录:按这个路径 ensureWorkspace(**会顺带 mkdir 目录**,宿主 createCanonical 内部对同路径幂等复用)。
425
- * · 没路径:只能抛错让用户去左侧手动建一次工作区(之后 schedule.json 里的 workspaceId 会被自动建回。
426
- * 老数据迁移后第一次失败就属于这种情况)。
427
- * - 恢复成功后顺便把 workspaceId 刷回去(**mutate 传入的 item + items.set 同步**:
428
- * execute 自身就是单飞序列,传入的 item 是 Map 里的那条引用;mutate 后 withRun 能
429
- * 拿到最新字段,items.set + persist 保证落盘,调度下次直接命中 registry)。
430
- *
431
- * @returns 恢复出来的 workspace(已经是 registry 里的实体);返回 null 表示不可恢复。
432
- */
433
- async function tryAutoRecoverWorkspace(item) {
434
- const finalPath = resolveRebuildPath(item, undefined);
435
- if (finalPath === "") return null;
436
- try {
437
- const workspace = await ensureOrFail(finalPath);
438
- // 1) mutate 传入的 item:execute 后面的 withRun(item, ...) 直接看到新 workspaceId/Path。
439
- item.workspaceId = workspace.id;
440
- item.workspacePath = workspace.path;
441
- item.updatedAt = new Date().toISOString();
442
- delete item.lastRunError;
443
- // 2) items.set 触发 Map 引用更新(与 mutate 实际指向同一对象,但保持语义一致);
444
- // persist 把这条新字段落到磁盘。
445
- items.set(item.id, item);
446
- await persist();
447
- logger?.info?.(`[t-team] 定时事项「${item.title}」绑定的工作区不存在,已按原路径 ${finalPath} 自动重建(id=${workspace.id})。`);
448
- return workspace;
449
- } catch (error) {
450
- logger?.warn?.(`[t-team] 定时事项「${item.title}」自动恢复工作区失败:${error instanceof Error ? error.message : String(error)}`);
451
- return null;
452
- }
453
- }
454
-
455
- /** 抽取"用哪条 path"的判定:外部传入优先,否则用 task 记的。两边都没有就返回空串。 */
456
- function resolveRebuildPath(item, override) {
457
- if (typeof override === "string" && override.trim() !== "") return override.trim();
458
- if (typeof item?.workspacePath === "string" && item.workspacePath.trim() !== "") return item.workspacePath.trim();
459
- return "";
460
- }
461
-
462
- /**
463
- * 调宿主的工作区准备能力拿实体。宿主若未注入(理论上不会发生,scheduleEngine 必然随插件注入)
464
- * 就在这里抛错,调用方自己决定是抛还是降级。
465
- */
466
- function ensureOrFail(path) {
467
- if (ensureWorkspace === undefined) {
468
- return Promise.reject(new Error("宿主没有提供工作区准备能力(ensureWorkspace)。"));
469
- }
470
- return ensureWorkspace(path, undefined);
471
- }
472
-
473
- /** 卸载:停掉全部定时器(数据留着,下次启动重建)。 */
474
- function dispose() {
475
- for (const id of [...jobs.keys()]) stopJob(id);
476
- }
477
-
478
- return {
479
- file,
480
- snapshot,
481
- list: () => [...items.values()],
482
- create,
483
- update,
484
- remove,
485
- runNow,
486
- preview: (cron) => ({ fires: previewRuns(cron, t) }),
487
- /**
488
- * 给面板用:确保定时任务的默认工作区(或自定义目录)存在,返回精简 { id, title, path }。
489
- *
490
- * 注意:这里不直接转发 host 注入的 ensureWorkspace,因为后者现在透的是完整 entity
491
- * (含 attachSession 等方法引用),不能塞进 remote 的 zod 严格 schema;面板用不上那些方法,
492
- * 拿 id/title/path 就够了。
493
- */
494
- ensureWorkspace: async (path, title) => {
495
- if (ensureWorkspace === undefined) {
496
- throw new Error("宿主没有提供工作区准备能力(ensureWorkspace)。");
497
- }
498
- const entity = await ensureWorkspace(path, title);
499
- return { id: String(entity.id), title: String(entity.title ?? ""), path: String(entity.path ?? "") };
500
- },
501
- setArmed,
502
- isArmed: () => armed,
503
- /** 当前真正挂着定时器的事项 id(自检与诊断用:能看出"任务在、但没排定"这种失效)。 */
504
- scheduledIds: () => [...jobs.keys()],
505
- dispose,
506
- /** 开关变化后重建调度(settings 回调里用)。 */
507
- rescheduleAll,
508
- };
509
- }