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/CHANGELOG.md +247 -0
- package/README.en.md +55 -2
- package/README.md +53 -2
- package/icon.svg +10 -0
- package/lib/bar-geometry.js +139 -0
- package/lib/client.js +1349 -423
- 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 +122 -4
- package/lib/projection.js +149 -20
- package/lib/runtime.js +160 -24
- package/lib/schedule-compat.js +179 -0
- package/lib/schedule-summary-store.js +160 -0
- package/lib/schedule-summary.js +113 -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 +75 -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/schedule-summary-store.d.ts +75 -0
- package/lib/types/schedule-summary.d.ts +92 -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
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
import type { Context } from '@deepseek-ai/cordis';
|
|
20
20
|
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
21
21
|
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
|
|
22
|
-
import { foldScheduleEvents } from '@deepseek-ai/dsh-schedule';
|
|
23
22
|
import type { ScheduleRecord, ScheduleView } from '@deepseek-ai/dsh-schedule';
|
|
24
23
|
import type { UserScheduleDelivery } from './domain.js';
|
|
24
|
+
import type { PausedEntry } from './ownership-store.js';
|
|
25
25
|
/** 单 session 用户任务上限(PRD:防滥用)。 */
|
|
26
26
|
export declare const DEFAULT_MAX_SCHEDULES = 100;
|
|
27
27
|
/**
|
|
@@ -53,7 +53,7 @@ export declare function validatePrompt(raw: unknown, limits: PromptLimits): {
|
|
|
53
53
|
value: string;
|
|
54
54
|
} | UserScheduleError;
|
|
55
55
|
/** 用户工具稳定的闭包错误集合(在 dsh-schedule 闭包之上追加本插件专用码)。 */
|
|
56
|
-
export type UserScheduleErrorCode = 'invalid_prompt' | 'invalid_selector' | 'invalid_time_zone' | 'not_future' | 'time_out_of_range' | 'frequency_too_high' | 'invalid_rule' | 'schedule_not_found' | 'already_overdue' | 'quota_exceeded' | 'persistence_uncertain' | 'internal_error';
|
|
56
|
+
export type UserScheduleErrorCode = 'invalid_prompt' | 'invalid_selector' | 'invalid_time_zone' | 'not_future' | 'time_out_of_range' | 'frequency_too_high' | 'invalid_rule' | 'schedule_not_found' | 'already_overdue' | 'quota_exceeded' | 'persistence_uncertain' | 'unsupported_kind' | 'not_paused' | 'internal_error';
|
|
57
57
|
/** 封闭错误值。 */
|
|
58
58
|
export interface UserScheduleError {
|
|
59
59
|
readonly ok: false;
|
|
@@ -82,6 +82,21 @@ export type UserScheduleDeleteResult = UserScheduleResult<{
|
|
|
82
82
|
readonly deleted: false;
|
|
83
83
|
readonly code: 'schedule_not_found';
|
|
84
84
|
}>;
|
|
85
|
+
/** `user_schedule_pause` 成功响应(仅 after)。 */
|
|
86
|
+
export type UserSchedulePauseResult = UserScheduleResult<{
|
|
87
|
+
readonly ok: true;
|
|
88
|
+
readonly uid: string;
|
|
89
|
+
readonly schedule_id: string;
|
|
90
|
+
readonly remaining_seconds: number;
|
|
91
|
+
readonly scheduled_at: string;
|
|
92
|
+
}>;
|
|
93
|
+
/** `user_schedule_resume` 成功响应:新 scheduleId + 原 uid。 */
|
|
94
|
+
export type UserScheduleResumeResult = UserScheduleResult<{
|
|
95
|
+
readonly ok: true;
|
|
96
|
+
readonly uid: string;
|
|
97
|
+
readonly schedule_id: string;
|
|
98
|
+
readonly remaining_seconds: number;
|
|
99
|
+
}>;
|
|
85
100
|
/** `user_schedule_create` 的规范化输入。 */
|
|
86
101
|
export interface UserScheduleCreateInput {
|
|
87
102
|
readonly prompt: string;
|
|
@@ -121,15 +136,30 @@ export declare function foldOwnedDelivery(events: readonly SessionEvent[]): Read
|
|
|
121
136
|
* 消除热路径(每次 drive / 每次工具调用)对全量日志的重复 O(n) 扫描。
|
|
122
137
|
*/
|
|
123
138
|
export interface UserFoldState {
|
|
124
|
-
|
|
139
|
+
/**
|
|
140
|
+
* `foldScheduleEvents` 的结果,按**本插件的读视图**收窄。
|
|
141
|
+
*
|
|
142
|
+
* 0.1.5 返回 `ScheduleRecord`;0.1.7 返回 `LegacyScheduleRecord`
|
|
143
|
+
* (`title` 可选,因为 title 成为必填前写入的历史事件没有该字段),
|
|
144
|
+
* 且 `ScheduleRecord` 联合新增了 daily/weekly/cron 三个本插件不产生的变体。
|
|
145
|
+
*
|
|
146
|
+
* 收窄到 `Legacy*` 视图对读路径是**语义正确**的:本插件只创建
|
|
147
|
+
* after/at/every,title 缺失时投影回退到 prompt(见 projection.ts)。
|
|
148
|
+
* 统一在此收口,避免每个消费点各写一次断言。
|
|
149
|
+
*/
|
|
150
|
+
readonly folded: FoldedUserSchedules;
|
|
125
151
|
readonly owned: ReadonlySet<string>;
|
|
126
152
|
readonly delivery: ReadonlyMap<string, UserScheduleDelivery>;
|
|
127
153
|
}
|
|
154
|
+
/** fold 结果中本插件关心的字段面(`active` 按 Legacy 视图收窄)。 */
|
|
155
|
+
export interface FoldedUserSchedules {
|
|
156
|
+
readonly active: readonly ScheduleRecord[];
|
|
157
|
+
readonly seenIds: ReadonlySet<string>;
|
|
158
|
+
}
|
|
128
159
|
/** fold 当前会话的完整用户调度状态(带缓存;调用方不得变更返回值)。
|
|
129
160
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* (`(session as { ownEvents?; events? })`)双轨探测,不引入 `any`。
|
|
161
|
+
* 跨代 Session API 探测已收敛到 `upstream-compat.ts`(readOwnEvents /
|
|
162
|
+
* readInheritedEventCount)——新 rc 若再改名只动那一处。
|
|
133
163
|
*/
|
|
134
164
|
export declare function foldUserState(session: Session): UserFoldState;
|
|
135
165
|
/** 稳定内部错误(不透出异常细节)。 */
|
|
@@ -170,7 +200,18 @@ export interface UserScheduleCreateOptions {
|
|
|
170
200
|
export declare function userScheduleCreate(input: unknown, agent: Agent, ctx: Context, maxSchedules?: number, options?: UserScheduleCreateOptions, limits?: PromptLimits): Promise<UserScheduleCreateResult>;
|
|
171
201
|
/** `user_schedule_list` 核心实现。 */
|
|
172
202
|
export declare function userScheduleList(agent: Agent, ctx: Context): Promise<UserScheduleListResult>;
|
|
173
|
-
/** `user_schedule_delete` 核心实现。 */
|
|
203
|
+
/** `user_schedule_delete` 核心实现。id 可为 scheduleId 或稳定 uid。 */
|
|
174
204
|
export declare function userScheduleDelete(id: unknown, agent: Agent, ctx: Context): Promise<UserScheduleDeleteResult>;
|
|
175
205
|
/** `user_schedule_edit`(改内容)核心实现:删旧建新,保留原时刻与 delivery。 */
|
|
176
206
|
export declare function userScheduleEditPrompt(id: unknown, newPrompt: unknown, agent: Agent, ctx: Context, limits?: PromptLimits): Promise<UserScheduleCreateResult | UserScheduleError>;
|
|
207
|
+
/**
|
|
208
|
+
* 暂停一条 **after** 提醒(方案 B):sidecar 留档 + 日志 delete。
|
|
209
|
+
* 仅 kind==='after' 且未到点;恢复见 {@link userScheduleResume}。
|
|
210
|
+
*/
|
|
211
|
+
export declare function userSchedulePause(id: unknown, agent: Agent, ctx: Context): Promise<UserSchedulePauseResult>;
|
|
212
|
+
/** 恢复一条暂停中的 after 提醒:`after_seconds = remaining` 重建;uid 不变。 */
|
|
213
|
+
export declare function userScheduleResume(uid: unknown, agent: Agent, ctx: Context, maxSchedules?: number): Promise<UserScheduleResumeResult>;
|
|
214
|
+
/** 测试/投影辅助:某会话全部暂停留档。 */
|
|
215
|
+
export declare function listPausedForSession(sessionId: string): Readonly<Record<string, PausedEntry>>;
|
|
216
|
+
/** 测试辅助:按 pause 前 schedule id 查留档。 */
|
|
217
|
+
export declare function findPausedEntry(sessionId: string, scheduleId: string): PausedEntry | undefined;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 已验证的内核代次(peer 枚举的唯一事实来源)。
|
|
3
|
+
*
|
|
4
|
+
* - `id`:semver 精确版本(发布 tag)
|
|
5
|
+
* - `peerRange`:写入 package.json 的范围(node-semver 预发布需显式枚举)
|
|
6
|
+
* - `sessionApi`:Session 读日志的形状(见 readOwnEvents / readInheritedEventCount)
|
|
7
|
+
*/
|
|
8
|
+
export const KERNEL_GENERATIONS = [
|
|
9
|
+
{
|
|
10
|
+
id: '0.1.1-rc.2',
|
|
11
|
+
peerRange: '^0.1.1-rc.2',
|
|
12
|
+
sessionApi: 'legacy-events',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: '0.1.2-rc.1',
|
|
16
|
+
peerRange: '^0.1.2-rc.1',
|
|
17
|
+
sessionApi: 'own-events',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
/**
|
|
21
|
+
* `0.1.5` 代次:Session 形状未变(仍 `ownEvents()` + `inheritedEventCount`),
|
|
22
|
+
* 且插件依赖的 `dsh-schedule` 导出面完整 —— 实测 `rc.1/rc.2/rc.3` 逐个
|
|
23
|
+
* `import()` 全部 15 个 host 模块均通过(见 docs/verification.md)。
|
|
24
|
+
*/
|
|
25
|
+
id: '0.1.5-rc.1',
|
|
26
|
+
peerRange: '^0.1.5-rc.1',
|
|
27
|
+
sessionApi: 'own-events',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
/**
|
|
31
|
+
* `0.1.7` 代次:Session 形状仍未变(`ownEvents()` + `inheritedEventCount`);
|
|
32
|
+
* `dsh-schedule` 的三处破坏性变更(`allocateScheduleId` 删除、
|
|
33
|
+
* `renderEveryReminderBatchFraming` 改名 `renderRecurringReminderBatchFraming`、
|
|
34
|
+
* `createAfterScheduleRecord` 新增必填 `title`)**全部收敛在
|
|
35
|
+
* `schedule-compat.ts`**——调用点 `user-tools.ts` / `runtime.ts` 不含版本分支。
|
|
36
|
+
*
|
|
37
|
+
* 三条差异在 bridge 里按「存在性择一」(framing)或「统一用新语义」
|
|
38
|
+
* (UUID id、title 派生)处理,两代行为一致,故可安全同时声明这两个范围。
|
|
39
|
+
*
|
|
40
|
+
* 不单列 0.1.6-alpha.*:alpha 线无独立兼容面,且 0.1.7 已覆盖其功能面。
|
|
41
|
+
*/
|
|
42
|
+
id: '0.1.7-rc.2',
|
|
43
|
+
peerRange: '^0.1.7-rc.2',
|
|
44
|
+
sessionApi: 'own-events',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
/**
|
|
48
|
+
* `0.2.0` 代次:内核跨了次版本号(0.1 → 0.2),但本插件依赖的 API 面
|
|
49
|
+
* **全部保持**。逐项核对(见 docs/verification.md「0.2.0 兼容核对」):
|
|
50
|
+
*
|
|
51
|
+
* - Session:形状未变(`ownEvents()` + `inheritedEventCount`)
|
|
52
|
+
* - dsh-schedule:0.1.7 的三处破坏性变更即为 0.2.0 现状;`foldScheduleEvents`
|
|
53
|
+
* / `renderReminderFraming` / `resolveEveryOccurrence` / `decodeScheduleChange`
|
|
54
|
+
* / `scheduleView` / `ScheduleId` 导出面完整,且
|
|
55
|
+
* `renderRecurringReminderBatchFraming` 仍在 —— `schedule-compat.ts` 无需改动
|
|
56
|
+
* - dsh-llm:`createUserMessage` / `boundContextSummary` 仍在;`MessageSourceMap`
|
|
57
|
+
* 已无 `plugin` kind,仅剩 user/model/tool/system-prompt,而 dsh-schedule
|
|
58
|
+
* 声明的 `schedule` kind 经 module augmentation 并入 —— 0.1.7 分支继续适用
|
|
59
|
+
* - dsh-agent:`followup()`(next-turn)与 `steer()`(next-step)语义未变
|
|
60
|
+
* - dsh-tools:`defineTool` 仍在
|
|
61
|
+
*
|
|
62
|
+
* 实证:以 0.2.0-rc.2 类型面对 `src/**`(host 端)跑 `tsc --noEmit` **零错误**,
|
|
63
|
+
* 且本地 183 项测试全绿。
|
|
64
|
+
*
|
|
65
|
+
* 附带影响:0.2.0 内核开始**强制**校验本范围(peer 不满足即整插件不加载,
|
|
66
|
+
* 此前只是警告)。故这一代次的缺失曾让插件在 0.2.0 上完全失效。
|
|
67
|
+
*/
|
|
68
|
+
id: '0.2.0-rc.2',
|
|
69
|
+
peerRange: '^0.2.0-rc.2',
|
|
70
|
+
sessionApi: 'own-events',
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
/**
|
|
74
|
+
* 随内核代次一起 bump 的 peer 包(宿主 hoisting 变化会波及这些运行时 import)。
|
|
75
|
+
* cordis / schemastery 等独立 semver 包**不**列入——它们不随内核 rc 枚举。
|
|
76
|
+
*/
|
|
77
|
+
export const KERNEL_SCOPED_PACKAGES = [
|
|
78
|
+
'@deepseek-ai/dsh-agent',
|
|
79
|
+
'@deepseek-ai/dsh-commands',
|
|
80
|
+
'@deepseek-ai/dsh-llm',
|
|
81
|
+
'@deepseek-ai/dsh-schedule',
|
|
82
|
+
'@deepseek-ai/dsh-session',
|
|
83
|
+
'@deepseek-ai/dsh-session-projection',
|
|
84
|
+
'@deepseek-ai/dsh-settings',
|
|
85
|
+
'@deepseek-ai/dsh-tools',
|
|
86
|
+
];
|
|
87
|
+
/** dsh-commands 在 0.1.1 之前还有一代 rc.0,peer 需额外并上。 */
|
|
88
|
+
const EXTRA_PEER_RANGES = {
|
|
89
|
+
'@deepseek-ai/dsh-commands': ['^0.0.1-rc.1'],
|
|
90
|
+
};
|
|
91
|
+
/** 由 KERNEL_GENERATIONS 拼出的 peer 范围串(`a || b`)。 */
|
|
92
|
+
export function peerRangeFor(packageName) {
|
|
93
|
+
const extras = EXTRA_PEER_RANGES[packageName] ?? [];
|
|
94
|
+
return [...extras, ...KERNEL_GENERATIONS.map((g) => g.peerRange)].join(' || ');
|
|
95
|
+
}
|
|
96
|
+
/** 全部需逐代枚举的 `{ package, range }`(sync-peer-matrix / 矩阵测试共用)。 */
|
|
97
|
+
export function peerMatrixEntries() {
|
|
98
|
+
return KERNEL_SCOPED_PACKAGES.map((packageName) => ({
|
|
99
|
+
packageName,
|
|
100
|
+
range: peerRangeFor(packageName),
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 读取「本会话自有」事件日志(不含 fork 继承前缀时由调用方配合 offset)。
|
|
105
|
+
*
|
|
106
|
+
* - 0.1.2+:`session.ownEvents()`(推荐;仅 live 段)
|
|
107
|
+
* - 0.1.1:`session.events`(含 seed;配合 seedLength 切片)
|
|
108
|
+
*/
|
|
109
|
+
export function readOwnEvents(session) {
|
|
110
|
+
const surface = session;
|
|
111
|
+
if (typeof surface.ownEvents === 'function') {
|
|
112
|
+
return surface.ownEvents();
|
|
113
|
+
}
|
|
114
|
+
return surface.events ?? [];
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* 读取「fork 继承前缀长度」——`foldScheduleEvents` 第二参数。
|
|
118
|
+
*
|
|
119
|
+
* - 0.1.2+:`session.inheritedEventCount`(SessionLogOffset 品牌数字,运行时仍是 number)
|
|
120
|
+
* - 0.1.1:`session.header.seedLength`(等价物;此前实现漏了这条回退)
|
|
121
|
+
* - 均缺失:0(新建会话 / 测试假 Session)
|
|
122
|
+
*
|
|
123
|
+
* 统一以裸 `number` 返回:cache key 可用 `===`;传入 foldScheduleEvents 时
|
|
124
|
+
* 0.1.2 的 brandshape 校验接受 safe integer。
|
|
125
|
+
*/
|
|
126
|
+
export function readInheritedEventCount(session) {
|
|
127
|
+
const surface = session;
|
|
128
|
+
const modern = surface.inheritedEventCount;
|
|
129
|
+
if (typeof modern === 'number' && Number.isSafeInteger(modern) && modern >= 0) {
|
|
130
|
+
return modern;
|
|
131
|
+
}
|
|
132
|
+
const seed = surface.header?.seedLength;
|
|
133
|
+
if (typeof seed === 'number' && Number.isSafeInteger(seed) && seed >= 0) {
|
|
134
|
+
return seed;
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
/** 探测到的 sessionApi 代次(诊断 / 测试用;不参与业务分支)。 */
|
|
139
|
+
export function detectSessionApi(session) {
|
|
140
|
+
return typeof session.ownEvents === 'function'
|
|
141
|
+
? 'own-events'
|
|
142
|
+
: 'legacy-events';
|
|
143
|
+
}
|
package/lib/user-tools.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { ScheduleId, ScheduleInputError,
|
|
1
|
+
import { ScheduleId, ScheduleInputError, foldScheduleEvents, scheduleView, } from '@deepseek-ai/dsh-schedule';
|
|
2
|
+
import { allocateScheduleIdCompat as allocateScheduleId, createAfterRecord as createAfterScheduleRecord, createAtRecord as createAtScheduleRecord, createEveryRecord as createEveryScheduleRecord, toChangePayload, asLegacySchedules, } from './schedule-compat.js';
|
|
2
3
|
import { OWNED_EVENT } from './domain.js';
|
|
3
|
-
import { allOwnership, getOwnership, recordOwnership, removeOwnership } from './ownership-store.js';
|
|
4
|
+
import { allOwnership, allPaused, findPausedByScheduleId, findScheduleIdByUid, getOwnership, getPaused, newTaskUid, recordOwnership, recordPaused, removeOwnership, removePaused, } from './ownership-store.js';
|
|
4
5
|
import { detectTimeZone } from './time-utils.js';
|
|
6
|
+
import { readInheritedEventCount, readOwnEvents } from './upstream-compat.js';
|
|
5
7
|
/** 单 session 用户任务上限(PRD:防滥用)。 */
|
|
6
8
|
export const DEFAULT_MAX_SCHEDULES = 100;
|
|
7
9
|
/**
|
|
@@ -153,18 +155,12 @@ export function foldOwnedDelivery(events) {
|
|
|
153
155
|
const foldCache = new WeakMap();
|
|
154
156
|
/** fold 当前会话的完整用户调度状态(带缓存;调用方不得变更返回值)。
|
|
155
157
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* (`(session as { ownEvents?; events? })`)双轨探测,不引入 `any`。
|
|
158
|
+
* 跨代 Session API 探测已收敛到 `upstream-compat.ts`(readOwnEvents /
|
|
159
|
+
* readInheritedEventCount)——新 rc 若再改名只动那一处。
|
|
159
160
|
*/
|
|
160
161
|
export function foldUserState(session) {
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
// 0.1.2 上 Session.inheritedEventCount 是 SessionLogOffset(BrandedNumber),
|
|
164
|
-
// foldScheduleEvents 第二个参数需要它;0.1.1 上等价于 header.seedLength(number)。
|
|
165
|
-
// 接受任意 number,统一以 number 形式进入 cache key;foldScheduleEvents 内部会
|
|
166
|
-
// 自适应(要么收 number、要么要求 branded——后者 0.1.1 路径不会触达)。
|
|
167
|
-
const inheritedEventCount = session.inheritedEventCount ?? 0;
|
|
162
|
+
const events = readOwnEvents(session);
|
|
163
|
+
const inheritedEventCount = readInheritedEventCount(session);
|
|
168
164
|
const cached = foldCache.get(session);
|
|
169
165
|
if (cached !== undefined &&
|
|
170
166
|
cached.events === events &&
|
|
@@ -172,11 +168,9 @@ export function foldUserState(session) {
|
|
|
172
168
|
cached.inheritedEventCount === inheritedEventCount) {
|
|
173
169
|
return cached.state;
|
|
174
170
|
}
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
// 改由 session.inheritedEventCount 透传。这里保留双轨探测的安全门。
|
|
179
|
-
const folded = foldScheduleEvents(events, inheritedEventCount);
|
|
171
|
+
// number 形式进入 foldScheduleEvents:0.1.2 brandshape 校验接受 safe integer;
|
|
172
|
+
// 0.1.1 直接收 number。
|
|
173
|
+
const folded = asLegacySchedules(foldScheduleEvents(events, inheritedEventCount));
|
|
180
174
|
// 所有权与投递形态:sidecar 为准(新写入),日志中的历史 OWNED 事件作底
|
|
181
175
|
// (读兼容,旧日志可能仍有残留行)。
|
|
182
176
|
const sessionId = session.header?.id;
|
|
@@ -226,7 +220,10 @@ export function serializeScheduleView(view) {
|
|
|
226
220
|
...(view.kind === 'every' ? { every_seconds: view.everySeconds } : {}),
|
|
227
221
|
scheduled_at: view.scheduledAt,
|
|
228
222
|
state: view.state,
|
|
229
|
-
|
|
223
|
+
// 0.1.5 内核给 'session-local',0.1.7 改名为 'host'——两者语义相同
|
|
224
|
+
// (提醒永不离开所属会话)。wire 契约对外归一为 'session-local',
|
|
225
|
+
// 避免内核改名传导到客户端与已存档的对话内容。
|
|
226
|
+
delivery_mode: 'session-local',
|
|
230
227
|
source: 'user-tool',
|
|
231
228
|
};
|
|
232
229
|
}
|
|
@@ -302,12 +299,12 @@ export async function userScheduleCreate(input, agent, ctx, maxSchedules = DEFAU
|
|
|
302
299
|
// 所有权写 sidecar(append 之前:投影 fold 在事件到达时按 sidecar 判定归属)。
|
|
303
300
|
// flush 失败则回滚,避免 sidecar 与日志漂移。
|
|
304
301
|
const sessionId = agent.session.header?.id;
|
|
305
|
-
recordOwnership(sessionId, id, trustedDelivery);
|
|
302
|
+
recordOwnership(sessionId, id, trustedDelivery, newTaskUid(), record.kind === 'after' ? record.afterSeconds : undefined);
|
|
306
303
|
try {
|
|
307
304
|
agent.session.append('schedule/change', {
|
|
308
305
|
version: 1,
|
|
309
306
|
operation: 'create',
|
|
310
|
-
schedule: record,
|
|
307
|
+
schedule: toChangePayload(record),
|
|
311
308
|
});
|
|
312
309
|
}
|
|
313
310
|
catch {
|
|
@@ -345,13 +342,13 @@ export async function userScheduleList(agent, ctx) {
|
|
|
345
342
|
return { ok: true, schedules };
|
|
346
343
|
});
|
|
347
344
|
}
|
|
348
|
-
/** `user_schedule_delete` 核心实现。 */
|
|
345
|
+
/** `user_schedule_delete` 核心实现。id 可为 scheduleId 或稳定 uid。 */
|
|
349
346
|
export async function userScheduleDelete(id, agent, ctx) {
|
|
350
347
|
if (typeof id !== 'string' || id.length === 0 || id.trim() !== id) {
|
|
351
348
|
return { ok: false, code: 'invalid_rule', message: 'schedule id 必须是去空白非空字符串。' };
|
|
352
349
|
}
|
|
353
|
-
const scheduleId = ScheduleId(id);
|
|
354
350
|
return runUserScheduleTransaction(agent, async () => {
|
|
351
|
+
const sessionId = agent.session.header?.id;
|
|
355
352
|
let folded;
|
|
356
353
|
try {
|
|
357
354
|
folded = foldUserState(agent.session).folded;
|
|
@@ -359,26 +356,40 @@ export async function userScheduleDelete(id, agent, ctx) {
|
|
|
359
356
|
catch {
|
|
360
357
|
return { ok: false, code: 'internal_error', message: '会话定时日志读取失败。' };
|
|
361
358
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
359
|
+
// 1) 活动任务:uid → scheduleId
|
|
360
|
+
const activeId = resolveActiveScheduleId(sessionId, id);
|
|
361
|
+
if (folded.active.some((record) => record.id === activeId)) {
|
|
362
|
+
try {
|
|
363
|
+
agent.session.append('schedule/change', {
|
|
364
|
+
version: 1,
|
|
365
|
+
operation: 'delete',
|
|
366
|
+
id: ScheduleId(activeId),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return internalError();
|
|
371
|
+
}
|
|
372
|
+
if (!(await flushSession(ctx, agent.session)))
|
|
373
|
+
return persistenceError();
|
|
374
|
+
removeOwnership(sessionId, activeId);
|
|
375
|
+
return { ok: true, id, deleted: true };
|
|
372
376
|
}
|
|
373
|
-
|
|
374
|
-
|
|
377
|
+
// 2) 暂停项:wire id 即 uid,只清 sidecar。
|
|
378
|
+
//
|
|
379
|
+
// 这里**不能**像活动任务那样补一条 `schedule/change` delete 事件:
|
|
380
|
+
// dsh-schedule 的 fold 要求 delete 必须命中「仍 active」的 id,而该 id 在
|
|
381
|
+
// pause 时就已经被删掉了(见 userSchedulePause),补写会抛
|
|
382
|
+
// `schedule delete targets inactive id`,把整份日志读坏。
|
|
383
|
+
//
|
|
384
|
+
// 后果:投影 cell 不会因 sidecar 变化而重算,`paused[]` 镜像会停留在快照上。
|
|
385
|
+
// 因此 `viewUserScheduleProjection` 改为**以 sidecar 为准重读**(权威存储),
|
|
386
|
+
// 使删除即刻对客户端可见。
|
|
387
|
+
const paused = getPaused(sessionId, id);
|
|
388
|
+
if (paused !== undefined) {
|
|
389
|
+
removePaused(sessionId, id);
|
|
390
|
+
return { ok: true, id, deleted: true };
|
|
375
391
|
}
|
|
376
|
-
|
|
377
|
-
return persistenceError();
|
|
378
|
-
// flush 成功后再撤销所有权(失败时保留:日志里的 delete 可能未落盘,
|
|
379
|
-
// 运行时还要按 sidecar 继续追踪该任务)。
|
|
380
|
-
removeOwnership(sessionId, scheduleId);
|
|
381
|
-
return { ok: true, id, deleted: true };
|
|
392
|
+
return { ok: true, id, deleted: false, code: 'schedule_not_found' };
|
|
382
393
|
});
|
|
383
394
|
}
|
|
384
395
|
/** `user_schedule_edit`(改内容)核心实现:删旧建新,保留原时刻与 delivery。 */
|
|
@@ -404,6 +415,34 @@ export async function userScheduleEditPrompt(id, newPrompt, agent, ctx, limits =
|
|
|
404
415
|
}
|
|
405
416
|
const existing = folded.active.find((record) => record.id === scheduleId);
|
|
406
417
|
if (existing === undefined) {
|
|
418
|
+
/**
|
|
419
|
+
* 暂停项的编辑:只改 sidecar 里的 `prompt`。
|
|
420
|
+
*
|
|
421
|
+
* 为什么单独走一条路:暂停 = 日志 `delete` + sidecar 留档(方案 B),所以
|
|
422
|
+
* 暂停项**在日志里不存在**,`folded.active` 必然找不到它。但内容本就存在
|
|
423
|
+
* `PausedEntry.prompt`(resume 时用它重建记录),因此直接改写该字段即可——
|
|
424
|
+
* 不动时刻、不动剩余秒数、不动 uid、不产生任何会话事件。
|
|
425
|
+
*
|
|
426
|
+
* 这也消掉了"暂停了就不能改文案"这个反直觉的限制。
|
|
427
|
+
*/
|
|
428
|
+
const sessionId = agent.session.header?.id;
|
|
429
|
+
const paused = getPaused(sessionId, id);
|
|
430
|
+
if (paused !== undefined) {
|
|
431
|
+
// 仅替换 prompt,其余字段(remainingSeconds / pausedAt / uid / 原窗口)
|
|
432
|
+
// 原样保留 → 冻结进度与恢复后的窗口都不受影响。
|
|
433
|
+
recordPaused(sessionId, { ...paused, prompt: trimmed });
|
|
434
|
+
// 复用与日志记录一致的序列化,避免两处形状漂移。
|
|
435
|
+
return {
|
|
436
|
+
ok: true,
|
|
437
|
+
...serializeScheduleView(scheduleView({
|
|
438
|
+
id: paused.lastScheduleId,
|
|
439
|
+
kind: 'after',
|
|
440
|
+
prompt: trimmed,
|
|
441
|
+
afterSeconds: paused.originalAfterSeconds ?? paused.remainingSeconds,
|
|
442
|
+
scheduledAt: paused.originalScheduledAt,
|
|
443
|
+
}, Date.now())),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
407
446
|
return { ok: false, code: 'schedule_not_found', message: `未找到定时任务 ${scheduleId}。` };
|
|
408
447
|
}
|
|
409
448
|
// P0-4:已过期(overdue)的 at 任务禁止改内容——「保留原时刻」会让新记录
|
|
@@ -430,7 +469,13 @@ export async function userScheduleEditPrompt(id, newPrompt, agent, ctx, limits =
|
|
|
430
469
|
now);
|
|
431
470
|
}
|
|
432
471
|
else {
|
|
433
|
-
|
|
472
|
+
// 0.1.7 的 `ScheduleRecord` 联合新增 daily/weekly/cron(均无
|
|
473
|
+
// `everySeconds`)。本插件只创建 every,故其余变体按 300s 默认间隔
|
|
474
|
+
// 归一——它们只可能来自其他插件写入的日志,不是本插件的产物。
|
|
475
|
+
const everySeconds = existing.kind === 'every' && typeof existing.everySeconds === 'number'
|
|
476
|
+
? existing.everySeconds
|
|
477
|
+
: 300;
|
|
478
|
+
record = createEveryScheduleRecord(allocateScheduleId(folded), trimmed, everySeconds, now);
|
|
434
479
|
}
|
|
435
480
|
}
|
|
436
481
|
catch (error) {
|
|
@@ -438,13 +483,14 @@ export async function userScheduleEditPrompt(id, newPrompt, agent, ctx, limits =
|
|
|
438
483
|
}
|
|
439
484
|
const delivery = deliveries.get(scheduleId) ?? 'context';
|
|
440
485
|
// 新所有权先写 sidecar(create 事件到达投影 fold 时按 sidecar 判定归属);
|
|
441
|
-
// 旧条目在 flush 成功后再撤销(失败回滚两侧)。
|
|
486
|
+
// 旧条目在 flush 成功后再撤销(失败回滚两侧)。uid 跨编辑保留。
|
|
442
487
|
const sessionId = agent.session.header?.id;
|
|
443
488
|
const previousEntry = getOwnership(sessionId, scheduleId);
|
|
444
|
-
|
|
489
|
+
// 编辑 after 会重置倒计时 → 窗口等于新 afterSeconds,不沿用 resume 旧窗口
|
|
490
|
+
recordOwnership(sessionId, record.id, delivery, previousEntry?.uid, record.kind === 'after' ? record.afterSeconds : undefined);
|
|
445
491
|
try {
|
|
446
492
|
agent.session.append('schedule/change', { version: 1, operation: 'delete', id: scheduleId });
|
|
447
|
-
agent.session.append('schedule/change', { version: 1, operation: 'create', schedule: record });
|
|
493
|
+
agent.session.append('schedule/change', { version: 1, operation: 'create', schedule: toChangePayload(record) });
|
|
448
494
|
}
|
|
449
495
|
catch {
|
|
450
496
|
removeOwnership(sessionId, record.id);
|
|
@@ -461,3 +507,181 @@ export async function userScheduleEditPrompt(id, newPrompt, agent, ctx, limits =
|
|
|
461
507
|
return { ok: true, ...serializeScheduleView(view) };
|
|
462
508
|
});
|
|
463
509
|
}
|
|
510
|
+
/** 把 wire/命令里的 id 解析为当前 active 的日志 schedule id(uid 或 scheduleId)。 */
|
|
511
|
+
function resolveActiveScheduleId(sessionId, id) {
|
|
512
|
+
if (getOwnership(sessionId, id) !== undefined)
|
|
513
|
+
return id;
|
|
514
|
+
const byUid = findScheduleIdByUid(sessionId, id);
|
|
515
|
+
return byUid ?? id;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* 暂停一条 **after** 提醒(方案 B):sidecar 留档 + 日志 delete。
|
|
519
|
+
* 仅 kind==='after' 且未到点;恢复见 {@link userScheduleResume}。
|
|
520
|
+
*/
|
|
521
|
+
export async function userSchedulePause(id, agent, ctx) {
|
|
522
|
+
if (typeof id !== 'string' || id.length === 0 || id.trim() !== id) {
|
|
523
|
+
return { ok: false, code: 'invalid_rule', message: 'schedule id 必须是去空白非空字符串。' };
|
|
524
|
+
}
|
|
525
|
+
return runUserScheduleTransaction(agent, async () => {
|
|
526
|
+
const sessionId = agent.session.header?.id;
|
|
527
|
+
let folded;
|
|
528
|
+
let owned;
|
|
529
|
+
let deliveryMap;
|
|
530
|
+
try {
|
|
531
|
+
const state = foldUserState(agent.session);
|
|
532
|
+
folded = state.folded;
|
|
533
|
+
owned = state.owned;
|
|
534
|
+
deliveryMap = state.delivery;
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return { ok: false, code: 'internal_error', message: '会话定时日志读取失败。' };
|
|
538
|
+
}
|
|
539
|
+
const scheduleId = resolveActiveScheduleId(sessionId, id);
|
|
540
|
+
const existing = folded.active.find((record) => record.id === scheduleId);
|
|
541
|
+
if (existing === undefined || !owned.has(scheduleId)) {
|
|
542
|
+
return { ok: false, code: 'schedule_not_found', message: `未找到可暂停的定时任务 ${id}。` };
|
|
543
|
+
}
|
|
544
|
+
if (existing.kind !== 'after') {
|
|
545
|
+
return { ok: false, code: 'unsupported_kind', message: '仅支持暂停「再等 N 分钟」类(after)提醒。' };
|
|
546
|
+
}
|
|
547
|
+
const now = Date.now();
|
|
548
|
+
const target = Date.parse(existing.scheduledAt);
|
|
549
|
+
if (!Number.isFinite(target) || target <= now) {
|
|
550
|
+
return {
|
|
551
|
+
ok: false,
|
|
552
|
+
code: 'already_overdue',
|
|
553
|
+
message: '任务已到点,无法暂停;请删除或等待发送。',
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
const remainingSeconds = Math.max(1, Math.ceil((target - now) / 1000));
|
|
557
|
+
const ownership = getOwnership(sessionId, scheduleId);
|
|
558
|
+
const uid = ownership?.uid ?? newTaskUid();
|
|
559
|
+
const delivery = deliveryMap.get(scheduleId) ?? ownership?.delivery ?? 'context';
|
|
560
|
+
// 旧条目无 uid 时补写,保证 pause 后有稳定身份
|
|
561
|
+
if (ownership?.uid === undefined) {
|
|
562
|
+
recordOwnership(sessionId, scheduleId, delivery, uid);
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* 进度条总窗口(原间隔),**不能**用 `existing.afterSeconds`。
|
|
566
|
+
*
|
|
567
|
+
* resume 会用 `after_seconds = remaining` 重建记录(日志里的 afterSeconds
|
|
568
|
+
* 只是剩余),而把原窗口另行保存在 ownership.windowSeconds。若这里读日志的
|
|
569
|
+
* afterSeconds,每「恢复→再暂停」一轮窗口就缩水一次:
|
|
570
|
+
* 2 分钟任务 → 暂停(窗 120) → 恢复(日志 85) → 再暂停(窗被记成 85) …
|
|
571
|
+
* 表现为暂停后进度条比例越来越离谱(实测 29% → 2.3% 塌缩)。
|
|
572
|
+
* 故优先取 ownership 里保留的窗口,再回退日志值(首次暂停时两者相同)。
|
|
573
|
+
*/
|
|
574
|
+
const windowSeconds = ownership?.windowSeconds ?? existing.afterSeconds;
|
|
575
|
+
const pausedEntry = {
|
|
576
|
+
uid,
|
|
577
|
+
prompt: existing.prompt,
|
|
578
|
+
delivery,
|
|
579
|
+
kind: 'after',
|
|
580
|
+
remainingSeconds,
|
|
581
|
+
originalScheduledAt: existing.scheduledAt,
|
|
582
|
+
...(windowSeconds !== undefined ? { originalAfterSeconds: windowSeconds } : {}),
|
|
583
|
+
lastScheduleId: scheduleId,
|
|
584
|
+
pausedAt: now,
|
|
585
|
+
};
|
|
586
|
+
// sidecar 先写(投影 delete 后按 lastScheduleId 挂回 paused 列表)
|
|
587
|
+
recordPaused(sessionId, pausedEntry);
|
|
588
|
+
try {
|
|
589
|
+
agent.session.append('schedule/change', {
|
|
590
|
+
version: 1,
|
|
591
|
+
operation: 'delete',
|
|
592
|
+
id: ScheduleId(scheduleId),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
removePaused(sessionId, uid);
|
|
597
|
+
return internalError();
|
|
598
|
+
}
|
|
599
|
+
if (!(await flushSession(ctx, agent.session))) {
|
|
600
|
+
removePaused(sessionId, uid);
|
|
601
|
+
return persistenceError();
|
|
602
|
+
}
|
|
603
|
+
removeOwnership(sessionId, scheduleId);
|
|
604
|
+
return {
|
|
605
|
+
ok: true,
|
|
606
|
+
uid,
|
|
607
|
+
schedule_id: scheduleId,
|
|
608
|
+
remaining_seconds: remainingSeconds,
|
|
609
|
+
scheduled_at: existing.scheduledAt,
|
|
610
|
+
};
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
/** 恢复一条暂停中的 after 提醒:`after_seconds = remaining` 重建;uid 不变。 */
|
|
614
|
+
export async function userScheduleResume(uid, agent, ctx, maxSchedules = DEFAULT_MAX_SCHEDULES) {
|
|
615
|
+
if (typeof uid !== 'string' || uid.length === 0 || uid.trim() !== uid) {
|
|
616
|
+
return { ok: false, code: 'invalid_rule', message: 'uid 必须是去空白非空字符串。' };
|
|
617
|
+
}
|
|
618
|
+
return runUserScheduleTransaction(agent, async () => {
|
|
619
|
+
const sessionId = agent.session.header?.id;
|
|
620
|
+
const paused = getPaused(sessionId, uid);
|
|
621
|
+
if (paused === undefined) {
|
|
622
|
+
return { ok: false, code: 'not_paused', message: `未找到暂停中的任务 ${uid}。` };
|
|
623
|
+
}
|
|
624
|
+
let folded;
|
|
625
|
+
let owned;
|
|
626
|
+
try {
|
|
627
|
+
const state = foldUserState(agent.session);
|
|
628
|
+
folded = state.folded;
|
|
629
|
+
owned = state.owned;
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return { ok: false, code: 'internal_error', message: '会话定时日志读取失败。' };
|
|
633
|
+
}
|
|
634
|
+
const activeCount = folded.active.filter((record) => owned.has(record.id)).length;
|
|
635
|
+
if (activeCount >= maxSchedules) {
|
|
636
|
+
return {
|
|
637
|
+
ok: false,
|
|
638
|
+
code: 'quota_exceeded',
|
|
639
|
+
message: `当前会话最多 ${maxSchedules} 个定时任务,请先删除部分任务。`,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
const now = Date.now();
|
|
643
|
+
let record;
|
|
644
|
+
try {
|
|
645
|
+
record = createAfterScheduleRecord(ScheduleId(allocateScheduleId(folded)), paused.prompt, Math.max(1, paused.remainingSeconds), now);
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
return toInputError(error);
|
|
649
|
+
}
|
|
650
|
+
// 先清 paused:投影 fold 在 append(create) 时读 sidecar;若 create 落地后才
|
|
651
|
+
// removePaused,视图会短暂同时出现「已暂停」+ 新活动行(实测重复行根因)。
|
|
652
|
+
removePaused(sessionId, uid);
|
|
653
|
+
// 保留原 after 窗口:日志 afterSeconds 只是 remaining,进度条需要原窗口才能接着跑
|
|
654
|
+
recordOwnership(sessionId, record.id, paused.delivery, uid, paused.originalAfterSeconds ?? paused.remainingSeconds);
|
|
655
|
+
try {
|
|
656
|
+
agent.session.append('schedule/change', {
|
|
657
|
+
version: 1,
|
|
658
|
+
operation: 'create',
|
|
659
|
+
schedule: toChangePayload(record),
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
removeOwnership(sessionId, record.id);
|
|
664
|
+
recordPaused(sessionId, paused);
|
|
665
|
+
return internalError();
|
|
666
|
+
}
|
|
667
|
+
if (!(await flushSession(ctx, agent.session))) {
|
|
668
|
+
removeOwnership(sessionId, record.id);
|
|
669
|
+
recordPaused(sessionId, paused);
|
|
670
|
+
return persistenceError();
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
ok: true,
|
|
674
|
+
uid,
|
|
675
|
+
schedule_id: record.id,
|
|
676
|
+
remaining_seconds: Math.max(1, paused.remainingSeconds),
|
|
677
|
+
};
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
/** 测试/投影辅助:某会话全部暂停留档。 */
|
|
681
|
+
export function listPausedForSession(sessionId) {
|
|
682
|
+
return allPaused(sessionId);
|
|
683
|
+
}
|
|
684
|
+
/** 测试辅助:按 pause 前 schedule id 查留档。 */
|
|
685
|
+
export function findPausedEntry(sessionId, scheduleId) {
|
|
686
|
+
return findPausedByScheduleId(sessionId, scheduleId);
|
|
687
|
+
}
|