dsh-later 1.0.0 → 1.1.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/CHANGELOG.md +204 -0
- package/README.en.md +9 -2
- package/README.md +9 -2
- package/icon.svg +10 -0
- package/lib/bar-geometry.js +139 -0
- package/lib/client.js +1088 -386
- package/lib/command-outcome.js +15 -0
- package/lib/commands.js +56 -8
- package/lib/delivery.js +66 -0
- package/lib/domain.js +6 -0
- package/lib/index.js +152 -29
- package/lib/ownership-store.js +113 -15
- package/lib/presence.js +51 -3
- package/lib/projection.js +149 -20
- package/lib/runtime.js +160 -24
- package/lib/schedule-compat.js +179 -0
- package/lib/types/bar-geometry.d.ts +111 -0
- package/lib/types/command-outcome.d.ts +19 -0
- package/lib/types/delivery.d.ts +51 -0
- package/lib/types/domain.d.ts +32 -0
- package/lib/types/index.d.ts +28 -8
- package/lib/types/ownership-store.d.ts +42 -2
- package/lib/types/presence.d.ts +43 -4
- package/lib/types/projection.d.ts +68 -6
- package/lib/types/runtime.d.ts +58 -1
- package/lib/types/schedule-compat.d.ts +111 -0
- package/lib/types/upstream-compat.d.ts +123 -0
- package/lib/types/user-tools.d.ts +48 -7
- package/lib/upstream-compat.js +143 -0
- package/lib/user-tools.js +268 -44
- package/package.json +25 -22
package/lib/presence.js
CHANGED
|
@@ -14,12 +14,24 @@
|
|
|
14
14
|
/** 紧急窗口:下次触发在该窗口内时,badge 从 scheduled 升级为 urgent。 */
|
|
15
15
|
export const PRESENCE_URGENT_WINDOW_MS = 5 * 60_000;
|
|
16
16
|
/**
|
|
17
|
-
* 归纳:取最早触发时刻 +
|
|
18
|
-
*
|
|
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;
|
|
22
33
|
let count = 0;
|
|
34
|
+
let pausedCount = 0;
|
|
23
35
|
for (const item of schedules ?? []) {
|
|
24
36
|
const raw = item.scheduled_at ?? item.scheduledAt;
|
|
25
37
|
if (raw === undefined)
|
|
@@ -28,10 +40,16 @@ export function summarizeSchedules(schedules) {
|
|
|
28
40
|
if (!Number.isFinite(epoch))
|
|
29
41
|
continue;
|
|
30
42
|
count += 1;
|
|
43
|
+
if (item.status === 'paused') {
|
|
44
|
+
pausedCount += 1;
|
|
45
|
+
continue; // 冻结项没有排定的触发时刻,不参与 nextAt
|
|
46
|
+
}
|
|
31
47
|
if (nextAt === undefined || epoch < nextAt)
|
|
32
48
|
nextAt = epoch;
|
|
33
49
|
}
|
|
34
|
-
|
|
50
|
+
if (count === 0)
|
|
51
|
+
return { count: 0, pausedCount: 0 };
|
|
52
|
+
return nextAt === undefined ? { count, pausedCount } : { count, nextAt, pausedCount };
|
|
35
53
|
}
|
|
36
54
|
/** 由当前时刻判定 badge 状态。 */
|
|
37
55
|
export function badgeStateFor(nextAt, now) {
|
|
@@ -44,6 +62,36 @@ export function badgeStateFor(nextAt, now) {
|
|
|
44
62
|
return 'urgent';
|
|
45
63
|
return 'scheduled';
|
|
46
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* 悬浮卡片状态行的完整文案(与宿主 `.hoverStatus` 行同构:一个状态点 + 一句
|
|
67
|
+
* 描述)。宿主的状态链对定时任务无感知,本插件**只追加**这样一行,不改写也
|
|
68
|
+
* 不替换宿主已有的 `空闲` / `进行中` 等行,因此既有状态优先级天然不变。
|
|
69
|
+
*
|
|
70
|
+
* @param entry - 该会话的定时概要(调用方保证 count > 0)。
|
|
71
|
+
* @param now - 当前时刻(epoch ms),用于判定 overdue 与相对时间。
|
|
72
|
+
* @param labels - 相对时间文案槽位。
|
|
73
|
+
* @param strings - 当前语言的字典。
|
|
74
|
+
* @param formatHhmm - 时钟面格式化(注入以便单测)。
|
|
75
|
+
* @param formatDate - 日期面格式化(注入以便单测)。
|
|
76
|
+
* @returns 状态文案 + 视觉状态(`done` 为中性信息态,与宿主同款状态点)。
|
|
77
|
+
*/
|
|
78
|
+
export function hoverScheduleStatus(entry, now, labels, strings, formatHhmm, formatDate) {
|
|
79
|
+
const tone = badgeStateFor(entry.nextAt, now);
|
|
80
|
+
// 全部暂停:没有排定时刻 → 既不是「下次 X」也不是「已到期」(暂停项不会自行触发)。
|
|
81
|
+
if (entry.nextAt === undefined && entry.pausedCount > 0) {
|
|
82
|
+
const paused = strings.hoverSchedulePaused ?? strings.hoverScheduleOverdue;
|
|
83
|
+
return { label: formatTemplate(paused, { n: entry.count }), tone };
|
|
84
|
+
}
|
|
85
|
+
if (tone === 'overdue' || entry.nextAt === undefined) {
|
|
86
|
+
return { label: formatTemplate(strings.hoverScheduleOverdue, { n: entry.count }), tone };
|
|
87
|
+
}
|
|
88
|
+
const time = relativeFireLabel(entry.nextAt, now, labels, formatHhmm, formatDate);
|
|
89
|
+
return { label: formatTemplate(strings.hoverScheduleStatus, { n: entry.count, time }), tone };
|
|
90
|
+
}
|
|
91
|
+
/** `{name}` 占位符替换(与 client/strings 的 format 同语义;此处零依赖)。 */
|
|
92
|
+
function formatTemplate(template, params) {
|
|
93
|
+
return template.replace(/\{(\w+)\}/g, (match, key) => key in params ? String(params[key]) : match);
|
|
94
|
+
}
|
|
47
95
|
function startOfDay(epoch) {
|
|
48
96
|
const d = new Date(epoch);
|
|
49
97
|
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 {
|
|
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
|
|
118
|
+
id: uid ?? record.id,
|
|
119
|
+
kind,
|
|
77
120
|
prompt: record.prompt,
|
|
78
121
|
...(record.kind === 'after' ? { after_seconds: record.afterSeconds } : {}),
|
|
79
|
-
...(
|
|
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 &&
|
|
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 {
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
235
|
-
stateVersion:
|
|
363
|
+
// v3:sessionId + 所有权 sidecar;v4:paused[] 镜像(暂停/恢复)
|
|
364
|
+
stateVersion: 4,
|
|
236
365
|
};
|
package/lib/runtime.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createUserMessage, boundContextSummary } from '@deepseek-ai/dsh-llm';
|
|
2
|
-
import { foldScheduleEvents,
|
|
2
|
+
import { foldScheduleEvents, renderReminderFraming, resolveEveryOccurrence, ScheduleId, } from '@deepseek-ai/dsh-schedule';
|
|
3
|
+
import { renderRecurringBatchFraming as renderEveryReminderBatchFraming, scheduleMessageSource, } from './schedule-compat.js';
|
|
3
4
|
import { OWNED_EVENT } from './domain.js';
|
|
5
|
+
import { findScheduleIdByUid, getPaused, removePaused } from './ownership-store.js';
|
|
4
6
|
import { foldUserState } from './user-tools.js';
|
|
7
|
+
import { confirmDelivery, messageIdOf } from './delivery.js';
|
|
5
8
|
import { detectTimeZone, formatHhmm } from './time-utils.js';
|
|
6
9
|
/** Node timers 能表示的最大延迟。 */
|
|
7
10
|
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
@@ -54,8 +57,15 @@ export function dueUserDecision(active, now, delivery) {
|
|
|
54
57
|
* 多条到期一次性提醒的合并注入 framing(P1-5)。
|
|
55
58
|
* 形状与 dsh-schedule 的固定间隔批次 framing 完全一致:
|
|
56
59
|
* 动态字段全部经 JSON 转义,保持「非信任提醒内容」的防护语义。
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ 这是**上游文案的本地副本**。0.2.0 起 dsh-schedule 把 one-shot 与 recurring
|
|
62
|
+
* 两条 framing 统一收敛为一句 `SCHEDULED_MESSAGE_FRAMING`,取代了此前
|
|
63
|
+
* 「Present all due reminders … not new user instructions.」的长句。
|
|
64
|
+
* 本插件只对**多条合并**这一种上游不提供的形态自渲染(单条走
|
|
65
|
+
* `renderReminderFraming`,自动跟随上游),故此处需手工对齐,否则同一会话里
|
|
66
|
+
* 单条与批次的防护措辞会不一致。
|
|
57
67
|
*/
|
|
58
|
-
function renderOneShotBatchFraming(records) {
|
|
68
|
+
export function renderOneShotBatchFraming(records) {
|
|
59
69
|
const payload = records.map((record) => ({
|
|
60
70
|
schedule_id: record.id,
|
|
61
71
|
occurrence_at: record.scheduledAt,
|
|
@@ -63,7 +73,7 @@ function renderOneShotBatchFraming(records) {
|
|
|
63
73
|
}));
|
|
64
74
|
return [
|
|
65
75
|
'[SCHEDULE REMINDER BATCH]',
|
|
66
|
-
'
|
|
76
|
+
'This is a scheduled message from the user',
|
|
67
77
|
`reminders_json: ${JSON.stringify(payload)}`,
|
|
68
78
|
].join('\n');
|
|
69
79
|
}
|
|
@@ -71,6 +81,14 @@ function renderOneShotBatchFraming(records) {
|
|
|
71
81
|
export class UserScheduleRuntime {
|
|
72
82
|
ctx;
|
|
73
83
|
agent;
|
|
84
|
+
/**
|
|
85
|
+
* 读取当前生效设置(每次调用按需取值,与工具/命令同源)。
|
|
86
|
+
*
|
|
87
|
+
* 注入而非直接读 ctx.settings:settings 的注册在 index 的 `ctx.inject(['settings'])`
|
|
88
|
+
* 回调里完成,runtime 拿不到那个 scope;把它做成闭包注入,保证「设置 UI 改了立刻生效」
|
|
89
|
+
* 这一既有语义(`applies: 'live'`)在插话路径上同样成立。
|
|
90
|
+
*/
|
|
91
|
+
getSettings;
|
|
74
92
|
stop;
|
|
75
93
|
resolveStop;
|
|
76
94
|
timer;
|
|
@@ -81,9 +99,10 @@ export class UserScheduleRuntime {
|
|
|
81
99
|
failures = 0;
|
|
82
100
|
idleWait;
|
|
83
101
|
disposal;
|
|
84
|
-
constructor(ctx, agent) {
|
|
102
|
+
constructor(ctx, agent, getSettings) {
|
|
85
103
|
this.ctx = ctx;
|
|
86
104
|
this.agent = agent;
|
|
105
|
+
this.getSettings = getSettings;
|
|
87
106
|
this.stop = new Promise((resolve) => {
|
|
88
107
|
this.resolveStop = resolve;
|
|
89
108
|
});
|
|
@@ -200,7 +219,39 @@ export class UserScheduleRuntime {
|
|
|
200
219
|
if (this.stopping || this.disposal !== undefined) {
|
|
201
220
|
return { ok: false, code: 'stopping', message: '调度器已停止。' };
|
|
202
221
|
}
|
|
203
|
-
|
|
222
|
+
/**
|
|
223
|
+
* 暂停项的插话:直接从 sidecar 留档取材,不经过日志。
|
|
224
|
+
*
|
|
225
|
+
* 暂停 = 日志 `delete` + sidecar 留档,所以暂停项**不在日志里**,下面的
|
|
226
|
+
* `folded.active` 必然找不到它。但"插话"的语义是「不等倒计时、立刻推送」,
|
|
227
|
+
* 对暂停项恰恰是最需要的(暂停了但想马上发)。留档里已有 prompt 与 delivery,
|
|
228
|
+
* 足够构造消息。
|
|
229
|
+
*
|
|
230
|
+
* 投递成功后**清掉留档**:该提醒已经送达,不应再留在"待发送"列表里
|
|
231
|
+
* (与 dispatch 会把记录移出 active 的语义一致)。
|
|
232
|
+
*/
|
|
233
|
+
const pausedHit = await this.steerPausedById(id);
|
|
234
|
+
if (pausedHit !== undefined)
|
|
235
|
+
return pausedHit;
|
|
236
|
+
/**
|
|
237
|
+
* wire id → 日志 schedule id。
|
|
238
|
+
*
|
|
239
|
+
* ⚠️ GUI 传过来的是**投影里的 `id`**,而那是「稳定 uid 优先」的
|
|
240
|
+
* (见 projection 的 `wireItemOf`:`id: uid ?? record.id`)。而日志的
|
|
241
|
+
* `schedule/change` 记录用 `schedule-N` 作 id。两者不同,必须换算。
|
|
242
|
+
*
|
|
243
|
+
* 此前直接 `ScheduleId(id)` 并 `r.id === scheduleId` 比对,于是**所有**由
|
|
244
|
+
* 用户工具创建的活动任务插话都报 `schedule_not_found`(实测复现:
|
|
245
|
+
* uid `f0fc0c90…` vs 日志 id `schedule-1`)。删除/暂停路径早已用
|
|
246
|
+
* `resolveActiveScheduleId` 做了这层换算,插话是漏网的。
|
|
247
|
+
*/
|
|
248
|
+
const sessionId = this.agent.session.header?.id;
|
|
249
|
+
const scheduleId = (() => {
|
|
250
|
+
// 先按 uid 找当前活动的日志 id;找不到再当作日志 id 原样使用
|
|
251
|
+
//(无 uid 的旧数据 / 调用方直接传 schedule-N)。
|
|
252
|
+
const byUid = sessionId === undefined ? undefined : findScheduleIdByUid(sessionId, id);
|
|
253
|
+
return ScheduleId(byUid ?? id);
|
|
254
|
+
})();
|
|
204
255
|
let claimed = null;
|
|
205
256
|
try {
|
|
206
257
|
const claimedState = foldUserState(this.agent.session);
|
|
@@ -209,7 +260,7 @@ export class UserScheduleRuntime {
|
|
|
209
260
|
if (record === undefined) {
|
|
210
261
|
return { ok: false, code: 'schedule_not_found', message: '指定的提醒不存在或已派发。' };
|
|
211
262
|
}
|
|
212
|
-
if (record.kind
|
|
263
|
+
if (record.kind !== 'after' && record.kind !== 'at') {
|
|
213
264
|
return { ok: false, code: 'unsupported_kind', message: 'every 类提醒的插话暂未支持。' };
|
|
214
265
|
}
|
|
215
266
|
const delivery = claimedState.delivery.get(scheduleId) ?? 'context';
|
|
@@ -219,32 +270,68 @@ export class UserScheduleRuntime {
|
|
|
219
270
|
this.ctx.logger.warn(`later: steer fold 失败: ${renderThrown(error)}`);
|
|
220
271
|
return { ok: false, code: 'internal_error', message: '会话定时日志读取失败。' };
|
|
221
272
|
}
|
|
273
|
+
let message;
|
|
222
274
|
try {
|
|
223
|
-
// 同一同步块内:重 fold(防 auto-driver 刚把 dispatch 写掉的竞态)→
|
|
224
|
-
// append dispatch
|
|
275
|
+
// 同一同步块内:重 fold(防 auto-driver 刚把 dispatch 写掉的竞态)→ 投递 →
|
|
276
|
+
// append dispatch(仅当确认送达 / 或未启用确认 / 或配置为丢弃)。
|
|
225
277
|
const reState = foldUserState(this.agent.session);
|
|
226
278
|
const reRecord = reState.folded.active.find((r) => r.id === scheduleId);
|
|
227
279
|
if (reRecord === undefined || claimed === null) {
|
|
228
280
|
// 已被并发 fold 派发(auto-driver 抢先),端态与插话成功一致,不重复投。
|
|
229
281
|
return { ok: true, id, steered: true };
|
|
230
282
|
}
|
|
231
|
-
|
|
232
|
-
this.
|
|
233
|
-
this.agent.session.append('schedule/change', { version: 1, operation: 'dispatch', id: scheduleId });
|
|
283
|
+
message = this.buildMessage(claimed, reState.delivery);
|
|
284
|
+
this.deliver(message);
|
|
234
285
|
}
|
|
235
286
|
catch (error) {
|
|
236
287
|
this.ctx.logger.warn(`later: steer 失败 agent "${this.agent.id}": ${renderThrown(error)}`);
|
|
237
288
|
return { ok: false, code: 'internal_error', message: '插话执行失败。' };
|
|
238
289
|
}
|
|
290
|
+
// 送达确认:默认开启。未确认时按 onUnconfirmed 决定是否仍写出列标记。
|
|
291
|
+
const confirmed = await this.verifyDelivery(message);
|
|
292
|
+
if (!confirmed && this.getSettings().onUnconfirmed === 'keep') {
|
|
293
|
+
// 不写 dispatch:条目留在 active,用户可重试。这正是「提醒消失但消息没到」
|
|
294
|
+
// 的根治点 —— 旧实现在 steer() 后**无条件**写 dispatch,投递失败即永久丢失。
|
|
295
|
+
return { ok: false, code: 'delivery_unconfirmed', message: '投递未确认,提醒已保留,可重试。' };
|
|
296
|
+
}
|
|
297
|
+
this.agent.session.append('schedule/change', { version: 1, operation: 'dispatch', id: scheduleId });
|
|
239
298
|
// 同步落盘,确保 fold 路径下次扫描看到 dispatch 标记。
|
|
240
299
|
try {
|
|
241
300
|
await this.ctx.sessions.flush(this.agent.session);
|
|
242
301
|
}
|
|
243
302
|
catch {
|
|
244
|
-
/*
|
|
303
|
+
/* 落盘失败不影响投递已确认的语义;下次 fold 仍可能重触发,作为最坏情况接受 */
|
|
245
304
|
}
|
|
246
305
|
return { ok: true, id, steered: true };
|
|
247
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* 按配置把消息投进队列。
|
|
309
|
+
*
|
|
310
|
+
* `followup`(默认)入 `next-turn`:与 `next-step` 是两条独立队列,
|
|
311
|
+
* `claim('next-step', …)` 不触碰它,**结构上不存在**与 `turn/start` 竞争的窗口。
|
|
312
|
+
* `steer` 入 `next-step`,更快但会在撞上回合启动时被吞(见 delivery.ts 注释)。
|
|
313
|
+
*/
|
|
314
|
+
deliver(message) {
|
|
315
|
+
if (this.getSettings().steerDelivery === 'steer') {
|
|
316
|
+
this.agent.steer(message);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
this.agent.followup(message);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* 核对投递是否真正进入会话日志。
|
|
324
|
+
*
|
|
325
|
+
* 关闭确认时直接返回 true(fire-and-forget,保持旧语义)。
|
|
326
|
+
*/
|
|
327
|
+
async verifyDelivery(message) {
|
|
328
|
+
if (!this.getSettings().confirmDelivery)
|
|
329
|
+
return true;
|
|
330
|
+
const messageId = messageIdOf(message);
|
|
331
|
+
if (messageId.length === 0)
|
|
332
|
+
return false;
|
|
333
|
+
return confirmDelivery(this.agent.session, messageId);
|
|
334
|
+
}
|
|
248
335
|
/** 执行一次用户提醒决策与派发。 */
|
|
249
336
|
async driveOnce() {
|
|
250
337
|
this.clearTimer();
|
|
@@ -344,6 +431,65 @@ export class UserScheduleRuntime {
|
|
|
344
431
|
if (this.isLive())
|
|
345
432
|
this.requestDrive();
|
|
346
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* 暂停项的插话投递。
|
|
436
|
+
*
|
|
437
|
+
* 暂停项不在日志里(其日志记录在 pause 时被删),所以从 sidecar 留档取材:
|
|
438
|
+
* `PausedEntry` 已含 `prompt` 与 `delivery`,足以构造消息。呈现方式与
|
|
439
|
+
* {@link buildMessage} 的 one-shot 分支保持一致(`user` 代发 vs `context` 注入),
|
|
440
|
+
* 避免两条路径的语义分叉。
|
|
441
|
+
*
|
|
442
|
+
* 投递成功后清掉留档:提醒已送达,不应再留在待发送列表。这与 dispatch 会把记录
|
|
443
|
+
* 移出 `active` 语义一致。(清留档不写会话事件,故投影不会自动刷新——客户端已用
|
|
444
|
+
* 本地摘除 + 投影对账处理同类情况,与"删除暂停项"同源。)
|
|
445
|
+
*
|
|
446
|
+
* @returns `undefined` 表示该 id 不是暂停项,交由调用方走常规日志路径。
|
|
447
|
+
*/
|
|
448
|
+
async steerPausedById(id) {
|
|
449
|
+
const sessionId = this.agent.session.header?.id;
|
|
450
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0)
|
|
451
|
+
return undefined;
|
|
452
|
+
const paused = getPaused(sessionId, id);
|
|
453
|
+
if (paused === undefined)
|
|
454
|
+
return undefined;
|
|
455
|
+
const asUser = paused.delivery === 'user';
|
|
456
|
+
const message = asUser
|
|
457
|
+
? createUserMessage({
|
|
458
|
+
content: [{ type: 'text', text: paused.prompt }],
|
|
459
|
+
source: { kind: 'user' },
|
|
460
|
+
})
|
|
461
|
+
: createUserMessage({
|
|
462
|
+
content: [{ type: 'text', text: renderReminderFraming({
|
|
463
|
+
id: paused.lastScheduleId,
|
|
464
|
+
kind: 'after',
|
|
465
|
+
prompt: paused.prompt,
|
|
466
|
+
afterSeconds: paused.originalAfterSeconds ?? paused.remainingSeconds,
|
|
467
|
+
scheduledAt: paused.originalScheduledAt,
|
|
468
|
+
}) }],
|
|
469
|
+
source: scheduleMessageSource(boundContextSummary(paused.prompt)),
|
|
470
|
+
});
|
|
471
|
+
try {
|
|
472
|
+
this.deliver(message);
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
this.ctx.logger.warn(`later: 暂停项插话失败 agent "${this.agent.id}": ${renderThrown(error)}`);
|
|
476
|
+
return { ok: false, code: 'internal_error', message: '插话执行失败。' };
|
|
477
|
+
}
|
|
478
|
+
// 投递成功即出列:清掉留档,避免已送达的提醒继续显示为"待发送"。
|
|
479
|
+
// 未确认时(onUnconfirmed='keep')**不清留档** —— 留档就是暂停项的「active」,
|
|
480
|
+
// 清掉等于让提醒消失而消息没到,与日志路径同样的丢失。
|
|
481
|
+
const confirmed = await this.verifyDelivery(message);
|
|
482
|
+
if (!confirmed && this.getSettings().onUnconfirmed === 'keep') {
|
|
483
|
+
return { ok: false, code: 'delivery_unconfirmed', message: '投递未确认,提醒已保留,可重试。' };
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
removePaused(sessionId, id);
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
this.ctx.logger.warn(`later: 清理暂停留档失败 uid "${id}": ${renderThrown(error)}`);
|
|
490
|
+
}
|
|
491
|
+
return { ok: true, id, steered: true };
|
|
492
|
+
}
|
|
347
493
|
/**
|
|
348
494
|
* 构造注入的消息,按投递形态分流:
|
|
349
495
|
* - `user`(`/later` 显式请求的延迟发送):原样内容 + `source.kind='user'`,
|
|
@@ -370,12 +516,7 @@ export class UserScheduleRuntime {
|
|
|
370
516
|
: renderOneShotBatchFraming(records);
|
|
371
517
|
return createUserMessage({
|
|
372
518
|
content: [{ type: 'text', text }],
|
|
373
|
-
source:
|
|
374
|
-
kind: 'plugin',
|
|
375
|
-
plugin: '定时提醒',
|
|
376
|
-
form: 'notice',
|
|
377
|
-
summary: boundContextSummary(noticeSummary(records)),
|
|
378
|
-
},
|
|
519
|
+
source: scheduleMessageSource(boundContextSummary(noticeSummary(records))),
|
|
379
520
|
});
|
|
380
521
|
}
|
|
381
522
|
const records = decision.reminders.map((reminder) => reminder.record);
|
|
@@ -388,12 +529,7 @@ export class UserScheduleRuntime {
|
|
|
388
529
|
}
|
|
389
530
|
return createUserMessage({
|
|
390
531
|
content: [{ type: 'text', text: renderEveryReminderBatchFraming(decision.reminders) }],
|
|
391
|
-
source:
|
|
392
|
-
kind: 'plugin',
|
|
393
|
-
plugin: '定时提醒',
|
|
394
|
-
form: 'notice',
|
|
395
|
-
summary: boundContextSummary(noticeSummary(records)),
|
|
396
|
-
},
|
|
532
|
+
source: scheduleMessageSource(boundContextSummary(noticeSummary(records))),
|
|
397
533
|
});
|
|
398
534
|
}
|
|
399
535
|
}
|