@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.
@@ -0,0 +1,506 @@
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
+ var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
7
+ var useValue = arguments.length > 2;
8
+ for (var i = 0; i < initializers.length; i++) {
9
+ value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
10
+ }
11
+ return useValue ? value : void 0;
12
+ };
13
+ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
14
+ function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
15
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
16
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
17
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
18
+ var _, done = false;
19
+ for (var i = decorators.length - 1; i >= 0; i--) {
20
+ var context = {};
21
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
22
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
23
+ context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
24
+ var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
25
+ if (kind === "accessor") {
26
+ if (result === void 0) continue;
27
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
28
+ if (_ = accept(result.get)) descriptor.get = _;
29
+ if (_ = accept(result.set)) descriptor.set = _;
30
+ if (_ = accept(result.init)) initializers.unshift(_);
31
+ }
32
+ else if (_ = accept(result)) {
33
+ if (kind === "field") initializers.unshift(_);
34
+ else descriptor[key] = _;
35
+ }
36
+ }
37
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
38
+ done = true;
39
+ };
40
+ import { createHash, randomUUID } from 'node:crypto';
41
+ import { Service } from '@deepseek-ai/cordis';
42
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
43
+ import { SessionId } from '@deepseek-ai/dsh-session';
44
+ import { WorkspaceId } from '@deepseek-ai/dsh-workspace';
45
+ import { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval';
46
+ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
47
+ import { advanceRule, buildRule, renderTaskFraming, ScheduledTaskError, ScheduledTaskId, scheduledTaskView, validatePrompt, validateTitle, } from "./domain.js";
48
+ import { scheduledTaskDomainSpec } from "./spec.js";
49
+ export { ScheduledTaskError, ScheduledTaskId, advanceRule, buildRule, renderTaskFraming, scheduledTaskView, validatePrompt, validateTitle, } from "./domain.js";
50
+ export { scheduledTaskDomainSpec, scheduledTaskRecord } from "./spec.js";
51
+ /** Largest delay Node timers represent without clamping. */
52
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
53
+ /** Render an unknown value for process-local diagnostics only. */
54
+ function renderThrown(value) {
55
+ return value instanceof Error ? value.message : String(value);
56
+ }
57
+ /**
58
+ * Retry delay after `count` consecutive admission failures: 30s doubling,
59
+ * capped at 5 minutes. Exported pure for tests.
60
+ */
61
+ export function admissionBackoffMs(count) {
62
+ return Math.min(30_000 * 2 ** (count - 1), 300_000);
63
+ }
64
+ /**
65
+ * Delay until the earliest active task's scheduled instant: an overdue target
66
+ * fires immediately (0), a far target is clamped to the maximum timer delay,
67
+ * and a schedule with no finite active target arms nothing (`undefined`).
68
+ * Exported pure for tests; `rearm` is its only consumer.
69
+ */
70
+ export function nextDelayMs(records, now) {
71
+ let earliest;
72
+ for (const [, record] of records) {
73
+ if (record.status !== 'active')
74
+ continue;
75
+ const target = Date.parse(record.rule.scheduledAt);
76
+ if (Number.isFinite(target) && (earliest === undefined || target < earliest))
77
+ earliest = target;
78
+ }
79
+ if (earliest === undefined)
80
+ return undefined;
81
+ return Math.min(Math.max(earliest - now, 0), MAX_TIMER_DELAY_MS);
82
+ }
83
+ /**
84
+ * Whether a resume failure means the run session simply is not persisted yet
85
+ * (first run of this task+project pair) — the only case where falling back to
86
+ * `create` is safe. Any other failure is rethrown so it surfaces as a visible
87
+ * task error instead of silently re-creating a session under the same id.
88
+ *
89
+ * The upstream agents service has no stable machine-readable "missing session"
90
+ * contract, so this matches the human-readable phrasings it emits today.
91
+ * Caveat: an upstream copy change silently breaks resume-vs-create
92
+ * discrimination (a truly missing session would then be rethrown as a visible
93
+ * task error, never silently re-created). If upstream ever exposes a stable
94
+ * error type/code, prefer that over this regex.
95
+ */
96
+ function isMissingSessionError(error) {
97
+ return /\bnot found\b|\bdoes not exist\b|\bno such session\b|\bunknown session\b/
98
+ .test(renderThrown(error).toLowerCase());
99
+ }
100
+ /**
101
+ * Deterministic run-session identity for one task+project pair. Deriving the id
102
+ * from the cwd makes a project edit migrate the task to a FRESH conversation in
103
+ * the new workspace (workspace listings index sessions by their header's
104
+ * canonical cwd), while toggling back to an earlier project re-attaches that
105
+ * project's existing history.
106
+ */
107
+ function runSessionIdOf(taskId, cwd) {
108
+ const digest = createHash('sha256').update(cwd).digest('hex').slice(0, 8);
109
+ return SessionId(`task-${taskId}-${digest}`);
110
+ }
111
+ /** Whether a table entry is a task currently due for admission. */
112
+ function isDue(record, now) {
113
+ return record.status === 'active' && Date.parse(record.rule.scheduledAt) <= now;
114
+ }
115
+ /** Merge optional carry fields (prompt/project/model/confirmation) onto a record. */
116
+ function applyCarry(target, carry) {
117
+ if (carry.prompt !== undefined)
118
+ target.prompt = validatePrompt(carry.prompt);
119
+ if (carry.confirmBeforeChange !== undefined)
120
+ target.confirmBeforeChange = carry.confirmBeforeChange;
121
+ if (carry.workspaceId !== undefined)
122
+ target.workspaceId = carry.workspaceId;
123
+ if (carry.cwd !== undefined)
124
+ target.cwd = carry.cwd;
125
+ if (carry.model !== undefined)
126
+ target.model = carry.model;
127
+ return target;
128
+ }
129
+ /**
130
+ * Global scheduled-task capability. The `tasks` storage-domain table is the
131
+ * only durable task authority; the in-memory table is its live projection, and
132
+ * the single timer is a disposable projection of the earliest due target. Each
133
+ * task's run Session is created lazily on first fire and kept live while the
134
+ * process runs.
135
+ */
136
+ let ScheduledTaskService = (() => {
137
+ let _classSuper = TypertRemoteService;
138
+ let _instanceExtraInitializers = [];
139
+ let _list_decorators;
140
+ let _create_decorators;
141
+ let _update_decorators;
142
+ let _setStatus_decorators;
143
+ let _delete_decorators;
144
+ let _markRead_decorators;
145
+ return class ScheduledTaskService extends _classSuper {
146
+ static {
147
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
148
+ _list_decorators = [Remote];
149
+ _create_decorators = [Remote];
150
+ _update_decorators = [Remote];
151
+ _setStatus_decorators = [Remote];
152
+ _delete_decorators = [Remote];
153
+ _markRead_decorators = [Remote];
154
+ __esDecorate(this, null, _list_decorators, { kind: "method", name: "list", static: false, private: false, access: { has: obj => "list" in obj, get: obj => obj.list }, metadata: _metadata }, null, _instanceExtraInitializers);
155
+ __esDecorate(this, null, _create_decorators, { kind: "method", name: "create", static: false, private: false, access: { has: obj => "create" in obj, get: obj => obj.create }, metadata: _metadata }, null, _instanceExtraInitializers);
156
+ __esDecorate(this, null, _update_decorators, { kind: "method", name: "update", static: false, private: false, access: { has: obj => "update" in obj, get: obj => obj.update }, metadata: _metadata }, null, _instanceExtraInitializers);
157
+ __esDecorate(this, null, _setStatus_decorators, { kind: "method", name: "setStatus", static: false, private: false, access: { has: obj => "setStatus" in obj, get: obj => obj.setStatus }, metadata: _metadata }, null, _instanceExtraInitializers);
158
+ __esDecorate(this, null, _delete_decorators, { kind: "method", name: "delete", static: false, private: false, access: { has: obj => "delete" in obj, get: obj => obj.delete }, metadata: _metadata }, null, _instanceExtraInitializers);
159
+ __esDecorate(this, null, _markRead_decorators, { kind: "method", name: "markRead", static: false, private: false, access: { has: obj => "markRead" in obj, get: obj => obj.markRead }, metadata: _metadata }, null, _instanceExtraInitializers);
160
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
161
+ }
162
+ /** Services required before tasks can be listed, mutated, or fired. */
163
+ static inject = ['storageDomain', 'agents', 'sessions', 'sessionTitle', 'workspaceRegistry'];
164
+ table = __runInitializers(this, _instanceExtraInitializers);
165
+ timer;
166
+ handles = new Map();
167
+ /** Last task title pinned onto each run session (avoids re-rename spam). */
168
+ appliedTitles = new Map();
169
+ /** Run sessions already attached to their bound workspace (avoids re-attach churn). */
170
+ attachedSessions = new Set();
171
+ /** Consecutive admission failures per task, backing off the next retry. */
172
+ failures = new Map();
173
+ stopping = false;
174
+ constructor(ctx) {
175
+ super(ctx, 'scheduledTasks');
176
+ }
177
+ /** Open the domain, arm the first timer, and register teardown. */
178
+ async [Service.init]() {
179
+ const domain = await this.ctx.storageDomain.open(scheduledTaskDomainSpec);
180
+ this.ctx.effect(() => () => domain.close(), 'scheduled-task.domainClose');
181
+ this.table = domain.table('tasks');
182
+ this.ctx.effect(() => () => { void this.teardown(); }, 'scheduled-task.teardown');
183
+ this.rearm();
184
+ }
185
+ /**
186
+ * List every task, earliest target first, with wall-clock-derived view fields.
187
+ * @returns The complete client-facing task list.
188
+ */
189
+ list() {
190
+ const now = Date.now();
191
+ return [...this.requireTable().entries()]
192
+ .map(([, record]) => scheduledTaskView(record, now))
193
+ .sort((left, right) => Date.parse(left.rule.scheduledAt) - Date.parse(right.rule.scheduledAt)
194
+ || String(left.id).localeCompare(String(right.id)));
195
+ }
196
+ /**
197
+ * Create one task from a non-empty title and exactly one schedule selector.
198
+ * @param input - Task instruction and schedule selector.
199
+ * @returns The created task view or a stable error.
200
+ */
201
+ async create(input) {
202
+ try {
203
+ const title = validateTitle(input.title);
204
+ const prompt = validatePrompt(input.prompt);
205
+ const rule = buildRule(input, Date.now());
206
+ if (rule === undefined) {
207
+ return { ok: false, code: 'invalid_selector', message: 'scheduled tasks accept exactly one of after_seconds, at, or every_seconds.' };
208
+ }
209
+ const id = ScheduledTaskId(randomUUID());
210
+ const record = applyCarry({
211
+ id,
212
+ title,
213
+ prompt,
214
+ rule,
215
+ status: 'active',
216
+ sessionId: SessionId(`task-${id}`),
217
+ createdAt: new Date().toISOString(),
218
+ confirmBeforeChange: false,
219
+ }, input);
220
+ await this.requireTable().put(id, record);
221
+ this.rearm();
222
+ return { ok: true, task: scheduledTaskView(record, Date.now()) };
223
+ }
224
+ catch (error) {
225
+ return this.asMutationError(error);
226
+ }
227
+ }
228
+ /**
229
+ * Edit a task's title and/or schedule; absent fields keep their stored value.
230
+ * @param id - Task to edit.
231
+ * @param input - Replacement title and/or schedule selector.
232
+ * @returns The updated task view or a stable error.
233
+ */
234
+ async update(id, input) {
235
+ try {
236
+ const record = this.requireTable().get(id);
237
+ if (record === undefined)
238
+ return this.notFound(id);
239
+ const title = input.title === undefined ? record.title : validateTitle(input.title);
240
+ const rule = buildRule(input, Date.now()) ?? record.rule;
241
+ const next = applyCarry({ ...record, title, rule }, input);
242
+ await this.requireTable().put(id, next);
243
+ this.rearm();
244
+ return { ok: true, task: scheduledTaskView(next, Date.now()) };
245
+ }
246
+ catch (error) {
247
+ return this.asMutationError(error);
248
+ }
249
+ }
250
+ /**
251
+ * Pause or resume an active/paused task; completed tasks reject.
252
+ * @param id - Task to change.
253
+ * @param status - Target lifecycle status (`active` or `paused`).
254
+ * @returns The updated task view or a stable error.
255
+ */
256
+ async setStatus(id, status) {
257
+ try {
258
+ const record = this.requireTable().get(id);
259
+ if (record === undefined)
260
+ return this.notFound(id);
261
+ if (record.status === 'completed') {
262
+ return { ok: false, code: 'invalid_rule', message: 'a completed task cannot change status.' };
263
+ }
264
+ const next = { ...record, status };
265
+ await this.requireTable().put(id, next);
266
+ this.rearm();
267
+ return { ok: true, task: scheduledTaskView(next, Date.now()) };
268
+ }
269
+ catch (error) {
270
+ return this.asMutationError(error);
271
+ }
272
+ }
273
+ /**
274
+ * Delete one task and dispose its live run Agent, if any.
275
+ * @param id - Task to delete.
276
+ * @returns Whether a task was deleted, or a stable error.
277
+ */
278
+ async delete(id) {
279
+ try {
280
+ const deleted = await this.requireTable().delete(id);
281
+ if (deleted) {
282
+ const handle = this.handles.get(id);
283
+ if (handle !== undefined) {
284
+ this.handles.delete(id);
285
+ await handle.dispose();
286
+ }
287
+ this.failures.delete(id);
288
+ this.rearm();
289
+ }
290
+ return { ok: true, deleted };
291
+ }
292
+ catch {
293
+ return { ok: false, code: 'internal_error', message: 'The scheduled task delete failed.' };
294
+ }
295
+ }
296
+ /**
297
+ * Mark one task read; unknown ids are an idempotent no-op.
298
+ * @param id - Task to mark read.
299
+ * @returns `null` after the durable read mark, when one was written.
300
+ */
301
+ async markRead(id) {
302
+ const record = this.requireTable().get(id);
303
+ if (record !== undefined && record.lastRunAt !== undefined) {
304
+ await this.requireTable().put(id, { ...record, lastReadAt: new Date().toISOString() });
305
+ }
306
+ return null;
307
+ }
308
+ notFound(id) {
309
+ return { ok: false, code: 'task_not_found', message: `no scheduled task '${id}'.` };
310
+ }
311
+ asMutationError(error) {
312
+ if (error instanceof ScheduledTaskError)
313
+ return { ok: false, code: error.code, message: error.message };
314
+ return { ok: false, code: 'internal_error', message: 'The scheduled task operation failed.' };
315
+ }
316
+ requireTable() {
317
+ if (this.table === undefined)
318
+ throw new Error('scheduled task service is not started yet');
319
+ return this.table;
320
+ }
321
+ /** Cancel and re-derive the single timer from the earliest due active task. */
322
+ rearm() {
323
+ if (this.timer !== undefined) {
324
+ clearTimeout(this.timer);
325
+ this.timer = undefined;
326
+ }
327
+ if (this.stopping)
328
+ return;
329
+ const table = this.table;
330
+ if (table === undefined)
331
+ return;
332
+ const delay = nextDelayMs(table.entries(), Date.now());
333
+ if (delay === undefined)
334
+ return;
335
+ this.timer = setTimeout(() => {
336
+ this.timer = undefined;
337
+ void this.fireDue(Date.now());
338
+ }, delay);
339
+ }
340
+ /** Fire every currently due active task, then re-arm. */
341
+ async fireDue(now) {
342
+ if (this.stopping)
343
+ return;
344
+ const due = [...this.requireTable().entries()].filter(([id, record]) => {
345
+ if (!isDue(record, now))
346
+ return false;
347
+ // Tasks in failure backoff wait for their retry window even while the
348
+ // stored schedule instant is still in the past.
349
+ return (this.failures.get(id)?.retryAfter ?? 0) <= now;
350
+ });
351
+ // Fire independently: one hanging admission must not delay the others.
352
+ await Promise.allSettled(due.map(([id, record]) => this.fireOne(id, record, now)));
353
+ this.rearm();
354
+ }
355
+ /** Queue one task run, advance its durable record, and flush its Session. */
356
+ async fireOne(id, record, now) {
357
+ try {
358
+ const { agent, sessionId } = await this.ensureAgent(record);
359
+ // Pin the conversation title to the task title (re-pins only on change,
360
+ // and retroactively fixes sessions created before this pinning existed).
361
+ this.pinSessionTitle(sessionId, record, agent);
362
+ // List the conversation under the bound workspace project (membership is
363
+ // an explicit per-workspace session list, not derived from cwd).
364
+ await this.attachRunSession(record, sessionId);
365
+ // Apply the stored confirm-before-change policy before the run's first step.
366
+ setApprovalPolicy(agent.session, record.confirmBeforeChange ? 'ask' : 'never');
367
+ agent.followup(createUserMessage({
368
+ content: [{ type: 'text', text: renderTaskFraming(record) }],
369
+ source: { kind: 'plugin', plugin: 'scheduled-task' },
370
+ }));
371
+ const runAt = new Date(now).toISOString();
372
+ const advanced = advanceRule(record.rule, now);
373
+ const next = {
374
+ ...record,
375
+ sessionId,
376
+ rule: advanced ?? record.rule,
377
+ status: advanced === undefined ? 'completed' : record.status,
378
+ lastRunAt: runAt,
379
+ lastError: undefined,
380
+ };
381
+ this.failures.delete(id);
382
+ await this.requireTable().put(id, next);
383
+ await this.ctx.sessions.flush(agent.session);
384
+ }
385
+ catch (error) {
386
+ const message = renderThrown(error);
387
+ this.ctx.logger.warn(`scheduled-task: could not fire task "${id}": ${message}`);
388
+ // Record the failure on the task and back off the retry (30s doubling to
389
+ // 5min) so a persistently failing admission does not hot-loop while its
390
+ // schedule instant stays in the past. In-memory only; a restart retries
391
+ // immediately.
392
+ const previous = this.failures.get(id)?.count ?? 0;
393
+ const count = previous + 1;
394
+ const retryAfter = now + admissionBackoffMs(count);
395
+ this.failures.set(id, { count, retryAfter });
396
+ try {
397
+ await this.requireTable().put(id, {
398
+ ...record,
399
+ lastError: { at: new Date(now).toISOString(), message },
400
+ });
401
+ }
402
+ catch (persistError) {
403
+ this.ctx.logger.warn(`scheduled-task: could not persist failure of task "${id}": ${renderThrown(persistError)}`);
404
+ }
405
+ }
406
+ }
407
+ /**
408
+ * Attach the run session to the task's bound workspace so the conversation
409
+ * lists under that project instead of "ungrouped" (workspace membership is
410
+ * an explicit durable session list; the entity validates the session header's
411
+ * canonical cwd against the workspace path and attach is idempotent).
412
+ */
413
+ async attachRunSession(record, sessionId) {
414
+ if (record.workspaceId === undefined)
415
+ return;
416
+ const key = `${record.workspaceId}:${sessionId}`;
417
+ if (this.attachedSessions.has(key))
418
+ return;
419
+ const workspace = this.ctx.workspaceRegistry.get(WorkspaceId(record.workspaceId));
420
+ if (workspace === undefined) {
421
+ this.ctx.logger.warn(`scheduled-task: workspace "${record.workspaceId}" not found for task "${record.id}"`);
422
+ return;
423
+ }
424
+ try {
425
+ await workspace.attachSession(sessionId);
426
+ this.attachedSessions.add(key);
427
+ }
428
+ catch (error) {
429
+ this.ctx.logger.warn(`scheduled-task: could not attach session "${sessionId}" to workspace: ${renderThrown(error)}`);
430
+ }
431
+ }
432
+ /**
433
+ * Pin the session's conversation title to the task title via the
434
+ * session-title service (a `user`-source rename pins against automatic
435
+ * regeneration). Keyed per SESSION so a migrated run session is titled too;
436
+ * re-pins only when the stored title changed since the last applied pin.
437
+ */
438
+ pinSessionTitle(sessionId, record, agent) {
439
+ if (this.appliedTitles.get(sessionId) === record.title)
440
+ return;
441
+ const titles = this.ctx.sessionTitle;
442
+ try {
443
+ titles.rename(agent.session, record.title);
444
+ this.appliedTitles.set(sessionId, record.title);
445
+ }
446
+ catch (error) {
447
+ this.ctx.logger.warn(`scheduled-task: could not title session "${sessionId}": ${renderThrown(error)}`);
448
+ }
449
+ }
450
+ /**
451
+ * Resolve the live run Agent for a task's CURRENT project. The durable
452
+ * session identity is derived from the task id + cwd (`runSessionIdOf`), so
453
+ * an edited project transparently starts a fresh conversation inside the new
454
+ * workspace while returning to an earlier project resumes that project's
455
+ * existing history. Returns the possibly-new sessionId for persistence.
456
+ */
457
+ async ensureAgent(record) {
458
+ const desiredCwd = record.cwd ?? process.cwd();
459
+ const targetId = runSessionIdOf(record.id, desiredCwd);
460
+ const agentOptions = record.model === undefined
461
+ ? undefined
462
+ : { provider: record.model.provider, model: record.model.model };
463
+ // A retained run handle is reused only when it still matches the target.
464
+ const retained = this.handles.get(record.id);
465
+ if (retained !== undefined) {
466
+ if (retained.agent.session.id === targetId && retained.agent.session.header.cwd === desiredCwd) {
467
+ return { agent: retained.agent, sessionId: targetId };
468
+ }
469
+ this.handles.delete(record.id);
470
+ await retained.dispose();
471
+ }
472
+ let handle;
473
+ try {
474
+ handle = await this.ctx.agents.resume({
475
+ resumeSessionId: targetId,
476
+ ...agentOptions === undefined ? {} : { agentOptions },
477
+ });
478
+ }
479
+ catch (resumeError) {
480
+ if (!isMissingSessionError(resumeError))
481
+ throw resumeError;
482
+ // Not persisted yet (first run for this task+project pair): create it in-project.
483
+ handle = await this.ctx.agents.create({
484
+ sessionId: targetId,
485
+ meta: { cwd: desiredCwd },
486
+ ...agentOptions === undefined ? {} : { agentOptions },
487
+ });
488
+ }
489
+ this.handles.set(record.id, handle);
490
+ return { agent: handle.agent, sessionId: targetId };
491
+ }
492
+ /** Cancel the timer and dispose every retained run Agent. */
493
+ async teardown() {
494
+ this.stopping = true;
495
+ if (this.timer !== undefined) {
496
+ clearTimeout(this.timer);
497
+ this.timer = undefined;
498
+ }
499
+ const handles = [...this.handles.values()];
500
+ this.handles.clear();
501
+ await Promise.allSettled(handles.map(handle => handle.dispose()));
502
+ }
503
+ };
504
+ })();
505
+ export { ScheduledTaskService };
506
+ export default ScheduledTaskService;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for the external scheduled-task plugin.
3
+ * @module @qihongmu/dsh-plugins-scheduled-task/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis invariant-companion plugin name. */
7
+ export declare const name = "scheduled-task-invariant";
8
+ /** Service required before reserving this package's invariant ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register the package-owned invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant registry.
13
+ * @returns Exact registration disposer after child setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Package-owned invariant companion for the external scheduled-task plugin.
3
+ * @module @qihongmu/dsh-plugins-scheduled-task/invariant
4
+ */
5
+ const PACKAGE_NAME = '@qihongmu/dsh-plugins-scheduled-task';
6
+ /** Cordis invariant-companion plugin name. */
7
+ export const name = 'scheduled-task-invariant';
8
+ /** Service required before reserving this package's invariant ownership. */
9
+ export const inject = ['invariants'];
10
+ /**
11
+ * Owned relationship: a task enters `completed` only after its run recorded
12
+ * `lastRunAt`. The service sets both in one durable put, so a completed record
13
+ * without a run timestamp proves a write path bypassed the scheduler.
14
+ */
15
+ const install = Object.assign((ctx, fail) => {
16
+ ctx.on('domain/changed', (change) => {
17
+ if (change.domain !== 'scheduled_task' || change.table !== 'tasks' || change.operation !== 'put')
18
+ return;
19
+ const record = change.value;
20
+ if (record.status === 'completed' && record.lastRunAt === undefined) {
21
+ fail(`scheduled task '${String(record.id)}' entered completed without lastRunAt`);
22
+ }
23
+ });
24
+ }, { inject: [] });
25
+ /**
26
+ * Register the package-owned invariant companion.
27
+ * @param ctx - Cordis context carrying the invariant registry.
28
+ * @returns Exact registration disposer after child setup succeeds.
29
+ */
30
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));