dsh-later 1.0.0 → 1.2.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/presence.js CHANGED
@@ -14,12 +14,25 @@
14
14
  /** 紧急窗口:下次触发在该窗口内时,badge 从 scheduled 升级为 urgent。 */
15
15
  export const PRESENCE_URGENT_WINDOW_MS = 5 * 60_000;
16
16
  /**
17
- * 归纳:取最早触发时刻 + 可解析数量。`every` 类任务在投影里携带推进后的
18
- * `scheduledAt`(下次触发点),与一次性任务同构参与排序。
17
+ * 归纳:取最早触发时刻 + 任务数量。
18
+ *
19
+ * ⚠️ **暂停项不得参与 `nextAt`**(这是一个真实缺陷的根因)。暂停 = 日志 delete +
20
+ * sidecar 留档;wire 里的 `scheduled_at` 是 `originalScheduledAt`(**原目标时刻**),
21
+ * 而该时刻在暂停时通常已成过去。若照常参与取最小值,侧栏会算出 `nextAt <= now`
22
+ * → 显示「有 N 条定时提醒已到期,等待发送」,但这其实是一条**已冻结、不会触发**的
23
+ * 提醒,用户看到的是自相矛盾的状态(实测复现)。
24
+ *
25
+ * 正确语义:暂停项**计入 `count`**(它确实是待办提醒),但**不参与 `nextAt`**
26
+ * ——它没有排定的触发时刻,只在恢复后才重新拥有。
27
+ *
28
+ * 全部都是暂停项时 `nextAt` 为 undefined:调用方据此显示中性的「已暂停」而非
29
+ * 「已到期」或"下次 X 分后"。
19
30
  */
20
31
  export function summarizeSchedules(schedules) {
21
32
  let nextAt;
33
+ let progressFrom;
22
34
  let count = 0;
35
+ let pausedCount = 0;
23
36
  for (const item of schedules ?? []) {
24
37
  const raw = item.scheduled_at ?? item.scheduledAt;
25
38
  if (raw === undefined)
@@ -28,10 +41,85 @@ export function summarizeSchedules(schedules) {
28
41
  if (!Number.isFinite(epoch))
29
42
  continue;
30
43
  count += 1;
31
- if (nextAt === undefined || epoch < nextAt)
44
+ if (item.status === 'paused') {
45
+ pausedCount += 1;
46
+ continue; // 冻结项没有排定的触发时刻,不参与 nextAt
47
+ }
48
+ if (nextAt === undefined || epoch < nextAt) {
32
49
  nextAt = epoch;
50
+ // 窗口起点只跟随被选中的那一条;并列最早时保留先出现者的窗口。
51
+ progressFrom = windowStartOf(item, epoch);
52
+ }
53
+ }
54
+ if (count === 0)
55
+ return { count: 0, pausedCount: 0 };
56
+ // 字段用条件展开:无进度时不产生 `progressFrom: undefined`,保持既有
57
+ // deepEqual 测试与 wire 形状不变。
58
+ return nextAt === undefined
59
+ ? { count, pausedCount }
60
+ : { count, nextAt, pausedCount, ...(progressFrom !== undefined ? { progressFrom } : {}) };
61
+ }
62
+ /**
63
+ * 推导进度窗口的起点时刻(epoch ms);无从推导时返回 `undefined`。
64
+ *
65
+ * 口径按优先级:
66
+ * 1. `window_seconds` —— 宿主/投影直接给的总窗口,最权威(暂停项的 wire 正是
67
+ * 用它携带"原 after 间隔",见 projection.ts 的 `pausedWireItemOf`)。
68
+ * 2. 按 `kind` 推:`after` 用 `after_seconds`,`every` 用 `every_seconds`。
69
+ * 两者都是"从触发时刻往回推一整个窗口",天然得到本周期起点。
70
+ * 3. `created_at` —— `at` 类型没有周期,只能拿任务创建时刻当起点。
71
+ *
72
+ * **推不出就返回 `undefined`**,绝不拿 `scheduledAt` 兜底:那会让 span 为 0,
73
+ * 进度恒为 100%,把"算不出来"伪装成"已经走完"。
74
+ */
75
+ function windowStartOf(item, scheduledEpoch) {
76
+ const explicit = toFiniteSeconds(item.window_seconds ?? item.windowSeconds);
77
+ if (explicit !== undefined)
78
+ return scheduledEpoch - explicit * 1000;
79
+ const afterSec = toFiniteSeconds(item.after_seconds ?? item.afterSeconds);
80
+ const everySec = toFiniteSeconds(item.every_seconds ?? item.everySeconds);
81
+ const span = item.kind === 'every' ? everySec : (afterSec ?? everySec);
82
+ if (span !== undefined)
83
+ return scheduledEpoch - span * 1000;
84
+ const created = toEpochMs(item.created_at) ?? toEpochMs(item.createdAt);
85
+ if (created !== null)
86
+ return created;
87
+ return undefined;
88
+ }
89
+ /** 秒数归一:只接受有限正数(0 / 负数 / NaN 一律视为"没给")。 */
90
+ function toFiniteSeconds(value) {
91
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
92
+ }
93
+ /** epoch ms(number)或 ISO 字符串统一成 epoch ms;无法解析返回 `null`。 */
94
+ function toEpochMs(value) {
95
+ if (typeof value === 'number')
96
+ return Number.isFinite(value) ? value : null;
97
+ if (typeof value === 'string' && value.length > 0) {
98
+ const parsed = Date.parse(value);
99
+ return Number.isFinite(parsed) ? parsed : null;
33
100
  }
34
- return nextAt === undefined ? { count: 0 } : { count, nextAt };
101
+ return null;
102
+ }
103
+ /**
104
+ * 进度环比例(0–1):`now` 在 `[progressFrom, nextAt]` 中的位置。
105
+ *
106
+ * 返回 `undefined` 表示**无从算起**(缺窗口起点,或窗口已退化),调用方此时应
107
+ * 退回「恒定整环」的旧表现,而不是画一个 0% 的空环 —— 后者是错误信息:
108
+ * 0% 是"刚开始",`undefined` 是"算不出来"。
109
+ *
110
+ * 两端夹住:`overdue` 时 `now > nextAt`,环停在满环而不是绕出去。
111
+ */
112
+ export function progressOf(entry, now) {
113
+ const { nextAt, progressFrom } = entry;
114
+ if (nextAt === undefined || progressFrom === undefined)
115
+ return undefined;
116
+ const span = nextAt - progressFrom;
117
+ if (!(span > 0))
118
+ return undefined;
119
+ const ratio = (now - progressFrom) / span;
120
+ if (!Number.isFinite(ratio))
121
+ return undefined;
122
+ return ratio <= 0 ? 0 : ratio >= 1 ? 1 : ratio;
35
123
  }
36
124
  /** 由当前时刻判定 badge 状态。 */
37
125
  export function badgeStateFor(nextAt, now) {
@@ -44,6 +132,36 @@ export function badgeStateFor(nextAt, now) {
44
132
  return 'urgent';
45
133
  return 'scheduled';
46
134
  }
135
+ /**
136
+ * 悬浮卡片状态行的完整文案(与宿主 `.hoverStatus` 行同构:一个状态点 + 一句
137
+ * 描述)。宿主的状态链对定时任务无感知,本插件**只追加**这样一行,不改写也
138
+ * 不替换宿主已有的 `空闲` / `进行中` 等行,因此既有状态优先级天然不变。
139
+ *
140
+ * @param entry - 该会话的定时概要(调用方保证 count > 0)。
141
+ * @param now - 当前时刻(epoch ms),用于判定 overdue 与相对时间。
142
+ * @param labels - 相对时间文案槽位。
143
+ * @param strings - 当前语言的字典。
144
+ * @param formatHhmm - 时钟面格式化(注入以便单测)。
145
+ * @param formatDate - 日期面格式化(注入以便单测)。
146
+ * @returns 状态文案 + 视觉状态(`done` 为中性信息态,与宿主同款状态点)。
147
+ */
148
+ export function hoverScheduleStatus(entry, now, labels, strings, formatHhmm, formatDate) {
149
+ const tone = badgeStateFor(entry.nextAt, now);
150
+ // 全部暂停:没有排定时刻 → 既不是「下次 X」也不是「已到期」(暂停项不会自行触发)。
151
+ if (entry.nextAt === undefined && entry.pausedCount > 0) {
152
+ const paused = strings.hoverSchedulePaused ?? strings.hoverScheduleOverdue;
153
+ return { label: formatTemplate(paused, { n: entry.count }), tone };
154
+ }
155
+ if (tone === 'overdue' || entry.nextAt === undefined) {
156
+ return { label: formatTemplate(strings.hoverScheduleOverdue, { n: entry.count }), tone };
157
+ }
158
+ const time = relativeFireLabel(entry.nextAt, now, labels, formatHhmm, formatDate);
159
+ return { label: formatTemplate(strings.hoverScheduleStatus, { n: entry.count, time }), tone };
160
+ }
161
+ /** `{name}` 占位符替换(与 client/strings 的 format 同语义;此处零依赖)。 */
162
+ function formatTemplate(template, params) {
163
+ return template.replace(/\{(\w+)\}/g, (match, key) => key in params ? String(params[key]) : match);
164
+ }
47
165
  function startOfDay(epoch) {
48
166
  const d = new Date(epoch);
49
167
  d.setHours(0, 0, 0, 0);
package/lib/projection.js CHANGED
@@ -16,7 +16,7 @@
16
16
  import { z } from 'zod';
17
17
  import { decodeScheduleChange, resolveEveryOccurrence } from '@deepseek-ai/dsh-schedule';
18
18
  import { OWNED_EVENT, PROJECTION_KEY } from './domain.js';
19
- import { allOwnership, hasOwnership } from './ownership-store.js';
19
+ import { allOwnership, allPaused, findPausedByScheduleId, getOwnership, hasOwnership, } from './ownership-store.js';
20
20
  /** 单条 wire 项 schema。 */
21
21
  const wireItemSchema = z
22
22
  .object({
@@ -28,6 +28,11 @@ const wireItemSchema = z
28
28
  scheduled_at: z.string(),
29
29
  created_at: z.string().optional(),
30
30
  delivery_mode: z.literal('session-local'),
31
+ status: z.union([z.literal('active'), z.literal('paused')]).optional(),
32
+ remaining_seconds: z.number().optional(),
33
+ paused_at: z.string().optional(),
34
+ schedule_id: z.string().optional(),
35
+ window_seconds: z.number().optional(),
31
36
  })
32
37
  .strict();
33
38
  /** 投影 wire 值 schema(wire.viewSchema:离开宿主前校验客户端载荷)。 */
@@ -53,6 +58,17 @@ const scheduleRecordStateSchema = z
53
58
  createdAt: z.number().optional(),
54
59
  })
55
60
  .passthrough();
61
+ const pausedStateSchema = z.object({
62
+ uid: z.string(),
63
+ prompt: z.string(),
64
+ delivery: z.union([z.literal('context'), z.literal('user')]),
65
+ kind: z.literal('after'),
66
+ remainingSeconds: z.number(),
67
+ originalScheduledAt: z.string(),
68
+ originalAfterSeconds: z.number().optional(),
69
+ lastScheduleId: z.string(),
70
+ pausedAt: z.number(),
71
+ });
56
72
  export const userSchedulesStateSchema = z.object({
57
73
  /** 会话 id(init 由 header 注入;apply 据此查询所有权 sidecar 缓存)。 */
58
74
  sessionId: z.string(),
@@ -62,24 +78,73 @@ export const userSchedulesStateSchema = z.object({
62
78
  active: z.array(scheduleRecordStateSchema),
63
79
  /** 已见 `session/end-seed` 的 seq;更早的继承前缀一律忽略。 */
64
80
  seedSeq: z.number(),
81
+ /** sidecar 暂停留档镜像(v4)。 */
82
+ paused: z.array(pausedStateSchema).default([]),
65
83
  });
84
+ function pausedFromSidecar(sessionId) {
85
+ if (sessionId.length === 0)
86
+ return [];
87
+ return Object.values(allPaused(sessionId));
88
+ }
66
89
  /** 初始状态:按会话 id 从所有权 sidecar 装载(进程重启后恢复 GUI 归属)。 */
67
90
  export function initUserScheduleProjection(header) {
68
91
  const sessionId = typeof header?.id === 'string' && header.id.length > 0 ? header.id : '';
69
92
  const entries = sessionId.length > 0 ? allOwnership(sessionId) : {};
70
- return { sessionId, owned: Object.keys(entries), active: [], seedSeq: -1 };
93
+ return {
94
+ sessionId,
95
+ owned: Object.keys(entries),
96
+ active: [],
97
+ seedSeq: -1,
98
+ paused: pausedFromSidecar(sessionId),
99
+ };
71
100
  }
72
- /** 序列化一条活动记录为 wire 项(不含随墙钟变化的状态)。 */
73
- function wireItemOf(record) {
101
+ /** 序列化一条活动记录为 wire 项(id 优先稳定 uid)。 */
102
+ function wireItemOf(record, sessionId) {
103
+ const ownership = sessionId.length > 0 ? getOwnership(sessionId, record.id) : undefined;
104
+ const uid = ownership?.uid;
105
+ const windowSeconds = ownership?.windowSeconds;
106
+ // 0.1.7 的 `ScheduleRecord` 联合含 daily/weekly/cron,本插件的 wire 契约
107
+ // (`UserScheduleWireItem.kind`)仍只认 after/at/every —— 这三个变体只可能
108
+ // 来自其他插件写入的日志。归一为 `every`(与编辑路径的 300s 默认一致),
109
+ // 避免把未知 kind 泄漏到客户端。
110
+ //
111
+ // 字段读取按 `record.kind` 而非归一后的 `kind`:只有前者能让 TS 对判别式
112
+ // 联合收窄,从而确认 `afterSeconds` / `everySeconds` 存在。
113
+ const kind = record.kind === 'after' || record.kind === 'at' ? record.kind : 'every';
114
+ const everySeconds = record.kind === 'every' && typeof record.everySeconds === 'number'
115
+ ? record.everySeconds
116
+ : undefined;
74
117
  return {
75
- id: record.id,
76
- kind: record.kind,
118
+ id: uid ?? record.id,
119
+ kind,
77
120
  prompt: record.prompt,
78
121
  ...(record.kind === 'after' ? { after_seconds: record.afterSeconds } : {}),
79
- ...(record.kind === 'every' ? { every_seconds: record.everySeconds } : {}),
122
+ ...(everySeconds !== undefined ? { every_seconds: everySeconds } : {}),
80
123
  ...(typeof record.createdAt === 'number' ? { created_at: new Date(record.createdAt).toISOString() } : {}),
124
+ ...(record.kind === 'after' && typeof windowSeconds === 'number'
125
+ ? { window_seconds: windowSeconds }
126
+ : {}),
81
127
  scheduled_at: record.scheduledAt,
82
128
  delivery_mode: 'session-local',
129
+ status: 'active',
130
+ schedule_id: record.id,
131
+ };
132
+ }
133
+ function pausedWireItemOf(entry) {
134
+ const windowSec = entry.originalAfterSeconds ?? entry.remainingSeconds;
135
+ return {
136
+ id: entry.uid,
137
+ kind: 'after',
138
+ prompt: entry.prompt,
139
+ // 进度条总窗口用原 after 间隔;remaining 单独给冻结剩余
140
+ after_seconds: windowSec,
141
+ window_seconds: windowSec,
142
+ scheduled_at: entry.originalScheduledAt,
143
+ delivery_mode: 'session-local',
144
+ status: 'paused',
145
+ remaining_seconds: entry.remainingSeconds,
146
+ paused_at: new Date(entry.pausedAt).toISOString(),
147
+ schedule_id: entry.lastScheduleId,
83
148
  };
84
149
  }
85
150
  /** 对 `every` dispatch 推进 scheduledAt(与 dsh-schedule dispatchedRecord 一致)。 */
@@ -97,14 +162,24 @@ function advanceEvery(record, acceptedAt) {
97
162
  export function applyUserScheduleProjection(state, event) {
98
163
  // 派生会话边界:重置自身,忽略继承前缀(AC-07 fork 隔离)。
99
164
  if (event.type === 'session/end-seed') {
100
- if (state.seedSeq === event.seq && state.owned.length === 0 && state.active.length === 0)
165
+ if (state.seedSeq === event.seq &&
166
+ state.owned.length === 0 &&
167
+ state.active.length === 0 &&
168
+ state.paused.length === 0) {
101
169
  return state;
170
+ }
102
171
  // 派生会话(fork):sidecar 以 sessionId 隔离,派生 id 天然空表;此处再清一次
103
172
  // 兜底「同 id 重播种」场景,保持 AC-07 语义。
104
173
  const owned = state.sessionId.length > 0
105
174
  ? Object.keys(allOwnership(state.sessionId))
106
175
  : [];
107
- return { sessionId: state.sessionId, owned, active: [], seedSeq: event.seq };
176
+ return {
177
+ sessionId: state.sessionId,
178
+ owned,
179
+ active: [],
180
+ seedSeq: event.seq,
181
+ paused: pausedFromSidecar(state.sessionId),
182
+ };
108
183
  }
109
184
  if (seedIsBefore(state, event))
110
185
  return state;
@@ -115,7 +190,13 @@ export function applyUserScheduleProjection(state, event) {
115
190
  if (data.operation === 'add') {
116
191
  if (state.owned.includes(data.id))
117
192
  return state;
118
- return { sessionId: state.sessionId, owned: [...state.owned, data.id], active: state.active, seedSeq: state.seedSeq };
193
+ return {
194
+ sessionId: state.sessionId,
195
+ owned: [...state.owned, data.id],
196
+ active: state.active,
197
+ seedSeq: state.seedSeq,
198
+ paused: state.paused,
199
+ };
119
200
  }
120
201
  if (data.operation === 'remove') {
121
202
  if (!state.owned.includes(data.id))
@@ -125,6 +206,7 @@ export function applyUserScheduleProjection(state, event) {
125
206
  owned: state.owned.filter((id) => id !== data.id),
126
207
  active: state.active,
127
208
  seedSeq: state.seedSeq,
209
+ paused: state.paused,
128
210
  };
129
211
  }
130
212
  return state;
@@ -146,10 +228,14 @@ export function applyUserScheduleProjection(state, event) {
146
228
  if (!owned.includes(record.id) && state.sessionId.length > 0 && hasOwnership(state.sessionId, record.id)) {
147
229
  owned = [...owned, record.id];
148
230
  }
231
+ // resume:同 uid 的暂停项应在 create 后从 paused 列表消失
232
+ const paused = state.sessionId.length > 0
233
+ ? pausedFromSidecar(state.sessionId)
234
+ : state.paused;
149
235
  // 固化创建时刻(事件 time),供客户端进度条按真实起点计算
150
236
  const withCreated = typeof event.time === 'number' ? { ...record, createdAt: event.time } : record;
151
237
  const existing = state.active.find((entry) => entry.id === record.id);
152
- if (existing === withCreated && owned === state.owned)
238
+ if (existing === withCreated && owned === state.owned && paused === state.paused)
153
239
  return state;
154
240
  return {
155
241
  sessionId: state.sessionId,
@@ -158,18 +244,39 @@ export function applyUserScheduleProjection(state, event) {
158
244
  ? [...state.active, withCreated]
159
245
  : state.active.map((entry) => (entry.id === record.id ? withCreated : entry)),
160
246
  seedSeq: state.seedSeq,
247
+ paused,
161
248
  };
162
249
  }
163
250
  case 'delete': {
164
- if (!state.active.some((entry) => entry.id === change.id) && !state.owned.includes(change.id))
251
+ if (!state.active.some((entry) => entry.id === change.id) && !state.owned.includes(change.id)) {
252
+ // 仍可能要把 sidecar 暂停项挂进视图(pause 刚写完留档)
253
+ if (state.sessionId.length > 0) {
254
+ const paused = pausedFromSidecar(state.sessionId);
255
+ const linked = findPausedByScheduleId(state.sessionId, change.id) !== undefined;
256
+ if (linked && paused !== state.paused) {
257
+ return {
258
+ sessionId: state.sessionId,
259
+ owned: state.owned.filter((id) => id !== change.id),
260
+ active: state.active.filter((entry) => entry.id !== change.id),
261
+ seedSeq: state.seedSeq,
262
+ paused,
263
+ };
264
+ }
265
+ }
165
266
  return state;
267
+ }
166
268
  // 删除即撤销所有权声明(sidecar 由工具路径在 flush 后清理;此处先清视图)。
167
269
  const owned = state.owned.filter((id) => id !== change.id);
270
+ const active = state.active.filter((entry) => entry.id !== change.id);
271
+ const paused = state.sessionId.length > 0 ? pausedFromSidecar(state.sessionId) : state.paused;
272
+ if (owned === state.owned && active === state.active && paused === state.paused)
273
+ return state;
168
274
  return {
169
275
  sessionId: state.sessionId,
170
276
  owned,
171
- active: state.active.filter((entry) => entry.id !== change.id),
277
+ active,
172
278
  seedSeq: state.seedSeq,
279
+ paused,
173
280
  };
174
281
  }
175
282
  case 'dispatch': {
@@ -193,7 +300,13 @@ export function applyUserScheduleProjection(state, event) {
193
300
  }
194
301
  if (nextActive === state.active)
195
302
  return state;
196
- return { sessionId: state.sessionId, owned: state.owned, active: nextActive, seedSeq: state.seedSeq };
303
+ return {
304
+ sessionId: state.sessionId,
305
+ owned: state.owned,
306
+ active: nextActive,
307
+ seedSeq: state.seedSeq,
308
+ paused: state.paused,
309
+ };
197
310
  }
198
311
  /* c8 ignore next 2 -- decodeScheduleChange 是闭包联合 */
199
312
  default:
@@ -205,13 +318,29 @@ export function applyUserScheduleProjection(state, event) {
205
318
  function seedIsBefore(state, event) {
206
319
  return state.seedSeq >= 0 && typeof event.seq === 'number' && event.seq < state.seedSeq;
207
320
  }
208
- /** 投影 view:只暴露用户创建的、当前活动的记录。 */
321
+ /**
322
+ * 投影 view:活动 + 暂停(同列表;paused 靠 status 区分,dock 原地冻结)。
323
+ *
324
+ * ⚠️ 这里是**唯一**能让「删除暂停项」生效的地方。原因链:
325
+ * - 删除暂停项只清 sidecar、**不写会话事件**(补写 `schedule/change` delete 会
326
+ * 因为该 id 在 pause 时已被删除而抛 `targets inactive id`,把日志读坏);
327
+ * - 投影 cell 只在事件到达时由 `apply` 推进,且跨进程持久化(projcache),
328
+ * 客户端又按 seq「更高者胜」消费控制帧——没有新事件就没有新帧;
329
+ * - 于是 `state.paused` 会永久停留在最后一次事件时的快照上。
330
+ * 因此 view 不能直接信任 `state.paused`,必须**以 sidecar 为准重读**:
331
+ * sidecar 是暂停留档的权威存储(`ownship-store`),`state.paused` 只是镜像。
332
+ * 这样无论 cell 多陈旧,读出来的 paused 列表都与真实留档一致。
333
+ */
209
334
  export function viewUserScheduleProjection(state) {
210
- const schedules = state.active
335
+ const active = state.active
211
336
  .filter((record) => state.owned.includes(record.id))
212
337
  .sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt))
213
- .map(wireItemOf);
214
- return { schedules };
338
+ .map((record) => wireItemOf(record, state.sessionId));
339
+ // sidecar 权威:readFresh 带缓存但会按文件 mtime/内容失效,读到的是最新留档。
340
+ // sessionId 缺失(init 未注入)时退回镜像,保持旧行为。
341
+ const live = state.sessionId.length > 0 ? pausedFromSidecar(state.sessionId) : state.paused;
342
+ const paused = live.map(pausedWireItemOf);
343
+ return { schedules: [...active, ...paused] };
215
344
  }
216
345
  /**
217
346
  * 投影单元声明(传给 sessionProjections.register)。
@@ -231,6 +360,6 @@ export const userSchedulesProjectionUnit = {
231
360
  viewSchema: userSchedulesSchema,
232
361
  view: viewUserScheduleProjection,
233
362
  },
234
- // v3:状态新增 sessionId + 所有权改走 sidecar(init 装载);旧缓存行按版本丢弃
235
- stateVersion: 3,
363
+ // v3:sessionId + 所有权 sidecar;v4:paused[] 镜像(暂停/恢复)
364
+ stateVersion: 4,
236
365
  };