dsh-task-dispatch-table 0.1.11 → 0.1.13

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/dist/config.d.ts CHANGED
@@ -28,7 +28,17 @@ export interface PluginConfig {
28
28
  */
29
29
  /** 上传临时区保留期(天):到期文件由 tick 内清道夫删除(默认 7)。 */
30
30
  attachmentTmpRetentionDays: number;
31
+ /** 侧栏显示名;空串 = 用词典默认名(`panelTitle`)。 */
32
+ displayName: string;
33
+ /** 侧栏入口位置:`first` = 一级菜单(默认);`more` = 收进「更多」菜单。 */
34
+ entryMode: EntryMode;
35
+ /** 屏蔽官方「自动化任务」入口(`@deepseek-ai/dsh-client-ui-schedule`);false = 不屏蔽(默认)。 */
36
+ hideOfficialSchedule: boolean;
31
37
  }
38
+ /** 侧栏入口的两档位置(设置区下拉的取值,别用自由字符串)。 */
39
+ export type EntryMode = 'first' | 'more';
40
+ /** `entryMode` 的合法取值(服务端校验 + 前端下拉共用,防两处漂移)。 */
41
+ export declare const ENTRY_MODES: readonly EntryMode[];
32
42
  /**
33
43
  * **系统默认值**(全量,含字符串路径类)—— `Config` schema 与设置页「用户没设时显示什么」的唯一真源。
34
44
  *
@@ -50,6 +60,9 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
50
60
  defaultModel: z<string, string, "defined">;
51
61
  logRetentionDays: z<number, number, "defined">;
52
62
  attachmentTmpRetentionDays: z<number, number, "defined">;
63
+ displayName: z<string, string, "defined">;
64
+ entryMode: z<string, string, "defined">;
65
+ hideOfficialSchedule: z<boolean, boolean, "defined">;
53
66
  }>>, Schemastery.ObjectT<NoInfer<{
54
67
  statePath: z<string, string, "defined">;
55
68
  tickMs: z<number, number, "defined">;
@@ -63,6 +76,9 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
63
76
  defaultModel: z<string, string, "defined">;
64
77
  logRetentionDays: z<number, number, "defined">;
65
78
  attachmentTmpRetentionDays: z<number, number, "defined">;
79
+ displayName: z<string, string, "defined">;
80
+ entryMode: z<string, string, "defined">;
81
+ hideOfficialSchedule: z<boolean, boolean, "defined">;
66
82
  }>>, "plain">;
67
83
  /**
68
84
  * rc.1 volatile 字段的解析结果是**带 get() 的引用**(非纯值);读取时解包(与参考插件
package/dist/config.js CHANGED
@@ -4,6 +4,8 @@
4
4
  import { homedir } from 'node:os';
5
5
  import { join, resolve } from 'node:path';
6
6
  import z from '@deepseek-ai/schemastery';
7
+ /** `entryMode` 的合法取值(服务端校验 + 前端下拉共用,防两处漂移)。 */
8
+ export const ENTRY_MODES = ['first', 'more'];
7
9
  /**
8
10
  * **系统默认值**(全量,含字符串路径类)—— `Config` schema 与设置页「用户没设时显示什么」的唯一真源。
9
11
  *
@@ -24,6 +26,9 @@ export const CONFIG_DEFAULTS = {
24
26
  unknownGraceMs: 5 * 60_000,
25
27
  logRetentionDays: 30,
26
28
  attachmentTmpRetentionDays: 7,
29
+ displayName: '',
30
+ entryMode: 'first',
31
+ hideOfficialSchedule: false,
27
32
  };
28
33
  // ⚠️ rc.1(0.1.7-rc.1)约束:宿主插件配置字段**不能标 .volatile()**。
29
34
  // 实测根因(set717/lib/index.js:417 + types/index.js:368-380):configForms 的读写都要求字段
@@ -48,6 +53,12 @@ export const Config = z.object({
48
53
  defaultModel: z.string().default(CONFIG_DEFAULTS.defaultModel),
49
54
  logRetentionDays: z.number().min(1).default(CONFIG_DEFAULTS.logRetentionDays),
50
55
  attachmentTmpRetentionDays: z.number().min(1).default(CONFIG_DEFAULTS.attachmentTmpRetentionDays),
56
+ // ── 设置区(插件详情页)三个字段:显示名 / 入口位置 / 屏蔽官方自动化 ──
57
+ // entryMode 用 string 而非字面量联合:schemastery 的 union 在宿主界面上没好处,
58
+ // 合法性由服务端白名单(ENTRY_MODES)与前端下拉共同保证。
59
+ displayName: z.string().default(CONFIG_DEFAULTS.displayName),
60
+ entryMode: z.string().default(CONFIG_DEFAULTS.entryMode),
61
+ hideOfficialSchedule: z.boolean().default(CONFIG_DEFAULTS.hideOfficialSchedule),
51
62
  });
52
63
  /**
53
64
  * rc.1 volatile 字段的解析结果是**带 get() 的引用**(非纯值);读取时解包(与参考插件
@@ -71,7 +82,7 @@ export function readConfigField(field, fallback) {
71
82
  export function resolveStatePath(statePath) {
72
83
  if (statePath.trim().length > 0)
73
84
  return resolve(statePath);
74
- const raw = process.env['DSH_HOME'];
85
+ const raw = process.env.DSH_HOME;
75
86
  const selected = raw !== undefined && raw.trim().length > 0 ? raw : join(homedir(), '.dsh');
76
87
  const home = selected === '~' || selected.startsWith('~/')
77
88
  ? join(homedir(), selected.slice(selected.length === 1 ? 1 : 2))
@@ -10,10 +10,12 @@ export declare class DispatchPreconditionError extends Error {
10
10
  constructor(reason: string, message: string);
11
11
  }
12
12
  /**
13
- * 工作区名 → 工作区实体(决策 22:target.workspace 语义是**工作区**,不是工作目录;
14
- * 目录由实体 path 派生)。registry 无按 name 查询 API,遍历 list() 比对:
15
- * title 精确匹配优先,id 兜底。匹配不到即抛错 ⇒ 任务判失败,绝不落到「未分组」或随便找个目录跑。
16
- * (Loop A 落库前的前置检查用;Loop B 发动走 resolveWorkspaceByPath——快照里存的是 path。)
13
+ * 工作区标识 → 工作区实体(决策 22:target.workspace 语义是**工作区**,不是工作目录;
14
+ * 目录由实体 path 派生)。**新任务存 id**(宿主随机 UUID,改名不变);老任务存 title,
15
+ * 所以先按 title 精确匹配、再按 id 兜底(用户 2026-11-06 向下兼容)。
16
+ * registry 无按 name 查询 API,只能遍历 list()。匹配不到即抛错 ⇒ 任务判失败,
17
+ * 绝不落到「未分组」或随便找个目录跑。(Loop A 落库前的前置检查用;Loop B 发动走
18
+ * resolveWorkspaceByPath——快照里存的是 path。)
17
19
  */
18
20
  export declare function resolveWorkspace(ctx: HostContext, name: string): HostWorkspace;
19
21
  /**
package/dist/dispatch.js CHANGED
@@ -20,10 +20,12 @@ export class DispatchPreconditionError extends Error {
20
20
  }
21
21
  }
22
22
  /**
23
- * 工作区名 → 工作区实体(决策 22:target.workspace 语义是**工作区**,不是工作目录;
24
- * 目录由实体 path 派生)。registry 无按 name 查询 API,遍历 list() 比对:
25
- * title 精确匹配优先,id 兜底。匹配不到即抛错 ⇒ 任务判失败,绝不落到「未分组」或随便找个目录跑。
26
- * (Loop A 落库前的前置检查用;Loop B 发动走 resolveWorkspaceByPath——快照里存的是 path。)
23
+ * 工作区标识 → 工作区实体(决策 22:target.workspace 语义是**工作区**,不是工作目录;
24
+ * 目录由实体 path 派生)。**新任务存 id**(宿主随机 UUID,改名不变);老任务存 title,
25
+ * 所以先按 title 精确匹配、再按 id 兜底(用户 2026-11-06 向下兼容)。
26
+ * registry 无按 name 查询 API,只能遍历 list()。匹配不到即抛错 ⇒ 任务判失败,
27
+ * 绝不落到「未分组」或随便找个目录跑。(Loop A 落库前的前置检查用;Loop B 发动走
28
+ * resolveWorkspaceByPath——快照里存的是 path。)
27
29
  */
28
30
  export function resolveWorkspace(ctx, name) {
29
31
  const workspaces = ctx.workspaceRegistry.list();
@@ -0,0 +1,7 @@
1
+ /**
2
+ * 核心 `adv/explorer` 的文件系统 HTTP 路由(宿主注册 / 客户端连接共用一条常量)。
3
+ *
4
+ * 核心 `FileBrowser` 只吃 `FsFace`(绝对路径);宿主侧由 `serveFsRoute` 提供该路由,
5
+ * 客户端由 `connectFs({ path })` 建 HTTP face。路径与插件路由同前缀,避免与其他插件撞车。
6
+ */
7
+ export declare const FS_ROUTE = "/api/task-dispatch-table/fs";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * 核心 `adv/explorer` 的文件系统 HTTP 路由(宿主注册 / 客户端连接共用一条常量)。
3
+ *
4
+ * 核心 `FileBrowser` 只吃 `FsFace`(绝对路径);宿主侧由 `serveFsRoute` 提供该路由,
5
+ * 客户端由 `connectFs({ path })` 建 HTTP face。路径与插件路由同前缀,避免与其他插件撞车。
6
+ */
7
+ export const FS_ROUTE = '/api/task-dispatch-table/fs';
package/dist/index.d.ts CHANGED
@@ -17,8 +17,8 @@ export type { PluginConfig };
17
17
  * `tasksInline`(整份任务表 JSON)与 `debugSnapshot`(宿主调试数据),不能整份吐给浏览器。
18
18
  */
19
19
  export declare function configView(config: PluginConfig): {
20
- config: Record<string, string | number>;
21
- defaults: Record<string, string | number>;
20
+ config: Record<string, string | number | boolean>;
21
+ defaults: Record<string, string | number | boolean>;
22
22
  };
23
23
  /** 附件约束(白名单/上限/扩展名解析)与浏览器端预检**共用同一份**,防两处漂移。 */
24
24
  export { ATTACHMENT_MAX_BYTES, ALLOWED_ATTACHMENT_EXT, extOf } from './attachment-allowlist.js';
package/dist/index.js CHANGED
Binary file
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 官方「自动化任务」入口的开关(宿主 0.2.2-rc.1 源码核实,结论见
3
+ * `docs/design/external/official-schedule-toggle.md`)。
4
+ *
5
+ * 为什么必须借道宿主的插件管理器:官方 slots 没有「隐藏他人条目」的接口,唯一的正路是把
6
+ * `@deepseek-ai/dsh-client-ui-schedule` 这条组合行停用(写 profile 的 `disabled` 覆盖项)。
7
+ * 停用后该行仍留在 loader 的 entry 表里(只是没有 fiber),所以插件清单看得到、也还能再启用。
8
+ *
9
+ * ⚠️ 不写进 inject:`pluginManager` 缺失时整块要能安静降级(决策 17:组合满足不了的依赖会让
10
+ * entry 一直 pending)。
11
+ */
12
+ import type { HostContext, HostLogger } from './host.js';
13
+ /** 官方自动化任务界面所在的包(组合行 id `ui-schedule`,声明在 `@deepseek-ai/dsh-web-app` 的 patch)。 */
14
+ export declare const OFFICIAL_SCHEDULE_PACKAGE = "@deepseek-ai/dsh-client-ui-schedule";
15
+ /** 插件清单行(`pluginManager.listPlugins()` 投影的子集)。 */
16
+ interface PluginRow {
17
+ entryId: string;
18
+ moduleName: string;
19
+ enabled: boolean;
20
+ readOnlyReason?: string;
21
+ }
22
+ /** `ctx.pluginManager` 用到的最小面(TypertRemoteService,宿主 0.2.2-rc.1 `lib/types/index.d.ts:187`)。 */
23
+ export interface PluginManagerFace {
24
+ listPlugins(): Promise<PluginRow[]>;
25
+ setPluginEnabled(id: string, enabled: boolean): Promise<unknown>;
26
+ }
27
+ /**
28
+ * 取宿主插件管理器;缺失 / 面不齐 ⇒ null(调用方降级并只记一条日志)。
29
+ * @param ctx - 宿主上下文。
30
+ */
31
+ export declare function pluginManagerOf(ctx: HostContext): PluginManagerFace | null;
32
+ /**
33
+ * 让官方自动化入口处在 `hidden` 说的状态(幂等:本来就对就一次写都不发)。
34
+ * @param ctx - 宿主上下文(取 `pluginManager`)。
35
+ * @param logger - 宿主 logger(降级/失败原因留痕,别静默)。
36
+ * @param hidden - true = 屏蔽官方入口;false = 放出来。
37
+ * @returns 是否已对齐;false 表示宿主不支持或该行不可寻址。
38
+ */
39
+ export declare function syncOfficialSchedule(ctx: HostContext, logger: HostLogger, hidden: boolean): Promise<boolean>;
40
+ /**
41
+ * 卸载前放回官方入口(只在我们自己设过「屏蔽」时调用)。
42
+ *
43
+ * ⚠️ 不能同步等:卸载正是插件管理器持着 profile 文件锁做 reload 时触发的,此处再调一次
44
+ * `setPluginEnabled` 会去等同一把锁(120s)。故丢一个延后调用出去,等外层操作放锁后再跑。
45
+ * 宿主进程紧接退出时这一枪可能没打出去 —— 兜底是用户在插件页手动把那行打开。
46
+ * @param logger - 宿主 logger。
47
+ */
48
+ export declare function releaseOfficialSchedule(logger: HostLogger): void;
49
+ export {};
@@ -0,0 +1,74 @@
1
+ /** 官方自动化任务界面所在的包(组合行 id `ui-schedule`,声明在 `@deepseek-ai/dsh-web-app` 的 patch)。 */
2
+ export const OFFICIAL_SCHEDULE_PACKAGE = '@deepseek-ai/dsh-client-ui-schedule';
3
+ /**
4
+ * 取宿主插件管理器;缺失 / 面不齐 ⇒ null(调用方降级并只记一条日志)。
5
+ * @param ctx - 宿主上下文。
6
+ */
7
+ export function pluginManagerOf(ctx) {
8
+ const service = ctx.get('pluginManager');
9
+ if (typeof service !== 'object' || service === null)
10
+ return null;
11
+ const face = service;
12
+ if (typeof face.listPlugins !== 'function' || typeof face.setPluginEnabled !== 'function')
13
+ return null;
14
+ return face;
15
+ }
16
+ /** 上次拿到的管理器面:dispose 之后 `ctx.get` 未必还可用,回滚要能用旧引用。 */
17
+ let cached = null;
18
+ /** 找到官方自动化那一行(找不到 = 宿主改版换了包名/行,只记 warn 不抛)。 */
19
+ async function scheduleRow(manager) {
20
+ return (await manager.listPlugins()).find((row) => row.moduleName === OFFICIAL_SCHEDULE_PACKAGE);
21
+ }
22
+ /**
23
+ * 让官方自动化入口处在 `hidden` 说的状态(幂等:本来就对就一次写都不发)。
24
+ * @param ctx - 宿主上下文(取 `pluginManager`)。
25
+ * @param logger - 宿主 logger(降级/失败原因留痕,别静默)。
26
+ * @param hidden - true = 屏蔽官方入口;false = 放出来。
27
+ * @returns 是否已对齐;false 表示宿主不支持或该行不可寻址。
28
+ */
29
+ export async function syncOfficialSchedule(ctx, logger, hidden) {
30
+ const manager = pluginManagerOf(ctx);
31
+ if (manager === null) {
32
+ logger.warn('[官方自动化] 宿主没有 pluginManager 服务,屏蔽开关不生效');
33
+ return false;
34
+ }
35
+ cached = manager;
36
+ const row = await scheduleRow(manager);
37
+ if (row === undefined) {
38
+ logger.warn(`[官方自动化] 插件清单里找不到 ${OFFICIAL_SCHEDULE_PACKAGE},屏蔽开关不生效`);
39
+ return false;
40
+ }
41
+ if (row.readOnlyReason !== undefined) {
42
+ logger.warn(`[官方自动化] 该行不可管理(${row.readOnlyReason}),屏蔽开关不生效`);
43
+ return false;
44
+ }
45
+ if (row.enabled === !hidden)
46
+ return true;
47
+ await manager.setPluginEnabled(row.entryId, !hidden);
48
+ logger.info(`[官方自动化] 已${hidden ? '屏蔽' : '放出'}官方自动化入口(重启或 HMR 后生效)`);
49
+ return true;
50
+ }
51
+ /**
52
+ * 卸载前放回官方入口(只在我们自己设过「屏蔽」时调用)。
53
+ *
54
+ * ⚠️ 不能同步等:卸载正是插件管理器持着 profile 文件锁做 reload 时触发的,此处再调一次
55
+ * `setPluginEnabled` 会去等同一把锁(120s)。故丢一个延后调用出去,等外层操作放锁后再跑。
56
+ * 宿主进程紧接退出时这一枪可能没打出去 —— 兜底是用户在插件页手动把那行打开。
57
+ * @param logger - 宿主 logger。
58
+ */
59
+ export function releaseOfficialSchedule(logger) {
60
+ const manager = cached;
61
+ if (manager === null)
62
+ return;
63
+ setTimeout(() => {
64
+ void (async () => {
65
+ const row = await scheduleRow(manager);
66
+ if (row === undefined || row.enabled || row.readOnlyReason !== undefined)
67
+ return;
68
+ await manager.setPluginEnabled(row.entryId, true);
69
+ logger.info('[官方自动化] 插件卸载,已放回官方自动化入口');
70
+ })().catch((error) => {
71
+ logger.warn(`[官方自动化] 卸载回滚失败:${error instanceof Error ? error.message : String(error)}`);
72
+ });
73
+ }, 0);
74
+ }
@@ -37,6 +37,7 @@ export interface TaskOverviewRow {
37
37
  timezone: string | null;
38
38
  start: string | null;
39
39
  everyNWeeks: number | null;
40
+ everyNDays: number | null;
40
41
  window: string;
41
42
  /**
42
43
  * 结构化排期(新建 / 编辑时双写的 `schedule.ui`,2026-09-30 用户拍板):
@@ -9,6 +9,7 @@ function scheduleKeyOf(task) {
9
9
  s.timezone ?? '',
10
10
  s.start ?? '',
11
11
  s.everyNWeeks === undefined ? '' : String(s.everyNWeeks),
12
+ s.everyNDays === undefined ? '' : String(s.everyNDays),
12
13
  // ⚠️ `window`(允许延迟)**必须在这里**(2026-10-07 审计 🟡):它是「该冻还是该前移」与候选槽
13
14
  // 搜索的核心输入,改了它 nextSlotAt 就得重算。原先漏了它 ⇒ `overviewKey` 会 bump(界面拿到新行)
14
15
  // 但 `nextSlotAt` 仍按**旧 window** 算出来 ⇒ 卡片「下次执行」与调度器实际判定分叉,直到刻度过期才自愈。
@@ -35,7 +36,7 @@ function overviewKeyOf(task) {
35
36
  provider: task.target.provider ?? '',
36
37
  model: task.target.model ?? '',
37
38
  prompt: task.target.prompt,
38
- schedule: { cron: s.cron ?? '', once: s.once ?? '', timezone: s.timezone ?? '', start: s.start ?? '', everyNWeeks: s.everyNWeeks ?? 0, window: s.window, ui: s.ui ?? null },
39
+ schedule: { cron: s.cron ?? '', once: s.once ?? '', timezone: s.timezone ?? '', start: s.start ?? '', everyNWeeks: s.everyNWeeks ?? 0, everyNDays: s.everyNDays ?? 0, window: s.window, ui: s.ui ?? null },
39
40
  retry: task.retry?.maxAttempts ?? 1,
40
41
  attachments: (task.attachments ?? []).map(a => `${a.name}:${a.kind}:${a.ref}`),
41
42
  depends: (task.depends_on ?? []).map(d => d.task),
@@ -298,6 +299,7 @@ export function createRuntimeIndex() {
298
299
  timezone: task.schedule.timezone ?? null,
299
300
  start: task.schedule.start ?? null,
300
301
  everyNWeeks: task.schedule.everyNWeeks ?? null,
302
+ everyNDays: task.schedule.everyNDays ?? null,
301
303
  // 防御取值:overview 是展示面,畸形定义也不能让它崩(zod 有默认值的字段仍按可选读)。
302
304
  window: task.schedule.window ?? 'PT0S',
303
305
  ui: task.schedule.ui ?? null,
@@ -8,6 +8,7 @@ export interface ScheduleNextSchedule {
8
8
  once?: string;
9
9
  start?: string;
10
10
  everyNWeeks?: number | null;
11
+ everyNDays?: number | null;
11
12
  }
12
13
  /** 最小任务形状:只要求带 `schedule`。 */
13
14
  export interface ScheduleNextTask {
@@ -26,10 +27,12 @@ export declare function logicalDateOf(date: Date, timeZone: string | undefined):
26
27
  */
27
28
  export declare function scheduledSlotsFor(task: ScheduleNextTask, from: Date, to: Date, cap?: number): Date[];
28
29
  /**
29
- * 周期任务的锚点 / 步长过滤(「开始时间」+「每 N 周」):
30
- * - `start`:首跑下界(不早于此时);也是「每 N 周」取模的参考周(start 所在周 = 第 0 周)。
30
+ * 周期任务的锚点 / 步长过滤(「开始时间」+「每 N 周」/「每 N 天」):
31
+ * - `start`:首跑下界(不早于此时);也是步长取模的参考(start 所在周 / 日 = 第 0 个)。
31
32
  * - `everyNWeeks`:每周档的重复步长。cron 没有「第几周」位 ⇒ 用「刻度与 start 的整周差 % N」
32
33
  * 过滤,天然覆盖用户诉求:开始时间设下周一、每 2 周 ⇒ 下周一跑、下下周跳过、再下一周跑。
34
+ * - `everyNDays`:每天档的重复步长。cron 的日位 `*&#47;N` 是「每月第 N 天」(月底会重数)⇒ 同样取模,
35
+ * 但按**日历日**差算(DST 那天不是 24h,用毫秒会把相位推歪)。
33
36
  */
34
37
  export declare function filterSlotsBySchedule(task: ScheduleNextTask, slots: Date[]): Date[];
35
38
  /** 给定时刻之后的下一个刻度(面板展示「下次执行」用)。 */
@@ -127,30 +127,38 @@ export function scheduledSlotsFor(task, from, to, cap = 20_000) {
127
127
  return filterSlotsBySchedule(task, raw);
128
128
  }
129
129
  /**
130
- * 周期任务的锚点 / 步长过滤(「开始时间」+「每 N 周」):
131
- * - `start`:首跑下界(不早于此时);也是「每 N 周」取模的参考周(start 所在周 = 第 0 周)。
130
+ * 周期任务的锚点 / 步长过滤(「开始时间」+「每 N 周」/「每 N 天」):
131
+ * - `start`:首跑下界(不早于此时);也是步长取模的参考(start 所在周 / 日 = 第 0 个)。
132
132
  * - `everyNWeeks`:每周档的重复步长。cron 没有「第几周」位 ⇒ 用「刻度与 start 的整周差 % N」
133
133
  * 过滤,天然覆盖用户诉求:开始时间设下周一、每 2 周 ⇒ 下周一跑、下下周跳过、再下一周跑。
134
+ * - `everyNDays`:每天档的重复步长。cron 的日位 `*&#47;N` 是「每月第 N 天」(月底会重数)⇒ 同样取模,
135
+ * 但按**日历日**差算(DST 那天不是 24h,用毫秒会把相位推歪)。
134
136
  */
135
137
  export function filterSlotsBySchedule(task, slots) {
136
138
  const startIso = task.schedule.start;
137
139
  const startAbs = startIso === undefined ? undefined : wallClockToAbsolute(startIso, task.schedule.timezone);
138
140
  const start = startAbs === null ? undefined : startAbs;
139
- const nWeeks = task.schedule.everyNWeeks;
140
- if (start === undefined && (nWeeks === undefined || nWeeks === null || nWeeks <= 1))
141
+ const nWeeks = task.schedule.everyNWeeks ?? 1;
142
+ const nDays = task.schedule.everyNDays ?? 1;
143
+ const weekly = nWeeks > 1;
144
+ // 无锚点(老数据 / 手写 JSON)⇒ 不算相位,按「每天」跑(不猜)。
145
+ const daily = start !== undefined && nDays > 1;
146
+ if (start === undefined && !weekly)
141
147
  return slots;
142
148
  const ref = start ?? slots[0];
143
149
  if (ref === undefined)
144
150
  return slots;
145
151
  const WEEK = 7 * 24 * 3600 * 1000;
152
+ const dayIndex = (date) => Date.parse(`${logicalDateOf(date, task.schedule.timezone)}T00:00:00Z`) / 86_400_000;
153
+ const refDay = dayIndex(ref);
154
+ const mod = (value, n) => ((value % n) + n) % n;
146
155
  return slots.filter(slot => {
147
156
  if (start !== undefined && slot.getTime() < start.getTime())
148
157
  return false;
149
- if (nWeeks !== undefined && nWeeks !== null && nWeeks > 1) {
150
- const weeks = Math.floor((slot.getTime() - ref.getTime()) / WEEK);
151
- if (((weeks % nWeeks) + nWeeks) % nWeeks !== 0)
152
- return false;
153
- }
158
+ if (weekly && mod(Math.floor((slot.getTime() - ref.getTime()) / WEEK), nWeeks) !== 0)
159
+ return false;
160
+ if (daily && mod(dayIndex(slot) - refDay, nDays) !== 0)
161
+ return false;
154
162
  return true;
155
163
  });
156
164
  }
@@ -79,4 +79,9 @@ export declare function createScheduler(opts: {
79
79
  assets?: () => AssetPaths | null;
80
80
  /** 主界面运行态内存索引(2026-09-30);未装配则跳过(不影响调度)。 */
81
81
  runtime?: RuntimeIndex;
82
+ /**
83
+ * 老任务存的工作区是**标题**(用户 2026-11-06):按标题解析成功后就地改存 id 并落盘。
84
+ * 未传 ⇒ 只解析不改写(目录模式 / 测试)。
85
+ */
86
+ healWorkspaceId?: (taskId: string, workspaceId: string) => void;
82
87
  }): Scheduler;
package/dist/scheduler.js CHANGED
@@ -367,7 +367,9 @@ function dispatchNewSlots(ctx, logger, store, tasks, verdictLog, upstreams, asse
367
367
  /** 主界面运行态内存索引(2026-09-30):落库即顺手标记「运行中」,不额外查库。 */
368
368
  runtime,
369
369
  /** 本进程启动时刻(决策 54):补记「未执行」只补**启动之后**的槽 ⇒ 停机期间漏的不补。 */
370
- startedAtMs) {
370
+ startedAtMs,
371
+ /** 老任务存工作区标题:按标题解析成功后把 id 写回定义(用户 2026-11-06 向下兼容自愈)。 */
372
+ healWorkspaceId) {
371
373
  const nowMs = Date.now();
372
374
  for (const task of tasks) {
373
375
  // 停用 ⇒ **顺手清掉「被挡住」的原因**(2026-09-30 复核 P1):否则一张**已关掉**的卡片悬停时
@@ -469,6 +471,11 @@ startedAtMs) {
469
471
  runtime?.markBlocked(task.id, workspaceMissing);
470
472
  continue;
471
473
  }
474
+ // 老任务存的是标题 ⇒ 解析成功后**就地改存 id**(用户 2026-11-06:改名后才不会再失联)。
475
+ if (task.target.workspace !== workspace.id) {
476
+ healWorkspaceId?.(task.id, workspace.id);
477
+ task.target.workspace = workspace.id;
478
+ }
472
479
  // 附加文件校验(2026-09-30):缺失 ⇒ **不建行、不执行**,只记 error。
473
480
  // 与「预条件不过不建行」同源;且不判 failed——重试也不会自己长出来,白耗重试额度。
474
481
  // 日志走「结论变化才记」的同一张签名表,缺着不修就不会每 tick 刷一条。
@@ -506,7 +513,7 @@ startedAtMs) {
506
513
  }
507
514
  }
508
515
  export function createScheduler(opts) {
509
- const { ctx, logger, store, reconciler, config, assets, runtime } = opts;
516
+ const { ctx, logger, store, reconciler, config, assets, runtime, healWorkspaceId } = opts;
510
517
  const verdictLog = new Map();
511
518
  let taskMap = new Map();
512
519
  /** 进程启动时刻(决策 54):补记「未执行」的门禁 —— 停机期间漏掉的槽**不补**。 */
@@ -524,7 +531,7 @@ export function createScheduler(opts) {
524
531
  // 故这里把全量写进 taskMap,让前端前置列表能选到停用任务。
525
532
  taskMap = new Map(allTasks.map((t) => [t.id, t]));
526
533
  // Loop A:先处理新刻度(懒建行 + 不回看 + 不补跑 + 落库即止,决策 41)
527
- dispatchNewSlots(ctx, logger, store, tasks, verdictLog, upstreams, assets === undefined ? null : assets(), runtime ?? null, startedAtMs);
534
+ dispatchNewSlots(ctx, logger, store, tasks, verdictLog, upstreams, assets === undefined ? null : assets(), runtime ?? null, startedAtMs, healWorkspaceId);
528
535
  // Loop B:再收口全部执行记录(发动本 tick 新落库的行 + 追问 / 重试 / 租约——
529
536
  // 只读执行记录 + 快照,决策 41)。放在 Loop A 之后 = 新行当 tick 即被发动,时延不退化。
530
537
  reconciler.sweep();
@@ -574,6 +581,11 @@ export function createScheduler(opts) {
574
581
  catch {
575
582
  return { ok: false, error: 'workspace-missing', detail: task.target.workspace };
576
583
  }
584
+ // 与 Loop A 同口径:老任务存标题 ⇒ 解析成功后就地改存 id(用户 2026-11-06)。
585
+ if (task.target.workspace !== workspace.id) {
586
+ healWorkspaceId?.(task.id, workspace.id);
587
+ task.target.workspace = workspace.id;
588
+ }
577
589
  const missing = missingAttachments(ctx, task, workspace.path, assets === undefined ? null : assets());
578
590
  if (missing.length > 0)
579
591
  return { ok: false, error: 'attachment-missing', detail: missing.join('、') };
package/dist/tasks.d.ts CHANGED
@@ -23,6 +23,7 @@ export declare const taskDefinitionSchema: z.ZodObject<{
23
23
  once: z.ZodOptional<z.ZodString>;
24
24
  start: z.ZodOptional<z.ZodString>;
25
25
  everyNWeeks: z.ZodOptional<z.ZodNumber>;
26
+ everyNDays: z.ZodOptional<z.ZodNumber>;
26
27
  ui: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
27
28
  }, z.core.$strip>;
28
29
  target: z.ZodObject<{
@@ -132,7 +133,7 @@ export interface SaveValidation {
132
133
  * ④ 前置任务必须指向已存在的任务(**停用可以、不存在不行**)。
133
134
  * `title` / `code` **不做**格式与唯一性校验(决策 30:它们只是人读字段)。
134
135
  */
135
- export declare function validateDefinitionForSave(def: TaskDefinitionInput, existingIds: ReadonlySet<string>): SaveValidation;
136
+ export declare function validateDefinitionForSave(def: TaskDefinitionInput, existingIds: ReadonlySet<string>, knownWorkspaces?: ReadonlySet<string>): SaveValidation;
136
137
  export interface UpsertResult {
137
138
  json: string;
138
139
  mode: 'create' | 'update';
@@ -167,6 +168,17 @@ export interface SetEnabledResult {
167
168
  * 定义不存在 ⇒ error(404 语义);值没变 ⇒ changed=false(不落库不审计)。
168
169
  */
169
170
  export declare function setEnabledDefinitionInline(raw: string, id: string, enabled: boolean): SetEnabledResult;
171
+ export interface SetWorkspaceResult {
172
+ json: string;
173
+ changed: boolean;
174
+ error: string | null;
175
+ }
176
+ /**
177
+ * 把一条任务的 `target.workspace` 从「工作区标题」改存为「工作区 id」(用户 2026-11-06:
178
+ * 向下兼容自愈 —— 老任务存标题,执行时按标题解析成功后就地写成 id,下次改名不再失联)。
179
+ * 定义不存在 / 表非法 / 值没变 ⇒ 原样返回(不落库)。
180
+ */
181
+ export declare function setWorkspaceDefinitionInline(raw: string, id: string, workspace: string): SetWorkspaceResult;
170
182
  /** 把 ISO 8601 时长解析成毫秒。 */
171
183
  export declare function durationMs(iso: string): number;
172
184
  /** 用 Intl 验证 IANA 时区名(schedule.timezone 缺省 = 宿主时区)。 */
package/dist/tasks.js CHANGED
@@ -10,7 +10,7 @@ import { isSafeAttachmentRef } from './attachment-allowlist.js';
10
10
  // 「cron / once → 时刻」纯核已于 2026-10-05 抽出至 [`./schedule-next.ts`](./schedule-next.ts)
11
11
  //(零 node 依赖,客户端 bundle 与服务端**同一份**)。这里 import 供内部使用(firstSlotOnDay 等),
12
12
  // 并 re-export 保持既有调用面(runtime-index / scheduler / index)零改动。
13
- import { filterSlotsBySchedule, logicalDateOf, nextSlotAfter, onceScheduledAt, scheduledSlotsFor, } from './schedule-next.js';
13
+ import { logicalDateOf, onceScheduledAt, scheduledSlotsFor, } from './schedule-next.js';
14
14
  export { filterSlotsBySchedule, logicalDateOf, nextSlotAfter, onceScheduledAt, scheduledSlotsFor, } from './schedule-next.js';
15
15
  /** ISO 8601 时长(如 PT4H),只支持 H/M/S 组合——窗口与新鲜度够用。 */
16
16
  const isoDuration = z.string().regex(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/, 'ISO 8601 时长,如 PT4H');
@@ -44,6 +44,8 @@ export const taskDefinitionSchema = z.object({
44
44
  start: z.string().optional(),
45
45
  /** 每周档的重复步长(周):1 = 每周;≥2 = 每 N 周。cron 无隔周位 ⇒ 用锚点 + 取模实现(决策)。 */
46
46
  everyNWeeks: z.number().int().min(1).optional(),
47
+ /** 每天档的重复步长(天):1 = 每天;≥2 = 每 N 天。cron 的日位 `*&#47;N` 是「每月第 N 天」(月底会重数)⇒ 同「每 N 周」用锚点 + 日历日差取模。 */
48
+ everyNDays: z.number().int().min(1).optional(),
47
49
  /**
48
50
  * 结构化排期的**编辑态快照**(2026-09-30 双写,data-model §5.4):表单控件原样留档,
49
51
  * 供「编辑现有任务」反解。**执行永不读它**——排期真源恒为 `cron` / `once`。
@@ -51,7 +53,8 @@ export const taskDefinitionSchema = z.object({
51
53
  ui: z.record(z.string(), z.unknown()).optional(),
52
54
  }),
53
55
  target: z.object({
54
- // 工作区(非工作目录,决策 22):按 registry 的 title 精确匹配、id 兜底;目录由工作区 path 派生。
56
+ // 工作区(非工作目录,决策 22):存 workspace id(宿主创建时的随机 UUID,重命名不变);
57
+ // 老任务存的是 title,执行 / 保存时按标题解析成功后**就地改存 id**(用户 2026-11-06 向下兼容)。
55
58
  workspace: z.string().min(1),
56
59
  /** 派发模型(决策 22 漏斗第①层):provider 与 model 成对;都留空则漏到插件配置 / 宿主默认。 */
57
60
  provider: z.string().optional(),
@@ -236,13 +239,19 @@ export function applyIdentity(def) {
236
239
  * ④ 前置任务必须指向已存在的任务(**停用可以、不存在不行**)。
237
240
  * `title` / `code` **不做**格式与唯一性校验(决策 30:它们只是人读字段)。
238
241
  */
239
- export function validateDefinitionForSave(def, existingIds) {
242
+ export function validateDefinitionForSave(def, existingIds, knownWorkspaces) {
240
243
  const prompt = typeof def.target?.prompt === 'string' ? def.target.prompt.trim() : '';
241
244
  if (prompt === '')
242
245
  return { ok: false, error: '提示词不能为空' };
243
246
  const workspace = typeof def.target?.workspace === 'string' ? def.target.workspace.trim() : '';
244
247
  if (workspace === '')
245
248
  return { ok: false, error: '工作区不能为空' };
249
+ // 工作区必须**现在真实存在**(按 id 或 title 任一命中):只判非空会让「工作区被改名 / 删除后
250
+ // 表单里留着的旧值」照样保存成功,等到执行时才报「工作区未找到」。registry 未就绪(降级)时
251
+ // 不传该集合 = 跳过(不能拿「拿不到列表」当成「工作区不存在」)。
252
+ if (knownWorkspaces !== undefined && !knownWorkspaces.has(workspace)) {
253
+ return { ok: false, error: `工作区不存在或已被重命名 / 删除:「${workspace}」,请重新选择工作区` };
254
+ }
246
255
  const cron = typeof def.schedule?.cron === 'string' && def.schedule.cron.trim() !== '' ? def.schedule.cron.trim() : undefined;
247
256
  const once = typeof def.schedule?.once === 'string' && def.schedule.once.trim() !== '' ? def.schedule.once.trim() : undefined;
248
257
  if (cron === undefined && once === undefined)
@@ -372,6 +381,38 @@ export function setEnabledDefinitionInline(raw, id, enabled) {
372
381
  row.enabled = enabled;
373
382
  return { json: JSON.stringify(rows, null, 2), changed: true, error: null };
374
383
  }
384
+ /**
385
+ * 把一条任务的 `target.workspace` 从「工作区标题」改存为「工作区 id」(用户 2026-11-06:
386
+ * 向下兼容自愈 —— 老任务存标题,执行时按标题解析成功后就地写成 id,下次改名不再失联)。
387
+ * 定义不存在 / 表非法 / 值没变 ⇒ 原样返回(不落库)。
388
+ */
389
+ export function setWorkspaceDefinitionInline(raw, id, workspace) {
390
+ const text = raw.trim();
391
+ let data;
392
+ if (text === '')
393
+ return { json: raw, changed: false, error: 'task-not-found' };
394
+ try {
395
+ data = JSON.parse(text);
396
+ }
397
+ catch {
398
+ return { json: raw, changed: false, error: '现有任务表不是合法 JSON,无法修改' };
399
+ }
400
+ if (!Array.isArray(data))
401
+ return { json: raw, changed: false, error: '现有任务表不是数组,无法修改' };
402
+ const rows = data;
403
+ const row = rows.find(item => item !== null && typeof item === 'object' && item.id === id);
404
+ if (row === undefined)
405
+ return { json: raw, changed: false, error: 'task-not-found' };
406
+ const target = row.target;
407
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) {
408
+ return { json: raw, changed: false, error: '任务缺少 target,无法修改工作区' };
409
+ }
410
+ const targetObj = target;
411
+ if (targetObj.workspace === workspace)
412
+ return { json: raw, changed: false, error: null };
413
+ targetObj.workspace = workspace;
414
+ return { json: JSON.stringify(rows, null, 2), changed: true, error: null };
415
+ }
375
416
  /** 把 ISO 8601 时长解析成毫秒。 */
376
417
  export function durationMs(iso) {
377
418
  const match = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/.exec(iso);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-task-dispatch-table",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Scheduled task dispatcher: a dsh host plugin that drives recurring agent tasks from a single task table, dispatching each run as an independent dsh session (scheduling itself uses zero model calls)",
5
5
  "icon": "./assets/icon-scheduler.svg",
6
6
  "keywords": [
@@ -34,12 +34,13 @@
34
34
  "LICENSE"
35
35
  ],
36
36
  "scripts": {
37
- "build": "npm run build:host && npm run build:client",
37
+ "build": "pnpm run build:host && pnpm run build:client",
38
38
  "build:host": "tsc",
39
39
  "build:client": "tsc --noEmit -p tsconfig.client.json && tsdown --config tsdown.client.config.ts",
40
40
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.client.json",
41
41
  "smoke": "node scripts/smoke.mjs"
42
42
  },
43
+ "packageManager": "pnpm@12.3.4",
43
44
  "engines": {
44
45
  "node": ">=22.5"
45
46
  },
@@ -59,6 +60,7 @@
59
60
  "@lezer/highlight": "^1.2.3",
60
61
  "@uiw/react-codemirror": "^4.25.12",
61
62
  "cron-parser": "^5.10.1",
63
+ "dsh-plugin-core": "^0.35.8",
62
64
  "zod": "^4.6.5"
63
65
  },
64
66
  "devDependencies": {