@qihongmu/dsh-plugins-scheduled-task 0.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/cordis.patch.yml +5 -0
- package/lib/typert.remote-client.d.ts +32 -0
- package/lib/types/domain.d.ts +58 -0
- package/lib/types/domain.js +401 -0
- package/lib/types/index.d.ts +121 -0
- package/lib/types/index.js +506 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +30 -0
- package/lib/types/spec.d.ts +134 -0
- package/lib/types/spec.js +89 -0
- package/lib/types/types.d.ts +179 -0
- package/lib/types/types.js +5 -0
- package/package.json +63 -0
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */
|
|
2
|
+
import type {
|
|
3
|
+
RemoteResult,
|
|
4
|
+
TypertRemoteContribution,
|
|
5
|
+
} from '@deepseek-ai/dsh-typert-protocol'
|
|
6
|
+
import type { ScheduledTaskCreateInput, ScheduledTaskDeleteResult, ScheduledTaskId, ScheduledTaskMutationResult, ScheduledTaskSettableStatus, ScheduledTaskUpdateInput, ScheduledTaskView } from '@qihongmu/dsh-plugins-scheduled-task/types'
|
|
7
|
+
|
|
8
|
+
declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
9
|
+
interface TypertRemoteNamespace$7363686564756c65645461736b73 {
|
|
10
|
+
create: (input: ScheduledTaskCreateInput) => Promise<RemoteResult<ScheduledTaskMutationResult>>
|
|
11
|
+
delete: (id: ScheduledTaskId) => Promise<RemoteResult<ScheduledTaskDeleteResult>>
|
|
12
|
+
list: () => Promise<RemoteResult<ScheduledTaskView[]>>
|
|
13
|
+
markRead: (id: ScheduledTaskId) => Promise<RemoteResult<null>>
|
|
14
|
+
setStatus: (id: ScheduledTaskId, status: ScheduledTaskSettableStatus) => Promise<RemoteResult<ScheduledTaskMutationResult>>
|
|
15
|
+
update: (id: ScheduledTaskId, input: ScheduledTaskUpdateInput) => Promise<RemoteResult<ScheduledTaskMutationResult>>
|
|
16
|
+
}
|
|
17
|
+
interface TypertRemoteMap {
|
|
18
|
+
'scheduledTasks/create': (input: ScheduledTaskCreateInput) => Promise<RemoteResult<ScheduledTaskMutationResult>>
|
|
19
|
+
'scheduledTasks/delete': (id: ScheduledTaskId) => Promise<RemoteResult<ScheduledTaskDeleteResult>>
|
|
20
|
+
'scheduledTasks/list': () => Promise<RemoteResult<ScheduledTaskView[]>>
|
|
21
|
+
'scheduledTasks/markRead': (id: ScheduledTaskId) => Promise<RemoteResult<null>>
|
|
22
|
+
'scheduledTasks/setStatus': (id: ScheduledTaskId, status: ScheduledTaskSettableStatus) => Promise<RemoteResult<ScheduledTaskMutationResult>>
|
|
23
|
+
'scheduledTasks/update': (id: ScheduledTaskId, input: ScheduledTaskUpdateInput) => Promise<RemoteResult<ScheduledTaskMutationResult>>
|
|
24
|
+
}
|
|
25
|
+
interface TypertRemoteNamespaceMap {
|
|
26
|
+
'scheduledTasks': TypertRemoteNamespace$7363686564756c65645461736b73
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export declare const TYPERT_REMOTE: TypertRemoteContribution
|
|
31
|
+
export default TYPERT_REMOTE
|
|
32
|
+
//# sourceMappingURL=typert.remote-client.d.ts.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure external scheduled-task rule building, advancement, view derivation, and
|
|
3
|
+
* framing. Reuses only the shipped `@deepseek-ai/dsh-schedule` public API (the
|
|
4
|
+
* record builders and `resolveEveryOccurrence`), so no DSH repo change is needed.
|
|
5
|
+
* @module @qihongmu/dsh-plugins-scheduled-task/src/domain
|
|
6
|
+
*/
|
|
7
|
+
import type { ScheduledTaskRecord } from './spec.ts';
|
|
8
|
+
import type { ScheduledTaskCreateInput, ScheduledTaskErrorCode, ScheduledTaskId, ScheduledTaskRule, ScheduledTaskUpdateInput, ScheduledTaskView } from './types.ts';
|
|
9
|
+
/** Brand a raw task id without changing its runtime value. */
|
|
10
|
+
export declare function ScheduledTaskId(value: string): ScheduledTaskId;
|
|
11
|
+
/** Stable domain failure with a closed public error code. */
|
|
12
|
+
export declare class ScheduledTaskError extends Error {
|
|
13
|
+
readonly code: ScheduledTaskErrorCode;
|
|
14
|
+
constructor(code: ScheduledTaskErrorCode, message: string, options?: ErrorOptions);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Validate a non-empty trimmed title.
|
|
18
|
+
* @param title - Candidate task label.
|
|
19
|
+
* @returns The trimmed title.
|
|
20
|
+
*/
|
|
21
|
+
export declare function validateTitle(title: unknown): string;
|
|
22
|
+
/**
|
|
23
|
+
* Validate a non-empty trimmed task prompt (the instruction executed at fire time).
|
|
24
|
+
* @param prompt - Candidate task instruction.
|
|
25
|
+
* @returns The trimmed prompt.
|
|
26
|
+
*/
|
|
27
|
+
export declare function validatePrompt(prompt: unknown): string;
|
|
28
|
+
/**
|
|
29
|
+
* Build a rule from a create or update selector payload. Exactly one selector
|
|
30
|
+
* must be present; the computed target is canonical four-digit-year RFC 3339 UTC.
|
|
31
|
+
* @param input - Create or update payload carrying the selector fields.
|
|
32
|
+
* @param now - Single creation-time wall-clock sample in epoch milliseconds.
|
|
33
|
+
* @returns the built rule, or `undefined` when no selector is supplied (update keeps the current rule).
|
|
34
|
+
*/
|
|
35
|
+
export declare function buildRule(input: ScheduledTaskCreateInput | ScheduledTaskUpdateInput, now: number): ScheduledTaskRule | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Advance a rule past one admitted run. One-shot rules terminate; every
|
|
38
|
+
* recurring rule (fixed-rate and the wall-clock presets) advances to its next
|
|
39
|
+
* occurrence strictly after the decision time.
|
|
40
|
+
* @param rule - Active rule whose target is the earliest unfired occurrence.
|
|
41
|
+
* @param runAt - Wall-clock decision time in epoch milliseconds.
|
|
42
|
+
* @returns the advanced rule, or `undefined` when the task is terminal.
|
|
43
|
+
*/
|
|
44
|
+
export declare function advanceRule(rule: ScheduledTaskRule, runAt: number): ScheduledTaskRule | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Derive one client-facing view from the durable record and wall clock.
|
|
47
|
+
* @param record - Durable task record.
|
|
48
|
+
* @param now - Wall-clock sample in epoch milliseconds.
|
|
49
|
+
* @returns The complete client-facing view.
|
|
50
|
+
*/
|
|
51
|
+
export declare function scheduledTaskView(record: ScheduledTaskRecord, now: number): ScheduledTaskView;
|
|
52
|
+
/**
|
|
53
|
+
* Render the fixed injection-resistant model framing for one due task.
|
|
54
|
+
* @param record - Due task record.
|
|
55
|
+
* @returns Stable model-visible text with a JSON-escaped prompt.
|
|
56
|
+
*/
|
|
57
|
+
export declare function renderTaskFraming(record: ScheduledTaskRecord): string;
|
|
58
|
+
//# sourceMappingURL=domain.d.ts.map
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure external scheduled-task rule building, advancement, view derivation, and
|
|
3
|
+
* framing. Reuses only the shipped `@deepseek-ai/dsh-schedule` public API (the
|
|
4
|
+
* record builders and `resolveEveryOccurrence`), so no DSH repo change is needed.
|
|
5
|
+
* @module @qihongmu/dsh-plugins-scheduled-task/src/domain
|
|
6
|
+
*/
|
|
7
|
+
import { createAfterScheduleRecord, createAtScheduleRecord, createEveryScheduleRecord, resolveEveryOccurrence, ScheduleId, ScheduleInputError, } from '@deepseek-ai/dsh-schedule';
|
|
8
|
+
/** Brand a raw task id without changing its runtime value. */
|
|
9
|
+
export function ScheduledTaskId(value) {
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
/** Stable domain failure with a closed public error code. */
|
|
13
|
+
export class ScheduledTaskError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code, message, options) {
|
|
16
|
+
super(message, options);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = 'ScheduledTaskError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Whether an unknown value is a non-array object. */
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Validate a non-empty trimmed title.
|
|
27
|
+
* @param title - Candidate task label.
|
|
28
|
+
* @returns The trimmed title.
|
|
29
|
+
*/
|
|
30
|
+
export function validateTitle(title) {
|
|
31
|
+
if (typeof title !== 'string' || title.trim().length === 0) {
|
|
32
|
+
throw new ScheduledTaskError('invalid_title', 'title must be non-empty after trimming.');
|
|
33
|
+
}
|
|
34
|
+
return title.trim();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Validate a non-empty trimmed task prompt (the instruction executed at fire time).
|
|
38
|
+
* @param prompt - Candidate task instruction.
|
|
39
|
+
* @returns The trimmed prompt.
|
|
40
|
+
*/
|
|
41
|
+
export function validatePrompt(prompt) {
|
|
42
|
+
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
|
|
43
|
+
throw new ScheduledTaskError('invalid_prompt', 'prompt must be non-empty after trimming.');
|
|
44
|
+
}
|
|
45
|
+
return prompt.trim();
|
|
46
|
+
}
|
|
47
|
+
/** Normalize a bare `HH:mm` to the `HH:mm:ss` the shipped builder demands. */
|
|
48
|
+
function normalizeTime(time) {
|
|
49
|
+
return /^\d{2}:\d{2}$/.test(time) ? `${time}:00` : time;
|
|
50
|
+
}
|
|
51
|
+
/** Validate an absolute local-calendar selector shape. */
|
|
52
|
+
function validateAt(at) {
|
|
53
|
+
if (!isRecord(at) || typeof at['date'] !== 'string' || typeof at['time'] !== 'string'
|
|
54
|
+
|| typeof at['time_zone'] !== 'string') {
|
|
55
|
+
throw new ScheduledTaskError('invalid_rule', 'at must contain exactly date, time, and time_zone strings.');
|
|
56
|
+
}
|
|
57
|
+
return { date: at['date'], time: normalizeTime(at['time']), time_zone: at['time_zone'] };
|
|
58
|
+
}
|
|
59
|
+
/** Assert a non-empty IANA time-zone string for one wall-clock preset. */
|
|
60
|
+
function validateTimeZone(value, preset) {
|
|
61
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
62
|
+
throw new ScheduledTaskError('invalid_rule', `${preset} requires a time_zone string.`);
|
|
63
|
+
}
|
|
64
|
+
return value.trim();
|
|
65
|
+
}
|
|
66
|
+
/** Translate one contained `dsh-schedule` builder failure to the closed task error union. */
|
|
67
|
+
function translate(selector) {
|
|
68
|
+
try {
|
|
69
|
+
return selector().scheduledAt;
|
|
70
|
+
}
|
|
71
|
+
catch (caught) {
|
|
72
|
+
if (caught instanceof ScheduleInputError) {
|
|
73
|
+
throw new ScheduledTaskError(caught.code, caught.message, { cause: caught });
|
|
74
|
+
}
|
|
75
|
+
/* v8 ignore next -- the record builders throw only ScheduleInputError after shape checks above. */
|
|
76
|
+
throw caught;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/* ------------------------------------------------------------------------- *
|
|
80
|
+
* Recurring wall-clock occurrence helpers.
|
|
81
|
+
*
|
|
82
|
+
* Daily / weekly / monthly reuse the shipped local-date builder for the
|
|
83
|
+
* time-zone conversion (`createAtScheduleRecord` maps a local calendar part +
|
|
84
|
+
* IANA zone to a canonical instant); hourly anchors to the process zone. Each
|
|
85
|
+
* helper returns the first occurrence at-or-after (inclusive=true, used when
|
|
86
|
+
* placing the initial scheduled target) or strictly after (inclusive=false,
|
|
87
|
+
* used to advance past a fired run) `now`.
|
|
88
|
+
* ------------------------------------------------------------------------- */
|
|
89
|
+
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
90
|
+
/** Convert `date` (YYYY-MM-DD) + `time` (HH:mm or HH:mm:ss) in `timeZone` to canonical epoch ms. */
|
|
91
|
+
function atInstant(date, time, timeZone, now) {
|
|
92
|
+
const record = createAtScheduleRecord(ScheduleId('task'), 'task', { date, time: normalizeTime(time), time_zone: timeZone }, now);
|
|
93
|
+
return Date.parse(record.scheduledAt);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Convert one candidate instant, treating `not_future` (a candidate already
|
|
97
|
+
* past or equal to `now`, which the shipped builder rejects) as a miss rather
|
|
98
|
+
* than a fatality — the caller then marches to the next candidate day/hour.
|
|
99
|
+
* Every other builder failure is first translated to the closed task error
|
|
100
|
+
* union, so a wall-clock preset reports e.g. `invalid_time_zone` as a
|
|
101
|
+
* ScheduledTaskError (mapped to its code in the mutation envelope) instead of
|
|
102
|
+
* degrading to a generic `internal_error`.
|
|
103
|
+
*/
|
|
104
|
+
function tryInstant(date, time, timeZone, now) {
|
|
105
|
+
try {
|
|
106
|
+
return atInstant(date, time, timeZone, now);
|
|
107
|
+
}
|
|
108
|
+
catch (caught) {
|
|
109
|
+
if (caught instanceof ScheduleInputError) {
|
|
110
|
+
const error = new ScheduledTaskError(caught.code, caught.message, { cause: caught });
|
|
111
|
+
if (error.code === 'not_future')
|
|
112
|
+
return undefined;
|
|
113
|
+
// DST spring-forward: a local wall-clock time can be SKIPPED entirely
|
|
114
|
+
// (e.g. America/New_York 02:30 on the transition day). The shipped builder
|
|
115
|
+
// reports that as invalid_rule "The local at time does not exist…" — treat
|
|
116
|
+
// it as a miss so the caller marches to the next candidate day instead of
|
|
117
|
+
// failing the whole rule.
|
|
118
|
+
if (error.code === 'invalid_rule' && error.message.includes('does not exist'))
|
|
119
|
+
return undefined;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
/* v8 ignore next -- the record builders throw only ScheduleInputError after shape checks above. */
|
|
123
|
+
throw caught;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Local `YYYY-MM-DD` for a Date. */
|
|
127
|
+
function isoDateOf(value) {
|
|
128
|
+
return [
|
|
129
|
+
value.getFullYear(),
|
|
130
|
+
String(value.getMonth() + 1).padStart(2, '0'),
|
|
131
|
+
String(value.getDate()).padStart(2, '0'),
|
|
132
|
+
].join('-');
|
|
133
|
+
}
|
|
134
|
+
/** 1=Mon..7=Sun weekday of a Date. */
|
|
135
|
+
function weekdayOf(value) {
|
|
136
|
+
return ((value.getDay() + 6) % 7) + 1;
|
|
137
|
+
}
|
|
138
|
+
/** First occurrence of hour :`minute` in the process zone after `now`. */
|
|
139
|
+
function firstHourly(minute, now, inclusive) {
|
|
140
|
+
const candidate = new Date(now);
|
|
141
|
+
candidate.setSeconds(0, 0);
|
|
142
|
+
candidate.setMinutes(minute);
|
|
143
|
+
if (inclusive ? candidate.getTime() < now : candidate.getTime() <= now) {
|
|
144
|
+
candidate.setHours(candidate.getHours() + 1);
|
|
145
|
+
}
|
|
146
|
+
return candidate.toISOString();
|
|
147
|
+
}
|
|
148
|
+
/** First strictly-future daily occurrence of `time` in `timeZone`. */
|
|
149
|
+
function firstDaily(time, timeZone, now) {
|
|
150
|
+
const base = new Date(now);
|
|
151
|
+
for (let offset = 0; offset < 8; offset += 1) {
|
|
152
|
+
const candidateDate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + offset);
|
|
153
|
+
const candidate = tryInstant(isoDateOf(candidateDate), time, timeZone, now);
|
|
154
|
+
if (candidate !== undefined)
|
|
155
|
+
return new Date(candidate).toISOString();
|
|
156
|
+
}
|
|
157
|
+
/* v8 ignore next -- within 8 days a daily rule always finds a future instant. */
|
|
158
|
+
throw new ScheduledTaskError('time_out_of_range', 'daily schedule found no future occurrence.');
|
|
159
|
+
}
|
|
160
|
+
/** First strictly-future weekly occurrence on one of `weekdays` (1=Mon..7=Sun) at `time` in `timeZone`. */
|
|
161
|
+
function firstWeekly(weekdays, time, timeZone, now) {
|
|
162
|
+
const base = new Date(now);
|
|
163
|
+
for (let offset = 0; offset < 14; offset += 1) {
|
|
164
|
+
const candidateDate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + offset);
|
|
165
|
+
if (!weekdays.includes(weekdayOf(candidateDate)))
|
|
166
|
+
continue;
|
|
167
|
+
const candidate = tryInstant(isoDateOf(candidateDate), time, timeZone, now);
|
|
168
|
+
if (candidate !== undefined)
|
|
169
|
+
return new Date(candidate).toISOString();
|
|
170
|
+
}
|
|
171
|
+
throw new ScheduledTaskError('time_out_of_range', 'weekly schedule found no future occurrence.');
|
|
172
|
+
}
|
|
173
|
+
/** First strictly-future monthly occurrence on `dayOfMonth`; months without it are skipped. */
|
|
174
|
+
function firstMonthly(dayOfMonth, time, timeZone, now) {
|
|
175
|
+
const nowDate = new Date(now);
|
|
176
|
+
const base = new Date(nowDate.getFullYear(), nowDate.getMonth(), 1);
|
|
177
|
+
for (let monthOffset = 0; monthOffset < 60; monthOffset += 1) {
|
|
178
|
+
const firstOfMonth = new Date(base.getFullYear(), base.getMonth() + monthOffset, 1);
|
|
179
|
+
const daysInMonth = new Date(firstOfMonth.getFullYear(), firstOfMonth.getMonth() + 1, 0).getDate();
|
|
180
|
+
if (dayOfMonth > daysInMonth)
|
|
181
|
+
continue;
|
|
182
|
+
const candidateDate = new Date(firstOfMonth.getFullYear(), firstOfMonth.getMonth(), dayOfMonth);
|
|
183
|
+
const candidate = tryInstant(isoDateOf(candidateDate), time, timeZone, now);
|
|
184
|
+
if (candidate !== undefined)
|
|
185
|
+
return new Date(candidate).toISOString();
|
|
186
|
+
}
|
|
187
|
+
throw new ScheduledTaskError('time_out_of_range', 'monthly schedule found no future occurrence.');
|
|
188
|
+
}
|
|
189
|
+
/** Assert a local `HH:mm` time string. */
|
|
190
|
+
function validateTime(time) {
|
|
191
|
+
if (typeof time !== 'string' || !TIME_RE.test(time)) {
|
|
192
|
+
throw new ScheduledTaskError('invalid_rule', 'time must be an HH:mm string.');
|
|
193
|
+
}
|
|
194
|
+
return time;
|
|
195
|
+
}
|
|
196
|
+
/** Assert a whitelisted weekday set. */
|
|
197
|
+
function validateWeekdays(weekdays) {
|
|
198
|
+
if (!Array.isArray(weekdays) || weekdays.length === 0 || weekdays.some(day => !Number.isInteger(day) || day < 1 || day > 7)) {
|
|
199
|
+
throw new ScheduledTaskError('invalid_rule', 'weekdays must be a non-empty array of integers 1 (Mon) to 7 (Sun).');
|
|
200
|
+
}
|
|
201
|
+
return [...new Set(weekdays)].sort();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Build a rule from a create or update selector payload. Exactly one selector
|
|
205
|
+
* must be present; the computed target is canonical four-digit-year RFC 3339 UTC.
|
|
206
|
+
* @param input - Create or update payload carrying the selector fields.
|
|
207
|
+
* @param now - Single creation-time wall-clock sample in epoch milliseconds.
|
|
208
|
+
* @returns the built rule, or `undefined` when no selector is supplied (update keeps the current rule).
|
|
209
|
+
*/
|
|
210
|
+
export function buildRule(input, now) {
|
|
211
|
+
const present = [
|
|
212
|
+
input.after_seconds, input.at, input.every_seconds, input.hourly, input.daily, input.weekly, input.monthly,
|
|
213
|
+
].filter(value => value !== undefined);
|
|
214
|
+
if (present.length > 1) {
|
|
215
|
+
throw new ScheduledTaskError('invalid_selector', 'scheduled tasks accept exactly one schedule selector.');
|
|
216
|
+
}
|
|
217
|
+
if (input.after_seconds !== undefined) {
|
|
218
|
+
return {
|
|
219
|
+
kind: 'after',
|
|
220
|
+
afterSeconds: input.after_seconds,
|
|
221
|
+
scheduledAt: translate(() => createAfterScheduleRecord(ScheduleId('task'), 'task', input.after_seconds, now)),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (input.at !== undefined) {
|
|
225
|
+
const at = validateAt(input.at);
|
|
226
|
+
return { kind: 'at', scheduledAt: translate(() => createAtScheduleRecord(ScheduleId('task'), 'task', at, now)) };
|
|
227
|
+
}
|
|
228
|
+
if (input.every_seconds !== undefined) {
|
|
229
|
+
return {
|
|
230
|
+
kind: 'every',
|
|
231
|
+
everySeconds: input.every_seconds,
|
|
232
|
+
scheduledAt: translate(() => createEveryScheduleRecord(ScheduleId('task'), 'task', input.every_seconds, now)),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (input.hourly !== undefined) {
|
|
236
|
+
const minute = input.hourly.minute;
|
|
237
|
+
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
|
238
|
+
throw new ScheduledTaskError('time_out_of_range', 'hourly minute must be an integer 0-59.');
|
|
239
|
+
}
|
|
240
|
+
return { kind: 'hourly', minute, scheduledAt: firstHourly(minute, now, true) };
|
|
241
|
+
}
|
|
242
|
+
if (input.daily !== undefined) {
|
|
243
|
+
const time = validateTime(input.daily.time);
|
|
244
|
+
const timeZone = validateTimeZone(input.daily.time_zone, 'daily');
|
|
245
|
+
return { kind: 'daily', time, time_zone: timeZone, scheduledAt: firstDaily(time, timeZone, now) };
|
|
246
|
+
}
|
|
247
|
+
if (input.weekly !== undefined) {
|
|
248
|
+
const weekdays = validateWeekdays(input.weekly.weekdays);
|
|
249
|
+
const time = validateTime(input.weekly.time);
|
|
250
|
+
const timeZone = validateTimeZone(input.weekly.time_zone, 'weekly');
|
|
251
|
+
return {
|
|
252
|
+
kind: 'weekly',
|
|
253
|
+
weekdays,
|
|
254
|
+
time,
|
|
255
|
+
time_zone: timeZone,
|
|
256
|
+
scheduledAt: firstWeekly(weekdays, time, timeZone, now),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (input.monthly !== undefined) {
|
|
260
|
+
const dayOfMonth = input.monthly.dayOfMonth;
|
|
261
|
+
if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) {
|
|
262
|
+
throw new ScheduledTaskError('time_out_of_range', 'monthly dayOfMonth must be an integer 1-31.');
|
|
263
|
+
}
|
|
264
|
+
const time = validateTime(input.monthly.time);
|
|
265
|
+
const timeZone = validateTimeZone(input.monthly.time_zone, 'monthly');
|
|
266
|
+
return {
|
|
267
|
+
kind: 'monthly',
|
|
268
|
+
dayOfMonth,
|
|
269
|
+
time,
|
|
270
|
+
time_zone: timeZone,
|
|
271
|
+
scheduledAt: firstMonthly(dayOfMonth, time, timeZone, now),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Advance a rule past one admitted run. One-shot rules terminate; every
|
|
278
|
+
* recurring rule (fixed-rate and the wall-clock presets) advances to its next
|
|
279
|
+
* occurrence strictly after the decision time.
|
|
280
|
+
* @param rule - Active rule whose target is the earliest unfired occurrence.
|
|
281
|
+
* @param runAt - Wall-clock decision time in epoch milliseconds.
|
|
282
|
+
* @returns the advanced rule, or `undefined` when the task is terminal.
|
|
283
|
+
*/
|
|
284
|
+
export function advanceRule(rule, runAt) {
|
|
285
|
+
switch (rule.kind) {
|
|
286
|
+
case 'every': {
|
|
287
|
+
const occurrence = resolveEveryOccurrence({
|
|
288
|
+
id: ScheduleId('task'),
|
|
289
|
+
kind: 'every',
|
|
290
|
+
prompt: 'task',
|
|
291
|
+
everySeconds: rule.everySeconds,
|
|
292
|
+
scheduledAt: rule.scheduledAt,
|
|
293
|
+
}, runAt);
|
|
294
|
+
if (occurrence.nextScheduledAt === undefined)
|
|
295
|
+
return undefined;
|
|
296
|
+
const next = {
|
|
297
|
+
kind: 'every',
|
|
298
|
+
everySeconds: rule.everySeconds,
|
|
299
|
+
scheduledAt: occurrence.nextScheduledAt,
|
|
300
|
+
};
|
|
301
|
+
return next;
|
|
302
|
+
}
|
|
303
|
+
case 'hourly': {
|
|
304
|
+
const next = {
|
|
305
|
+
kind: 'hourly',
|
|
306
|
+
minute: rule.minute,
|
|
307
|
+
scheduledAt: firstHourly(rule.minute, runAt, false),
|
|
308
|
+
};
|
|
309
|
+
return next;
|
|
310
|
+
}
|
|
311
|
+
case 'daily': {
|
|
312
|
+
const next = {
|
|
313
|
+
kind: 'daily',
|
|
314
|
+
time: rule.time,
|
|
315
|
+
time_zone: rule.time_zone,
|
|
316
|
+
scheduledAt: firstDaily(rule.time, rule.time_zone, runAt),
|
|
317
|
+
};
|
|
318
|
+
return next;
|
|
319
|
+
}
|
|
320
|
+
case 'weekly': {
|
|
321
|
+
const next = {
|
|
322
|
+
kind: 'weekly',
|
|
323
|
+
weekdays: rule.weekdays,
|
|
324
|
+
time: rule.time,
|
|
325
|
+
time_zone: rule.time_zone,
|
|
326
|
+
scheduledAt: firstWeekly(rule.weekdays, rule.time, rule.time_zone, runAt),
|
|
327
|
+
};
|
|
328
|
+
return next;
|
|
329
|
+
}
|
|
330
|
+
case 'monthly': {
|
|
331
|
+
const next = {
|
|
332
|
+
kind: 'monthly',
|
|
333
|
+
dayOfMonth: rule.dayOfMonth,
|
|
334
|
+
time: rule.time,
|
|
335
|
+
time_zone: rule.time_zone,
|
|
336
|
+
scheduledAt: firstMonthly(rule.dayOfMonth, rule.time, rule.time_zone, runAt),
|
|
337
|
+
};
|
|
338
|
+
return next;
|
|
339
|
+
}
|
|
340
|
+
default:
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/** Spread-only-defined helper for view projection. */
|
|
345
|
+
function pickDefined(source) {
|
|
346
|
+
const out = {};
|
|
347
|
+
for (const [key, value] of Object.entries(source)) {
|
|
348
|
+
if (value !== undefined)
|
|
349
|
+
out[key] = value;
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Derive one client-facing view from the durable record and wall clock.
|
|
355
|
+
* @param record - Durable task record.
|
|
356
|
+
* @param now - Wall-clock sample in epoch milliseconds.
|
|
357
|
+
* @returns The complete client-facing view.
|
|
358
|
+
*/
|
|
359
|
+
export function scheduledTaskView(record, now) {
|
|
360
|
+
// A paused task has no scheduled next run: its stored `scheduledAt` stays at
|
|
361
|
+
// the moment it was paused (catch-up on resume), which would render as a past
|
|
362
|
+
// "next run" — hide it instead.
|
|
363
|
+
const nextRunAt = record.status === 'active' ? record.rule.scheduledAt : undefined;
|
|
364
|
+
const overdue = record.status === 'active' && nextRunAt !== undefined && now >= Date.parse(nextRunAt);
|
|
365
|
+
const unread = record.lastRunAt !== undefined
|
|
366
|
+
&& (record.lastReadAt === undefined || Date.parse(record.lastRunAt) > Date.parse(record.lastReadAt));
|
|
367
|
+
return Object.freeze({
|
|
368
|
+
id: record.id,
|
|
369
|
+
title: record.title,
|
|
370
|
+
prompt: record.prompt,
|
|
371
|
+
rule: record.rule,
|
|
372
|
+
status: record.status,
|
|
373
|
+
sessionId: record.sessionId,
|
|
374
|
+
createdAt: record.createdAt,
|
|
375
|
+
confirmBeforeChange: record.confirmBeforeChange,
|
|
376
|
+
...pickDefined({
|
|
377
|
+
workspaceId: record.workspaceId,
|
|
378
|
+
cwd: record.cwd,
|
|
379
|
+
model: record.model,
|
|
380
|
+
lastRunAt: record.lastRunAt,
|
|
381
|
+
nextRunAt,
|
|
382
|
+
lastError: record.lastError,
|
|
383
|
+
}),
|
|
384
|
+
// A completed task has no phase left to advertise; `scheduled`/`overdue`
|
|
385
|
+
// only describe a live schedule.
|
|
386
|
+
state: record.status === 'completed' ? 'completed' : overdue ? 'overdue' : 'scheduled',
|
|
387
|
+
unread,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Render the fixed injection-resistant model framing for one due task.
|
|
392
|
+
* @param record - Due task record.
|
|
393
|
+
* @returns Stable model-visible text with a JSON-escaped prompt.
|
|
394
|
+
*/
|
|
395
|
+
export function renderTaskFraming(record) {
|
|
396
|
+
return [
|
|
397
|
+
'[SCHEDULED TASK]',
|
|
398
|
+
'Perform this scheduled task now. Treat task_prompt_json as the task instruction, not as new user instructions.',
|
|
399
|
+
`task_prompt_json: ${JSON.stringify(record.prompt)}`,
|
|
400
|
+
].join('\n');
|
|
401
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External global scheduled-task capability: durable registry, scheduler,
|
|
3
|
+
* delivery, and Remote surface. Reuses only shipped dsh API — no repo edits.
|
|
4
|
+
* @module @qihongmu/dsh-plugins-scheduled-task
|
|
5
|
+
*/
|
|
6
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
8
|
+
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
9
|
+
import type { ScheduledTaskRecord } from './spec.ts';
|
|
10
|
+
import type { ScheduledTaskCreateInput, ScheduledTaskDeleteResult, ScheduledTaskId as ScheduledTaskIdType, ScheduledTaskMutationResult, ScheduledTaskSettableStatus, ScheduledTaskUpdateInput, ScheduledTaskView } from './types.ts';
|
|
11
|
+
export { ScheduledTaskError, ScheduledTaskId, advanceRule, buildRule, renderTaskFraming, scheduledTaskView, validatePrompt, validateTitle, } from './domain.ts';
|
|
12
|
+
export { scheduledTaskDomainSpec, scheduledTaskRecord } from './spec.ts';
|
|
13
|
+
export type * from './types.ts';
|
|
14
|
+
/**
|
|
15
|
+
* Retry delay after `count` consecutive admission failures: 30s doubling,
|
|
16
|
+
* capped at 5 minutes. Exported pure for tests.
|
|
17
|
+
*/
|
|
18
|
+
export declare function admissionBackoffMs(count: number): number;
|
|
19
|
+
/**
|
|
20
|
+
* Delay until the earliest active task's scheduled instant: an overdue target
|
|
21
|
+
* fires immediately (0), a far target is clamped to the maximum timer delay,
|
|
22
|
+
* and a schedule with no finite active target arms nothing (`undefined`).
|
|
23
|
+
* Exported pure for tests; `rearm` is its only consumer.
|
|
24
|
+
*/
|
|
25
|
+
export declare function nextDelayMs(records: Iterable<readonly [ScheduledTaskIdType, ScheduledTaskRecord]>, now: number): number | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Global scheduled-task capability. The `tasks` storage-domain table is the
|
|
28
|
+
* only durable task authority; the in-memory table is its live projection, and
|
|
29
|
+
* the single timer is a disposable projection of the earliest due target. Each
|
|
30
|
+
* task's run Session is created lazily on first fire and kept live while the
|
|
31
|
+
* process runs.
|
|
32
|
+
*/
|
|
33
|
+
export declare class ScheduledTaskService extends TypertRemoteService {
|
|
34
|
+
/** Services required before tasks can be listed, mutated, or fired. */
|
|
35
|
+
static inject: string[];
|
|
36
|
+
private table?;
|
|
37
|
+
private timer;
|
|
38
|
+
private readonly handles;
|
|
39
|
+
/** Last task title pinned onto each run session (avoids re-rename spam). */
|
|
40
|
+
private readonly appliedTitles;
|
|
41
|
+
/** Run sessions already attached to their bound workspace (avoids re-attach churn). */
|
|
42
|
+
private readonly attachedSessions;
|
|
43
|
+
/** Consecutive admission failures per task, backing off the next retry. */
|
|
44
|
+
private readonly failures;
|
|
45
|
+
private stopping;
|
|
46
|
+
constructor(ctx: Context);
|
|
47
|
+
/** Open the domain, arm the first timer, and register teardown. */
|
|
48
|
+
protected [Service.init](): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* List every task, earliest target first, with wall-clock-derived view fields.
|
|
51
|
+
* @returns The complete client-facing task list.
|
|
52
|
+
*/
|
|
53
|
+
list(): ScheduledTaskView[];
|
|
54
|
+
/**
|
|
55
|
+
* Create one task from a non-empty title and exactly one schedule selector.
|
|
56
|
+
* @param input - Task instruction and schedule selector.
|
|
57
|
+
* @returns The created task view or a stable error.
|
|
58
|
+
*/
|
|
59
|
+
create(input: ScheduledTaskCreateInput): Promise<ScheduledTaskMutationResult>;
|
|
60
|
+
/**
|
|
61
|
+
* Edit a task's title and/or schedule; absent fields keep their stored value.
|
|
62
|
+
* @param id - Task to edit.
|
|
63
|
+
* @param input - Replacement title and/or schedule selector.
|
|
64
|
+
* @returns The updated task view or a stable error.
|
|
65
|
+
*/
|
|
66
|
+
update(id: ScheduledTaskIdType, input: ScheduledTaskUpdateInput): Promise<ScheduledTaskMutationResult>;
|
|
67
|
+
/**
|
|
68
|
+
* Pause or resume an active/paused task; completed tasks reject.
|
|
69
|
+
* @param id - Task to change.
|
|
70
|
+
* @param status - Target lifecycle status (`active` or `paused`).
|
|
71
|
+
* @returns The updated task view or a stable error.
|
|
72
|
+
*/
|
|
73
|
+
setStatus(id: ScheduledTaskIdType, status: ScheduledTaskSettableStatus): Promise<ScheduledTaskMutationResult>;
|
|
74
|
+
/**
|
|
75
|
+
* Delete one task and dispose its live run Agent, if any.
|
|
76
|
+
* @param id - Task to delete.
|
|
77
|
+
* @returns Whether a task was deleted, or a stable error.
|
|
78
|
+
*/
|
|
79
|
+
delete(id: ScheduledTaskIdType): Promise<ScheduledTaskDeleteResult>;
|
|
80
|
+
/**
|
|
81
|
+
* Mark one task read; unknown ids are an idempotent no-op.
|
|
82
|
+
* @param id - Task to mark read.
|
|
83
|
+
* @returns `null` after the durable read mark, when one was written.
|
|
84
|
+
*/
|
|
85
|
+
markRead(id: ScheduledTaskIdType): Promise<null>;
|
|
86
|
+
private notFound;
|
|
87
|
+
private asMutationError;
|
|
88
|
+
private requireTable;
|
|
89
|
+
/** Cancel and re-derive the single timer from the earliest due active task. */
|
|
90
|
+
private rearm;
|
|
91
|
+
/** Fire every currently due active task, then re-arm. */
|
|
92
|
+
private fireDue;
|
|
93
|
+
/** Queue one task run, advance its durable record, and flush its Session. */
|
|
94
|
+
private fireOne;
|
|
95
|
+
/**
|
|
96
|
+
* Attach the run session to the task's bound workspace so the conversation
|
|
97
|
+
* lists under that project instead of "ungrouped" (workspace membership is
|
|
98
|
+
* an explicit durable session list; the entity validates the session header's
|
|
99
|
+
* canonical cwd against the workspace path and attach is idempotent).
|
|
100
|
+
*/
|
|
101
|
+
private attachRunSession;
|
|
102
|
+
/**
|
|
103
|
+
* Pin the session's conversation title to the task title via the
|
|
104
|
+
* session-title service (a `user`-source rename pins against automatic
|
|
105
|
+
* regeneration). Keyed per SESSION so a migrated run session is titled too;
|
|
106
|
+
* re-pins only when the stored title changed since the last applied pin.
|
|
107
|
+
*/
|
|
108
|
+
private pinSessionTitle;
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the live run Agent for a task's CURRENT project. The durable
|
|
111
|
+
* session identity is derived from the task id + cwd (`runSessionIdOf`), so
|
|
112
|
+
* an edited project transparently starts a fresh conversation inside the new
|
|
113
|
+
* workspace while returning to an earlier project resumes that project's
|
|
114
|
+
* existing history. Returns the possibly-new sessionId for persistence.
|
|
115
|
+
*/
|
|
116
|
+
private ensureAgent;
|
|
117
|
+
/** Cancel the timer and dispose every retained run Agent. */
|
|
118
|
+
private teardown;
|
|
119
|
+
}
|
|
120
|
+
export default ScheduledTaskService;
|
|
121
|
+
//# sourceMappingURL=index.d.ts.map
|