dsh-later 1.0.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/lib/index.js ADDED
@@ -0,0 +1,298 @@
1
+ import s from '@deepseek-ai/schemastery';
2
+ import { defineTool } from '@deepseek-ai/dsh-tools';
3
+ import { userSchedulesProjectionUnit } from './projection.js';
4
+ import { registerUserScheduleCommands } from './commands.js';
5
+ import { UserScheduleRuntime } from './runtime.js';
6
+ import { DEFAULT_MAX_PROMPT_CHARS, DEFAULT_MAX_SCHEDULES, resolvePromptLimits, userScheduleCreate, userScheduleDelete, userScheduleList, } from './user-tools.js';
7
+ import { name } from './domain.js';
8
+ import { registerOwnedSessionEventType } from './owned-event-registration.js';
9
+ /** 插件配置 schema(全可选,默认开箱即用;经 cordis config 覆盖)。 */
10
+ export const Config = s.object({
11
+ /** 单 session 用户任务上限。 */
12
+ maxSchedules: s.number().step(1).min(1).default(DEFAULT_MAX_SCHEDULES),
13
+ });
14
+ /** Settings namespace schema(设置 - 插件 配置 UI)。 */
15
+ const SchedulerSettingsSchema = s.object({
16
+ /** 单 session 用户任务上限。 */
17
+ maxSchedules: s.number().step(1).min(1).default(DEFAULT_MAX_SCHEDULES),
18
+ /** 是否解除提示字符的默认下限(1000);关闭时 maxPromptChars 不生效。 */
19
+ allowLongPrompts: s.boolean().default(false),
20
+ /** 提示字符上限(≥1 整数);仅在 allowLongPrompts=true 时生效。 */
21
+ maxPromptChars: s.number().step(1).min(1).default(DEFAULT_MAX_PROMPT_CHARS),
22
+ /** 智能时段:工作时间开始(HH:mm)。 */
23
+ workStart: s.string().default('09:00'),
24
+ /** 智能时段:工作时间结束(HH:mm)。 */
25
+ workEnd: s.string().default('18:00'),
26
+ /** 智能时段:午休开始(HH:mm)。 */
27
+ lunchStart: s.string().default('12:00'),
28
+ /** 智能时段:午休结束(HH:mm)。 */
29
+ lunchEnd: s.string().default('14:00'),
30
+ /** 智能时段:晚间结束(HH:mm)。 */
31
+ eveningEnd: s.string().default('22:00'),
32
+ /** 是否显示输入框右侧的定时按钮(仅 GUI 显隐;提醒触发与 dock 不受影响)。 */
33
+ showButton: s.boolean().default(true),
34
+ });
35
+ /** 所需服务(延后激活,缺失时插件不加载)。 */
36
+ export const inject = ['agents', 'sessions', 'tools', 'commands', 'settings'];
37
+ /** 解析配置(容错非法值回落默认)。 */
38
+ function resolveMaxSchedules(value) {
39
+ if (typeof value !== 'number' || value < 1)
40
+ return DEFAULT_MAX_SCHEDULES;
41
+ return Math.floor(value);
42
+ }
43
+ /** 根据 settings 计算当前生效的提示字符上限(与 user-tools.resolvePromptLimits 同源)。 */
44
+ function resolveSchedulerPromptLimits(settings) {
45
+ return resolvePromptLimits({
46
+ allowLongPrompts: settings.allowLongPrompts,
47
+ maxPromptChars: settings.maxPromptChars,
48
+ });
49
+ }
50
+ /** 纯泛化等待卡片(GenericCallView)。 */
51
+ function present(title, kind, rawInput) {
52
+ return {
53
+ card: 'generic',
54
+ title,
55
+ kind,
56
+ ...(rawInput === undefined ? {} : { rawInput }),
57
+ };
58
+ }
59
+ /** 渲染工具结果:JSON 文本。 */
60
+ function renderText(_args, value) {
61
+ return [{ type: 'text', text: value }];
62
+ }
63
+ /** 为单个 root agent 注册 `user_schedule_*` 工具。 */
64
+ function registerUserScheduleTools(rootCtx, toolCtx, agent, getSettings, onUserChange) {
65
+ const dispose = () => undefined;
66
+ try {
67
+ const disposers = [
68
+ toolCtx.tools.register(defineTool({
69
+ name: 'user_schedule_create',
70
+ // description 在每次 register 时按当前 settings 生成:
71
+ // 模型看到的字符上限始终与宿主实际校验一致,避免描述与实现脱节。
72
+ description: '为用户创建一条当前会话内的定时提醒。必须提供非空提示与恰好一个选择器:after_seconds(正整数延时秒)、at(显式偏移时间或 {date,time,time_zone} 本地时间)、every_seconds(≥300 的固定间隔秒)。time_zone 可选,缺省用会话检测时区。创建来源被标记为用户工具,GUI 面板会展示。',
73
+ parameters: {
74
+ prompt: {
75
+ type: 'string',
76
+ required: true,
77
+ description: `提醒内容(trim 后非空,≤${resolveSchedulerPromptLimits(getSettings()).maxChars} 字符;超过会返回 invalid_prompt)。`,
78
+ },
79
+ after_seconds: { type: 'number', description: '正整数延时秒数。' },
80
+ every_seconds: { type: 'number', description: '固定间隔秒数,至少 300。' },
81
+ at: {
82
+ description: '绝对目标:显式偏移的 RFC 3339 字符串,或 {date, time, time_zone} 本地日历对象。',
83
+ oneOf: [
84
+ { type: 'string' },
85
+ {
86
+ type: 'object',
87
+ additionalProperties: false,
88
+ properties: {
89
+ date: { type: 'string', required: true },
90
+ time: { type: 'string', required: true },
91
+ time_zone: { type: 'string', required: true },
92
+ },
93
+ },
94
+ ],
95
+ },
96
+ time_zone: { type: 'string', description: 'IANA 时区(如 Asia/Shanghai);可选,缺省用会话检测时区。' },
97
+ },
98
+ output: {
99
+ schema: { type: 'string' },
100
+ render: renderText,
101
+ },
102
+ async execute(args, exec) {
103
+ if (exec.agent !== agent)
104
+ return JSON.stringify({ ok: false, code: 'internal_error', message: '操作失败。' });
105
+ const settings = getSettings();
106
+ const result = await userScheduleCreate(args, agent, rootCtx, settings.maxSchedules, undefined, resolveSchedulerPromptLimits(settings));
107
+ if (result.ok) {
108
+ try {
109
+ onUserChange(agent);
110
+ }
111
+ catch {
112
+ /* 通知失败不影响结果 */
113
+ }
114
+ }
115
+ return JSON.stringify(result);
116
+ },
117
+ presentCall: (args) => present('创建定时提醒', 'other', args?.prompt),
118
+ })),
119
+ toolCtx.tools.register(defineTool({
120
+ name: 'user_schedule_list',
121
+ description: '列出当前会话内用户创建的活动定时提醒(含 id、UTC 目标、scheduled/overdue 状态、投递模式)。',
122
+ parameters: {},
123
+ output: {
124
+ schema: { type: 'string' },
125
+ render: renderText,
126
+ },
127
+ async execute(_args, exec) {
128
+ if (exec.agent !== agent)
129
+ return JSON.stringify({ ok: false, code: 'internal_error', message: '操作失败。' });
130
+ const result = await userScheduleList(agent, rootCtx);
131
+ return JSON.stringify(result);
132
+ },
133
+ presentCall: () => present('列出定时提醒', 'read'),
134
+ })),
135
+ toolCtx.tools.register(defineTool({
136
+ name: 'user_schedule_delete',
137
+ description: '按 user_schedule_create / user_schedule_list 返回的精确 id 删除一条活动提醒;未知或已结束返回 deleted:false。',
138
+ parameters: {
139
+ id: { type: 'string', required: true, description: '精确会话内 schedule id。' },
140
+ },
141
+ output: {
142
+ schema: { type: 'string' },
143
+ render: renderText,
144
+ },
145
+ async execute(args, exec) {
146
+ if (exec.agent !== agent)
147
+ return JSON.stringify({ ok: false, code: 'internal_error', message: '操作失败。' });
148
+ const result = await userScheduleDelete(args.id, agent, rootCtx);
149
+ if (result.ok && result.deleted) {
150
+ try {
151
+ onUserChange(agent);
152
+ }
153
+ catch {
154
+ /* 通知失败不影响结果 */
155
+ }
156
+ }
157
+ return JSON.stringify(result);
158
+ },
159
+ presentCall: (args) => present('删除定时提醒', 'other', args?.id),
160
+ })),
161
+ ];
162
+ return () => {
163
+ for (const disposeOne of disposers.reverse())
164
+ disposeOne();
165
+ };
166
+ }
167
+ catch (error) {
168
+ dispose();
169
+ rootCtx.logger.warn(`later: user tools 注册失败: ${error instanceof Error ? error.message : String(error)}`);
170
+ throw error;
171
+ }
172
+ }
173
+ /**
174
+ * Cordis 函数插件主体。
175
+ * @param ctx - 全局服务上下文。
176
+ * @param config - 可选配置(maxSchedules)。
177
+ */
178
+ /** 插件卸载中的标记(避免继续接受新 agent 挂载)。 */
179
+ let stopping = false;
180
+ export function apply(ctx, config) {
181
+ // P0-2:cordis 在配置变更/重载时会 teardown 后再次 apply。stopping 是模块级
182
+ // 标志,若不复位,第二次 apply 起所有 agent 都会在 agent/created 处早退,
183
+ // 用户工具与调度器静默不挂载(GUI 无按钮、提醒永不触发)。
184
+ stopping = false;
185
+ // yml 启动配置仅在首次解析时生效(与运行时用户设置正交,参见
186
+ // dsh-smooth-stream 等同款做法):用户在设置页保存的值走自己的 user 层,
187
+ // 不与 yml base 混合。命令/工具每次调用通过 getSettings() 按需读取设置层的
188
+ // maxSchedules(设置未保存则回退 yml/默认)。
189
+ const startMaxSchedules = resolveMaxSchedules(config?.maxSchedules);
190
+ // 0. 注册自有事件类型(进程级、幂等):否则含 `session-scheduler/user-schedule`
191
+ // 的日志会被任何加载了本插件的读者以 SessionFormatUnsupportedError 拒读
192
+ // (GUI 表现为「历史加载失败」)。详见 domain.ts 中的说明。
193
+ registerOwnedSessionEventType();
194
+ // 1. `userSchedules` 会话投影(GUI 读状态)。
195
+ // `as never`:本插件 dev 依赖解析到的 @deepseek-ai/dsh-session-projection
196
+ // 可能是旧版(rc.1,契约 `schema`+顶层 `view`),而宿主运行时是 rc.2
197
+ // (契约 `stateSchema`+`wire{viewSchema,view}`)。两者在类型层冲突,
198
+ // 直接类型化注册会被旧版契约拒掉;运行时又必须按 rc.2 传。故此处保留
199
+ // `as never` 显式声明「运行时契约以宿主为准」,并用
200
+ // `tests/projection-unit.test.mjs` 在运行时锁定 rc.2 形状,防止回归。
201
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
202
+ projectionCtx.sessionProjections.register(userSchedulesProjectionUnit);
203
+ });
204
+ // 2. 注册 settings namespace(设置 - 插件 配置 UI)。
205
+ // 拿住 owner scope:`applies: 'live'` 声明改动即时生效。工具/命令 handler
206
+ // 每次调用通过 getSettings() 按需读取——避免主动 watch 引入的一致性边界与
207
+ // 误同步(对齐 dsh-smooth-stream / dsh-auto-collapse 的写法)。
208
+ const startSettings = {
209
+ maxSchedules: startMaxSchedules,
210
+ allowLongPrompts: false,
211
+ maxPromptChars: DEFAULT_MAX_PROMPT_CHARS,
212
+ };
213
+ let getSettings = () => startSettings;
214
+ ctx.inject(['settings'], (settingsCtx) => {
215
+ const scope = settingsCtx.settings.register(
216
+ // 断言而非 settingsNamespace():0.1.1 要求 branded 类型,0.1.2 已删除该 helper。
217
+ 'dsh-later', SchedulerSettingsSchema, { applies: 'live' });
218
+ getSettings = () => {
219
+ try {
220
+ const value = scope.get();
221
+ return {
222
+ maxSchedules: resolveMaxSchedules(value?.maxSchedules ?? startSettings.maxSchedules),
223
+ allowLongPrompts: value?.allowLongPrompts === true,
224
+ maxPromptChars: typeof value?.maxPromptChars === 'number' && value.maxPromptChars >= 1
225
+ ? Math.floor(value.maxPromptChars)
226
+ : startSettings.maxPromptChars,
227
+ };
228
+ }
229
+ catch {
230
+ return startSettings;
231
+ }
232
+ };
233
+ });
234
+ // 每个 root agent 一个「用户提醒调度器」:负责确保命令/工具创建的提醒会武装 timer 并触发。
235
+ const runtimes = new Map();
236
+ /** 用户创建/删除成功 → 重驱该 agent 的用户调度器(也让 dsh-schedule 感知后续 idle)。 */
237
+ const notifyUserChange = (agent) => {
238
+ try {
239
+ runtimes.get(agent)?.requestDrive();
240
+ }
241
+ catch {
242
+ /* 调度失败不影响工具/命令的结果 */
243
+ }
244
+ };
245
+ /** 用户主动插话(GUI「插话发送」按钮):路由到该 agent 的 runtime。 */
246
+ const notifyUserSteer = async (agent, id) => {
247
+ const runtime = runtimes.get(agent);
248
+ if (runtime === undefined) {
249
+ return { ok: false, code: 'no_runtime', message: '该会话尚未挂载用户调度器。' };
250
+ }
251
+ return runtime.steerById(id);
252
+ };
253
+ // 2. slash 命令(客户端变更通道),全局注册一次。
254
+ ctx.effect(() => registerUserScheduleCommands(ctx, getSettings, notifyUserChange, notifyUserSteer), 'later.commands()');
255
+ // 3. 每个 root agent:注册用户工具 + 用户调度器生命周期。
256
+ ctx.effect(() => {
257
+ const stopCreated = ctx.on('agent/created', ({ agent }) => {
258
+ if (stopping || runtimes.has(agent) || !ctx.agents.roots().includes(agent))
259
+ return;
260
+ const runtime = new UserScheduleRuntime(ctx, agent);
261
+ let cleanup = () => undefined;
262
+ try {
263
+ cleanup = agent.ctx.effect(() => {
264
+ const disposeTools = registerUserScheduleTools(ctx, agent.ctx, agent, getSettings, notifyUserChange);
265
+ const stopStatus = agent.ctx.on('agent/status', ({ status }) => {
266
+ // 自愈:agent 转 idle 时重算(与 dsh-schedule 同构),覆盖被遗漏的重驱。
267
+ if (status === 'idle')
268
+ runtime.requestDrive();
269
+ });
270
+ runtime.requestDrive();
271
+ return async () => {
272
+ stopStatus();
273
+ disposeTools();
274
+ try {
275
+ await runtime.dispose();
276
+ }
277
+ finally {
278
+ if (runtimes.get(agent) === runtime)
279
+ runtimes.delete(agent);
280
+ }
281
+ };
282
+ }, 'later.userTools()');
283
+ runtimes.set(agent, runtime);
284
+ }
285
+ catch (error) {
286
+ ctx.logger.warn(`later: agent "${agent.id}" 用户工具/调度器挂载失败: ${error instanceof Error ? error.message : String(error)}`);
287
+ }
288
+ });
289
+ return async () => {
290
+ stopping = true;
291
+ stopCreated();
292
+ const runtimesToDispose = [...runtimes.values()];
293
+ runtimes.clear();
294
+ await Promise.allSettled(runtimesToDispose.map((runtime) => runtime.dispose()));
295
+ };
296
+ }, 'later.userToolsLifecycle()');
297
+ }
298
+ export { name };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 宿主端专属:把本插件自有会话事件类型登记进宿主已知事件集合。
3
+ *
4
+ * ⚠️ 本模块只能被 host 端入口(index.ts)引用——它对
5
+ * `@deepseek-ai/dsh-session` 有**运行时值依赖**;client 端浏览器 bundle 若经
6
+ * 共享模块(如 domain.ts,被 `src/client/index.tsx` 引用)间接引入本模块,
7
+ * esbuild 会尝试把宿主包(依赖 node:path/node:module)打进浏览器产物而失败。
8
+ *
9
+ * @module dsh-later/owned-event-registration
10
+ */
11
+ import { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session';
12
+ import { name, OWNED_EVENT } from './domain.js';
13
+ /**
14
+ * 把 `OWNED_EVENT` 注册进宿主的已知会话事件类型集合(进程级、幂等)。
15
+ *
16
+ * 2026-09 起插件**不再写入**该事件(所有权改存 sidecar,见 ownership-store.ts),
17
+ * 本注册保留为**读兼容**:早期版本写入的历史日志(含当前 format 的日志)仍含
18
+ * 此事件类型,未注册时宿主读路径会以「unknown event type not marked ignorable」
19
+ * 拒读整份日志。
20
+ *
21
+ * 历史教训(为什么当初需要它 + 为什么现在不再写入):当前构建的
22
+ * `Session.append(type, data)` 不暴露 envelope 的 `ignorable` 字段,插件无法
23
+ * 在写入侧自保;持久化层在特定时序下会「seq 已消费但行未落盘」,在日志里留下
24
+ * 永久 seq 缺口,任何读方(含 v0→v3 格式迁移)都会拒读整份历史(GUI 表现为
25
+ * 「历史加载失败」)。唯一根治 = 不往会话日志写宿主词汇表之外的事件。
26
+ *
27
+ * 安全性:该事件是纯信息性的所有权记录,`{version, operation, id}` 自描述;
28
+ * dsh-schedule 的 fold 天然跳过它,本插件投影按严格解码消费,把它当作
29
+ * 「已知必需」不会让任何读者误解释日志其余部分。
30
+ *
31
+ * TODO(上游):待 dsh-session 暴露 append 侧的 `ignorable` 标记后,改为写入
32
+ * 时标记(更符合 envelope 契约),并移除本注册。
33
+ */
34
+ export function registerOwnedSessionEventType() {
35
+ try {
36
+ // 本地 typings(dsh-session@0.1.0-rc.8)把它声明为 ReadonlySet,但运行时
37
+ // 实现是普通可变 Set(lib/index.js 中 `new Set([...])`,未冻结),
38
+ // 且核心文档明示插件注册面暂缓提供——此处收窄为可变引用完成登记。
39
+ KNOWN_SESSION_EVENT_TYPES.add(OWNED_EVENT);
40
+ }
41
+ catch (error) {
42
+ // 未来核心若冻结该集合,此处降级为告警:插件功能不受影响,
43
+ // 只是含本插件事件的旧日志仍会被无本插件上下文的读者拒绝。
44
+ console.warn(`[${name}] register owned session event type failed: ${error instanceof Error ? error.message : String(error)}`);
45
+ }
46
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * 用户任务所有权的 sidecar 存储层。
3
+ *
4
+ * 为什么存在(2026-09 缺口事故的根治):此前所有权经伴生会话事件
5
+ * `session-scheduler/user-schedule` 写进会话日志——但该类型不在宿主事件
6
+ * 词汇表内,持久化层在特定时序下会「seq 已消费但行未落盘」,在日志里留下
7
+ * 永久 seq 缺口,任何读方(含格式迁移)都会拒读整份历史。根治 = 所有权
8
+ * 完全移出会话日志,存到插件自己的 sidecar 文件:
9
+ *
10
+ * <DSH_HOME>/plugin-state/later/<sessionId>.json
11
+ *
12
+ * 改名迁移:插件旧名 `session-scheduler` 的 sidecar 目录在首次启动时自动
13
+ * 迁移(整个目录 rename;rename 失败则逐文件复制)。不写「一次性迁移
14
+ * 标记」——重复执行天然幂等,且不引入新的丢失路径。
15
+ *
16
+ * - 原子写:临时文件 + rename;
17
+ * - 容错读:JSON 损坏时把坏文件改名保留(.corrupt-<ts>),按空表继续;
18
+ * - 进程内缓存:按 (mtimeMs, size) 校验,避免热路径反复读盘;
19
+ * - 路径解析:`DSH_LATER_STATE_DIR`(测试/便携)> 旧名
20
+ * `DSH_LATER_STATE_DIR`(升级过渡)> `$DSH_HOME` > `~/.dsh`
21
+ * (与 dsh-home-paths 的默认解析一致)。
22
+ *
23
+ * 注意:本模块只被 host 端引用;client bundle 不得引入(依赖 node:fs)。
24
+ * @module dsh-later/ownership-store
25
+ */
26
+ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync, existsSync, unlinkSync, readdirSync, copyFileSync, } from 'node:fs';
27
+ import { homedir } from 'node:os';
28
+ import { join } from 'node:path';
29
+ /** 解析状态目录(优先级见模块注释)。 */
30
+ function resolveStateDir() {
31
+ const override = process.env['DSH_LATER_STATE_DIR'] ?? process.env['DSH_LATER_STATE_DIR'];
32
+ if (override !== undefined && override.trim().length > 0)
33
+ return override;
34
+ const dshHomeEnv = process.env['DSH_HOME'];
35
+ const home = dshHomeEnv !== undefined && dshHomeEnv.trim().length > 0 ? dshHomeEnv : join(homedir(), '.dsh');
36
+ return join(home, 'plugin-state', 'later');
37
+ }
38
+ /**
39
+ * 旧名 sidecar 目录(仅 `$DSH_HOME` 路径下存在;env 覆盖视为测试/便携场景,
40
+ * 不做迁移以免污染调用方给定的目录语义)。
41
+ */
42
+ function legacyStateDir() {
43
+ const override = process.env['DSH_LATER_STATE_DIR'] ?? process.env['DSH_LATER_STATE_DIR'];
44
+ if (override !== undefined && override.trim().length > 0)
45
+ return undefined;
46
+ const dshHomeEnv = process.env['DSH_HOME'];
47
+ const home = dshHomeEnv !== undefined && dshHomeEnv.trim().length > 0 ? dshHomeEnv : join(homedir(), '.dsh');
48
+ return join(home, 'plugin-state', 'session-scheduler');
49
+ }
50
+ /** 把旧名目录的 sidecar 文件搬到新目录(目录 rename 优先,失败逐文件复制;幂等)。 */
51
+ function migrateLegacyState(newDir) {
52
+ const oldDir = legacyStateDir();
53
+ if (oldDir === undefined || !existsSync(oldDir))
54
+ return;
55
+ try {
56
+ renameSync(oldDir, newDir);
57
+ return;
58
+ }
59
+ catch {
60
+ /* 目标已存在等:退化为逐文件复制 */
61
+ }
62
+ try {
63
+ for (const entry of readdirSync(oldDir)) {
64
+ if (!entry.endsWith('.json'))
65
+ continue;
66
+ const target = join(newDir, entry);
67
+ if (!existsSync(target))
68
+ copyFileSync(join(oldDir, entry), target);
69
+ }
70
+ }
71
+ catch (error) {
72
+ console.warn(`[dsh-later] 旧 sidecar 目录迁移失败(旧目录保留,可手动搬移): ${error instanceof Error ? error.message : String(error)}`);
73
+ }
74
+ }
75
+ let cachedDir;
76
+ /** 状态目录(懒创建;目录创建失败时返回原路径,由调用方容错)。 */
77
+ function stateDir() {
78
+ if (cachedDir === undefined) {
79
+ cachedDir = resolveStateDir();
80
+ try {
81
+ mkdirSync(cachedDir, { recursive: true });
82
+ migrateLegacyState(cachedDir);
83
+ }
84
+ catch {
85
+ /* 读写时再报错 */
86
+ }
87
+ }
88
+ return cachedDir;
89
+ }
90
+ function fileOf(sessionId) {
91
+ // sessionId 形如 session-<uuid>,字符安全;仍做一次兜底清洗。
92
+ const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
93
+ return join(stateDir(), `${safe}.json`);
94
+ }
95
+ function emptyFile() {
96
+ return { version: 1, entries: {} };
97
+ }
98
+ /** 进程内缓存:sessionId → (mtime, size, file)。写路径同步更新缓存。 */
99
+ const cache = new Map();
100
+ function readFresh(sessionId) {
101
+ const path = fileOf(sessionId);
102
+ let stat;
103
+ try {
104
+ stat = statSync(path);
105
+ }
106
+ catch {
107
+ cache.delete(sessionId);
108
+ return emptyFile();
109
+ }
110
+ const cached = cache.get(sessionId);
111
+ if (cached !== undefined && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
112
+ return cached.file;
113
+ }
114
+ try {
115
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
116
+ if (typeof parsed !== 'object' ||
117
+ parsed === null ||
118
+ parsed.version !== 1 ||
119
+ typeof parsed.entries !== 'object' ||
120
+ parsed.entries === null) {
121
+ throw new Error('unexpected shape');
122
+ }
123
+ const file = parsed;
124
+ cache.set(sessionId, { mtimeMs: stat.mtimeMs, size: stat.size, file });
125
+ return file;
126
+ }
127
+ catch (error) {
128
+ // 损坏文件:改名保留现场,按空表继续(不静默删除,方便离线排查)。
129
+ try {
130
+ renameSync(path, `${path}.corrupt-${Date.now()}`);
131
+ }
132
+ catch {
133
+ /* 改名失败也按空表继续 */
134
+ }
135
+ cache.delete(sessionId);
136
+ try {
137
+ // eslint 由库外保证;这里用 console 而非宿主 logger(本层无 ctx 依赖)。
138
+ console.warn(`[dsh-later] 所有权 sidecar 解析失败,已按空表继续: ${error instanceof Error ? error.message : String(error)}`);
139
+ }
140
+ catch {
141
+ /* 通知失败不影响语义 */
142
+ }
143
+ return emptyFile();
144
+ }
145
+ }
146
+ /** 读取某会话的全部所有权记录(缓存优先;返回副本语义,调用方不得变更)。 */
147
+ export function allOwnership(sessionId) {
148
+ if (typeof sessionId !== 'string' || sessionId.length === 0)
149
+ return {};
150
+ return readFresh(sessionId).entries;
151
+ }
152
+ /** 某 schedule id 是否由用户工具创建。 */
153
+ export function hasOwnership(sessionId, id) {
154
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof id !== 'string')
155
+ return false;
156
+ return Object.prototype.hasOwnProperty.call(readFresh(sessionId).entries, id);
157
+ }
158
+ /** 某条所有权记录(缺失返回 undefined)。 */
159
+ export function getOwnership(sessionId, id) {
160
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof id !== 'string')
161
+ return undefined;
162
+ return readFresh(sessionId).entries[id];
163
+ }
164
+ function writeThrough(sessionId, file) {
165
+ const path = fileOf(sessionId);
166
+ const dir = stateDir();
167
+ const tmp = join(dir, `.${safeName(sessionId)}.${process.pid}.${Date.now()}.tmp`);
168
+ const payload = `${JSON.stringify(file, null, 2)}\n`;
169
+ writeFileSync(tmp, payload, 'utf8');
170
+ try {
171
+ renameSync(tmp, path);
172
+ }
173
+ catch {
174
+ // 极端场景(跨设备等):退化为直接写目标文件。
175
+ writeFileSync(path, payload, 'utf8');
176
+ try {
177
+ if (existsSync(tmp))
178
+ unlinkSync(tmp);
179
+ }
180
+ catch {
181
+ /* 清理失败不影响语义 */
182
+ }
183
+ }
184
+ const stat = statSync(path);
185
+ cache.set(sessionId, { mtimeMs: stat.mtimeMs, size: stat.size, file });
186
+ }
187
+ function safeName(sessionId) {
188
+ return sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
189
+ }
190
+ /** 记录(新增或覆盖)一条所有权;幂等。 */
191
+ export function recordOwnership(sessionId, id, delivery) {
192
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof id !== 'string' || id.length === 0)
193
+ return;
194
+ const file = readFresh(sessionId);
195
+ const entries = { ...file.entries };
196
+ entries[id] = { delivery, createdAt: Date.now() };
197
+ writeThrough(sessionId, { version: 1, entries });
198
+ }
199
+ /** 撤销一条所有权;幂等(不存在时无操作)。 */
200
+ export function removeOwnership(sessionId, id) {
201
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof id !== 'string' || id.length === 0)
202
+ return;
203
+ const file = readFresh(sessionId);
204
+ if (!Object.prototype.hasOwnProperty.call(file.entries, id))
205
+ return;
206
+ const entries = { ...file.entries };
207
+ delete entries[id];
208
+ writeThrough(sessionId, { version: 1, entries });
209
+ }
210
+ /** 测试辅助:清空进程内缓存(不删文件)。 */
211
+ export function resetCacheForTests() {
212
+ cache.clear();
213
+ cachedDir = undefined;
214
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 侧栏「定时状态」badge 的纯逻辑(宿主与客户端共享)。
3
+ *
4
+ * 数据源说明(路线 B 的关键事实):本插件的 `userSchedules` 投影是
5
+ * client-visible 单元,宿主在 `session.list` 基线里为**每个**会话携带
6
+ * projections 块(live 会话走注册表现值,冷会话走持久化投影缓存,零日志
7
+ * 加载);客户端把每行的块喂进 per-session 投影 store,并合入
8
+ * `SessionSummary.projectionValues`。因此客户端从 `sessions.list` 就能拿到
9
+ * 跨会话的任务概要,**无需宿主新增任何数据通道**。
10
+ *
11
+ * 本模块不触碰 DOM / React,全部函数可在 node --test 下运行。
12
+ * @module dsh-later/presence
13
+ */
14
+ /** 紧急窗口:下次触发在该窗口内时,badge 从 scheduled 升级为 urgent。 */
15
+ export const PRESENCE_URGENT_WINDOW_MS = 5 * 60_000;
16
+ /**
17
+ * 归纳:取最早触发时刻 + 可解析数量。`every` 类任务在投影里携带推进后的
18
+ * `scheduledAt`(下次触发点),与一次性任务同构参与排序。
19
+ */
20
+ export function summarizeSchedules(schedules) {
21
+ let nextAt;
22
+ let count = 0;
23
+ for (const item of schedules ?? []) {
24
+ const raw = item.scheduled_at ?? item.scheduledAt;
25
+ if (raw === undefined)
26
+ continue;
27
+ const epoch = Date.parse(raw);
28
+ if (!Number.isFinite(epoch))
29
+ continue;
30
+ count += 1;
31
+ if (nextAt === undefined || epoch < nextAt)
32
+ nextAt = epoch;
33
+ }
34
+ return nextAt === undefined ? { count: 0 } : { count, nextAt };
35
+ }
36
+ /** 由当前时刻判定 badge 状态。 */
37
+ export function badgeStateFor(nextAt, now) {
38
+ if (nextAt === undefined)
39
+ return 'scheduled';
40
+ const diff = nextAt - now;
41
+ if (diff <= 0)
42
+ return 'overdue';
43
+ if (diff <= PRESENCE_URGENT_WINDOW_MS)
44
+ return 'urgent';
45
+ return 'scheduled';
46
+ }
47
+ function startOfDay(epoch) {
48
+ const d = new Date(epoch);
49
+ d.setHours(0, 0, 0, 0);
50
+ return d.getTime();
51
+ }
52
+ /**
53
+ * 下次触发的相对时间标签。已到期(≤0)返回绝对时钟面(overdue 态文案由
54
+ * 调用方另行处理),≤1min 返回「即将发送」,其余按 分/小时/明天/日期 分档。
55
+ */
56
+ export function relativeFireLabel(nextAt, now, labels, formatHhmm, formatDate) {
57
+ const diff = nextAt - now;
58
+ if (diff <= 0)
59
+ return labels.clock(formatHhmm(nextAt));
60
+ if (diff <= 60_000)
61
+ return labels.imminent;
62
+ if (diff < 3_600_000)
63
+ return labels.minutes(Math.max(1, Math.round(diff / 60_000)));
64
+ const dayDiff = Math.round((startOfDay(nextAt) - startOfDay(now)) / 86_400_000);
65
+ const time = formatHhmm(nextAt);
66
+ if (dayDiff <= 0)
67
+ return labels.hours(Math.max(1, Math.round(diff / 3_600_000)));
68
+ if (dayDiff === 1)
69
+ return labels.tomorrow(time);
70
+ return labels.date(formatDate(nextAt), time);
71
+ }