@qilin-agent/schedule 0.0.0-stage → 3.1.3
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/LICENSE +21 -0
- package/README.i18n.yaml +76 -0
- package/README.md +170 -2
- package/README.zh.md +171 -0
- package/lib/index.js +2271 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +734 -0
- package/lib/typert.remote-client.d.ts +30 -0
- package/lib/typert.remote-client.js +480 -0
- package/lib/types/client.d.ts +3 -0
- package/lib/types/client.js +2 -0
- package/lib/types/delivery-history.d.ts +24 -0
- package/lib/types/delivery-history.js +66 -0
- package/lib/types/domain.d.ts +309 -0
- package/lib/types/domain.js +1587 -0
- package/lib/types/index.d.ts +134 -0
- package/lib/types/index.js +416 -0
- package/lib/types/runtime.d.ts +42 -0
- package/lib/types/runtime.js +168 -0
- package/lib/types/storage.d.ts +59 -0
- package/lib/types/storage.js +54 -0
- package/lib/types/types.d.ts +422 -0
- package/lib/types/types.js +6 -0
- package/lib/types/update.d.ts +26 -0
- package/lib/types/update.js +164 -0
- package/package.json +84 -3
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import z from '@qilin-agent/schemastery';
|
|
2
|
+
import { Context, Service } from '@qilin-agent/kylin';
|
|
3
|
+
import { TypertRemoteService } from '@qilin-agent/typert-protocol';
|
|
4
|
+
import type { SessionId } from '@qilin-agent/session';
|
|
5
|
+
import type { ScheduleCatalogEntry, ScheduleCreateRequest, ScheduleDeleteRequest, ScheduleDeleteResult, ScheduleDeliveryHistoryRequest, ScheduleDeliveryHistoryResult, ScheduleListRequest, ScheduleRecord, ScheduleUpdateRequest, ScheduleUpdateResult } from './types.ts';
|
|
6
|
+
export type * from './types.ts';
|
|
7
|
+
export { scheduleDomain } from './storage.ts';
|
|
8
|
+
export type { ScheduleTask } from './storage.ts';
|
|
9
|
+
export type { RecurringOccurrence } from './domain.ts';
|
|
10
|
+
export { SCHEDULE_CHANGE_VERSION, MIN_EVERY_INTERVAL_SECONDS, MAX_TITLE_LENGTH, REQUIRED_TITLE_MESSAGE, ScheduleId, ScheduleInputError, ScheduleLogError, canonicalizeCronExpression, createAfterScheduleRecord, createAtScheduleRecord, createEveryScheduleRecord, createDailyScheduleRecord, createWeeklyScheduleRecord, createCronScheduleRecord, decodeScheduleChange, decodeScheduleRecord, foldScheduleEvents, isRecurringScheduleRecord, normalizeWeekdays, parseCronInput, parseWeeklyInput, renderReminderFraming, renderRecurringReminderBatchFraming, resolveEveryOccurrence, resolveDailyOccurrence, resolveWeeklyOccurrence, resolveCronOccurrence, resolveRecurringOccurrence, scheduleTitle, scheduleView, weeklyTime, } from './domain.ts';
|
|
11
|
+
declare module '@qilin-agent/kylin' {
|
|
12
|
+
interface Context {
|
|
13
|
+
/** Durable Host-wide reminder management. */
|
|
14
|
+
schedule: ScheduleService;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Configuration for the Host Schedule domain. */
|
|
18
|
+
export interface Config {
|
|
19
|
+
/**
|
|
20
|
+
* Delivery-history window retained per task, in days; omission defaults to 30.
|
|
21
|
+
* Pruning happens when an acknowledgment is appended, and `lastDelivery` is always retained.
|
|
22
|
+
*/
|
|
23
|
+
deliveryHistoryDays?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Retained delivery records per task; omission defaults to 200. The older of this
|
|
26
|
+
* cap and the window wins, and the newest records survive.
|
|
27
|
+
*/
|
|
28
|
+
deliveryHistoryRecords?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Shared management service; reads, deletion, and timing edits never activate a Session.
|
|
32
|
+
*
|
|
33
|
+
* `sessionPersistence` is a load-order requirement rather than a directly called
|
|
34
|
+
* service: a delivery commits only when `ctx.sessions.flush()` reports that a
|
|
35
|
+
* `session/flush` listener participated, and the persistence backend providing this
|
|
36
|
+
* service is the plugin that registers that listener.
|
|
37
|
+
*/
|
|
38
|
+
export declare class ScheduleService extends TypertRemoteService {
|
|
39
|
+
static inject: string[];
|
|
40
|
+
static Config: z<Config>;
|
|
41
|
+
/** Resolved retention bounds shared with the runtime that appends acknowledgments. */
|
|
42
|
+
private readonly retention;
|
|
43
|
+
private readonly ready;
|
|
44
|
+
private readonly initialized;
|
|
45
|
+
private chain;
|
|
46
|
+
private runtime;
|
|
47
|
+
private stopping;
|
|
48
|
+
/**
|
|
49
|
+
* @param ctx - Host services owning storage, dispatch, and Session restoration.
|
|
50
|
+
* @param config - Validated retention configuration for delivery history.
|
|
51
|
+
*/
|
|
52
|
+
constructor(ctx: Context, config: Config);
|
|
53
|
+
[Service.init](): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Create a reminder bound to the caller-selected Session without activating it.
|
|
56
|
+
*
|
|
57
|
+
* The request must supply a title; a missing, blank-after-trim, or over-long
|
|
58
|
+
* title rejects with `invalid_prompt` instead of deriving one from the prompt.
|
|
59
|
+
* The record is built from the clock reading taken before the request joins the
|
|
60
|
+
* serialized queue, so a create that waits behind a longer operation keeps its
|
|
61
|
+
* request-time anchor and may already be due when the queue reaches it.
|
|
62
|
+
* @param sessionId - Original Session receiving the reminder.
|
|
63
|
+
* @param request - Validated tool selector, required title, and reminder content.
|
|
64
|
+
* @param signal - Optional cancellation checked before persistence begins, including after FIFO waits.
|
|
65
|
+
* @returns The durably stored schedule. Cancellation does not roll back an in-flight write.
|
|
66
|
+
*/
|
|
67
|
+
create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: AbortSignal): Promise<ScheduleRecord>;
|
|
68
|
+
/**
|
|
69
|
+
* Read the selected Session's active tasks without resuming its Agent.
|
|
70
|
+
* @param request - Session whose task list is requested.
|
|
71
|
+
* @returns Persisted reminders in storage order.
|
|
72
|
+
*/
|
|
73
|
+
list(request: ScheduleListRequest): Promise<ScheduleRecord[]>;
|
|
74
|
+
/**
|
|
75
|
+
* Read all active and inactive Host reminders with their original Session bindings.
|
|
76
|
+
* A deleted reminder has no row, so it is absent here.
|
|
77
|
+
* Does not activate Sessions or read Session history.
|
|
78
|
+
* @returns Reminders ordered by scheduledAt ascending, then lexicographically by id.
|
|
79
|
+
*/
|
|
80
|
+
catalog(): Promise<ScheduleCatalogEntry[]>;
|
|
81
|
+
/**
|
|
82
|
+
* Read saved inbox deliveries without activating or reading the original Session.
|
|
83
|
+
* The task's own row supplies its binding, so its records stay readable through this lookup.
|
|
84
|
+
* @param request - Session binding, task identity, explicit limit, and optional exclusive message cursor.
|
|
85
|
+
* @returns Newest-first deliveries in append order, or a task/cursor lookup failure.
|
|
86
|
+
* @throws ScheduleInputError when limit is not a safe integer from 1 through 100.
|
|
87
|
+
*/
|
|
88
|
+
history(request: ScheduleDeliveryHistoryRequest): Promise<ScheduleDeliveryHistoryResult>;
|
|
89
|
+
/**
|
|
90
|
+
* Delete one task belonging to the selected Session, leaving queued messages intact.
|
|
91
|
+
*
|
|
92
|
+
* The row is removed: the task no longer schedules, leaves `list` and `catalog`, and its
|
|
93
|
+
* saved delivery records go with it.
|
|
94
|
+
* @param request - Session and exact task identity.
|
|
95
|
+
* @param signal - Optional cancellation checked before persistence begins, including after FIFO waits.
|
|
96
|
+
* @returns Whether that Session owned a deleted task. Cancellation does not roll back an in-flight write.
|
|
97
|
+
*/
|
|
98
|
+
delete(request: ScheduleDeleteRequest, signal?: AbortSignal): Promise<ScheduleDeleteResult>;
|
|
99
|
+
/**
|
|
100
|
+
* Update the name, instruction, and timing of an active task within the original Session
|
|
101
|
+
* binding without activating the Session or changing saved deliveries.
|
|
102
|
+
*
|
|
103
|
+
* Each supplied field replaces its stored value; an omitted field keeps it. A name or
|
|
104
|
+
* instruction change alone does not reset the committed target.
|
|
105
|
+
* @param request - Task binding, complete observed record, and any combination of timing, name, and instruction.
|
|
106
|
+
* @param signal - Cancellation checked after domain readiness and FIFO waits, before persistence begins.
|
|
107
|
+
* @returns The committed record, unchanged record for a no-op, or a non-mutating input/lookup/conflict result.
|
|
108
|
+
* Storage and lifecycle failures reject; cancellation after a write starts does not roll it back.
|
|
109
|
+
*/
|
|
110
|
+
update(request: ScheduleUpdateRequest, signal?: AbortSignal): Promise<ScheduleUpdateResult>;
|
|
111
|
+
/**
|
|
112
|
+
* Dispatch one post-commit `schedule/changed` notification, containing
|
|
113
|
+
* synchronous listener failures: every call site emits only after its durable
|
|
114
|
+
* task write landed, so a throwing listener must not reject the caller or
|
|
115
|
+
* skip the following `requestDrive()`.
|
|
116
|
+
*/
|
|
117
|
+
private emitChanged;
|
|
118
|
+
private getDomain;
|
|
119
|
+
/**
|
|
120
|
+
* Remove every active task stored for one Session, inside the queue the tools
|
|
121
|
+
* use.
|
|
122
|
+
*
|
|
123
|
+
* Enumerating and deleting in one queue slot is what makes an archive stop
|
|
124
|
+
* ordered behind a create whose write is still in flight: a stop that read the
|
|
125
|
+
* table outside the queue could miss a row the create was about to commit and
|
|
126
|
+
* leave an armed reminder behind. Re-entering the public `delete()` from here
|
|
127
|
+
* would deadlock on this queue, so the rows are removed directly.
|
|
128
|
+
* @param sessionId - Session whose active Host tasks must stop.
|
|
129
|
+
*/
|
|
130
|
+
private stopSessionTasks;
|
|
131
|
+
private serialize;
|
|
132
|
+
}
|
|
133
|
+
export default ScheduleService;
|
|
134
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
2
|
+
var useValue = arguments.length > 2;
|
|
3
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
4
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
5
|
+
}
|
|
6
|
+
return useValue ? value : void 0;
|
|
7
|
+
};
|
|
8
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
9
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
10
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
11
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
12
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
13
|
+
var _, done = false;
|
|
14
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
15
|
+
var context = {};
|
|
16
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
17
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
18
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
19
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
20
|
+
if (kind === "accessor") {
|
|
21
|
+
if (result === void 0) continue;
|
|
22
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
23
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
24
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
25
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
26
|
+
}
|
|
27
|
+
else if (_ = accept(result)) {
|
|
28
|
+
if (kind === "field") initializers.unshift(_);
|
|
29
|
+
else descriptor[key] = _;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
33
|
+
done = true;
|
|
34
|
+
};
|
|
35
|
+
/** Host-wide durable reminders and shared human/model management. */
|
|
36
|
+
import { randomUUID } from 'node:crypto';
|
|
37
|
+
import z from '@qilin-agent/schemastery';
|
|
38
|
+
import { Service } from '@qilin-agent/kylin';
|
|
39
|
+
import { TypertRemoteService, Remote } from '@qilin-agent/typert-protocol';
|
|
40
|
+
import { ScheduleRuntime } from "./runtime.js";
|
|
41
|
+
import { scheduleDomain } from "./storage.js";
|
|
42
|
+
import { deliveryHistoryPage } from "./delivery-history.js";
|
|
43
|
+
import { resolveScheduleUpdate } from "./update.js";
|
|
44
|
+
import { foldScheduleEvents, ScheduleInputError, ScheduleLogError, ScheduleId, createAfterScheduleRecord, createAtScheduleRecord, createEveryScheduleRecord, createDailyScheduleRecord, createWeeklyScheduleRecord, createCronScheduleRecord, scheduleTitle, } from "./domain.js";
|
|
45
|
+
export { scheduleDomain } from "./storage.js";
|
|
46
|
+
export { SCHEDULE_CHANGE_VERSION, MIN_EVERY_INTERVAL_SECONDS, MAX_TITLE_LENGTH, REQUIRED_TITLE_MESSAGE, ScheduleId, ScheduleInputError, ScheduleLogError, canonicalizeCronExpression, createAfterScheduleRecord, createAtScheduleRecord, createEveryScheduleRecord, createDailyScheduleRecord, createWeeklyScheduleRecord, createCronScheduleRecord, decodeScheduleChange, decodeScheduleRecord, foldScheduleEvents, isRecurringScheduleRecord, normalizeWeekdays, parseCronInput, parseWeeklyInput, renderReminderFraming, renderRecurringReminderBatchFraming, resolveEveryOccurrence, resolveDailyOccurrence, resolveWeeklyOccurrence, resolveCronOccurrence, resolveRecurringOccurrence, scheduleTitle, scheduleView, weeklyTime, } from "./domain.js";
|
|
47
|
+
/** Retained delivery-history window applied when the deployment states none. */
|
|
48
|
+
const DEFAULT_DELIVERY_HISTORY_DAYS = 30;
|
|
49
|
+
/** Retained delivery-history record cap applied when the deployment states none. */
|
|
50
|
+
const DEFAULT_DELIVERY_HISTORY_RECORDS = 200;
|
|
51
|
+
/**
|
|
52
|
+
* Shared management service; reads, deletion, and timing edits never activate a Session.
|
|
53
|
+
*
|
|
54
|
+
* `sessionPersistence` is a load-order requirement rather than a directly called
|
|
55
|
+
* service: a delivery commits only when `ctx.sessions.flush()` reports that a
|
|
56
|
+
* `session/flush` listener participated, and the persistence backend providing this
|
|
57
|
+
* service is the plugin that registers that listener.
|
|
58
|
+
*/
|
|
59
|
+
let ScheduleService = (() => {
|
|
60
|
+
let _classSuper = TypertRemoteService;
|
|
61
|
+
let _instanceExtraInitializers = [];
|
|
62
|
+
let _list_decorators;
|
|
63
|
+
let _catalog_decorators;
|
|
64
|
+
let _history_decorators;
|
|
65
|
+
let _delete_decorators;
|
|
66
|
+
let _update_decorators;
|
|
67
|
+
return class ScheduleService extends _classSuper {
|
|
68
|
+
static {
|
|
69
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
70
|
+
_list_decorators = [Remote('list')];
|
|
71
|
+
_catalog_decorators = [Remote('catalog')];
|
|
72
|
+
_history_decorators = [Remote('history')];
|
|
73
|
+
_delete_decorators = [Remote('delete')];
|
|
74
|
+
_update_decorators = [Remote('update')];
|
|
75
|
+
__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);
|
|
76
|
+
__esDecorate(this, null, _catalog_decorators, { kind: "method", name: "catalog", static: false, private: false, access: { has: obj => "catalog" in obj, get: obj => obj.catalog }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
77
|
+
__esDecorate(this, null, _history_decorators, { kind: "method", name: "history", static: false, private: false, access: { has: obj => "history" in obj, get: obj => obj.history }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
78
|
+
__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);
|
|
79
|
+
__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);
|
|
80
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
81
|
+
}
|
|
82
|
+
static inject = ['agents', 'sessions', 'tools', 'storageDomain', 'sessionController', 'sessionPersistence'];
|
|
83
|
+
static Config = z.object({
|
|
84
|
+
deliveryHistoryDays: z.number().step(1).min(1).max(3650).default(DEFAULT_DELIVERY_HISTORY_DAYS),
|
|
85
|
+
deliveryHistoryRecords: z.number().step(1).min(1).max(10_000).default(DEFAULT_DELIVERY_HISTORY_RECORDS),
|
|
86
|
+
});
|
|
87
|
+
/** Resolved retention bounds shared with the runtime that appends acknowledgments. */
|
|
88
|
+
retention = __runInitializers(this, _instanceExtraInitializers);
|
|
89
|
+
ready;
|
|
90
|
+
initialized;
|
|
91
|
+
chain = Promise.resolve();
|
|
92
|
+
runtime;
|
|
93
|
+
stopping = false;
|
|
94
|
+
/**
|
|
95
|
+
* @param ctx - Host services owning storage, dispatch, and Session restoration.
|
|
96
|
+
* @param config - Validated retention configuration for delivery history.
|
|
97
|
+
*/
|
|
98
|
+
constructor(ctx, config) {
|
|
99
|
+
super(ctx, 'schedule');
|
|
100
|
+
this.retention = {
|
|
101
|
+
days: config.deliveryHistoryDays ?? DEFAULT_DELIVERY_HISTORY_DAYS,
|
|
102
|
+
records: config.deliveryHistoryRecords ?? DEFAULT_DELIVERY_HISTORY_RECORDS,
|
|
103
|
+
};
|
|
104
|
+
this.ready = ctx.storageDomain.open(scheduleDomain).then(async (domain) => {
|
|
105
|
+
for (const [key, task] of domain.table('tasks').entries()) {
|
|
106
|
+
if (key !== task.record.id) {
|
|
107
|
+
// The mismatch is the actionable failure; a rejecting close must not replace it.
|
|
108
|
+
try {
|
|
109
|
+
await domain.close();
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
ctx.logger.warn(`schedule: closing the domain after a key mismatch failed: ${String(error)}`);
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`schedule: stored task key "${key}" differs from record id "${task.record.id}"`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return domain;
|
|
118
|
+
});
|
|
119
|
+
this.initialized = ctx.effect(async () => {
|
|
120
|
+
const domain = await this.ready;
|
|
121
|
+
let cleanup;
|
|
122
|
+
try {
|
|
123
|
+
cleanup = ctx.effect(() => async () => {
|
|
124
|
+
this.stopping = true;
|
|
125
|
+
await this.runtime?.dispose();
|
|
126
|
+
await this.chain; // The chain contains failures after returning them to their callers.
|
|
127
|
+
await domain.close();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
this.stopping = true;
|
|
132
|
+
await domain.close();
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
const tasks = domain.table('tasks');
|
|
136
|
+
this.runtime = new ScheduleRuntime(ctx, () => [...tasks.entries()].map(([, task]) => task), work => this.serialize(work), async (task) => {
|
|
137
|
+
await tasks.put(task.record.id, task);
|
|
138
|
+
this.emitChanged();
|
|
139
|
+
}, this.retention);
|
|
140
|
+
this.runtime.requestDrive();
|
|
141
|
+
return cleanup;
|
|
142
|
+
});
|
|
143
|
+
ctx.on('session/created', (session) => {
|
|
144
|
+
// Historical Schedule events remain readable but do not populate Host tasks.
|
|
145
|
+
// A throwing `session/created` listener rolls the attach back, so an unreadable
|
|
146
|
+
// legacy stream must warn here instead of blocking Session creation.
|
|
147
|
+
let activeLegacy = 0;
|
|
148
|
+
try {
|
|
149
|
+
// oxlint-disable-next-line typescript/no-deprecated -- Explicit warning for legacy Schedule history.
|
|
150
|
+
activeLegacy = foldScheduleEvents(session.ownEvents()).active.length;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
/* v8 ignore next -- foldScheduleEvents normalizes every rejected stream to ScheduleLogError. */
|
|
154
|
+
if (!(error instanceof ScheduleLogError))
|
|
155
|
+
throw error;
|
|
156
|
+
ctx.logger.warn(`schedule: Session "${session.id}" historical events could not be read (${error.message}); the legacy reminder is ignored.`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (activeLegacy > 0) {
|
|
160
|
+
ctx.logger.warn(`schedule: Session "${session.id}" contains legacy reminders; recreate active reminders with schedule_create.`);
|
|
161
|
+
}
|
|
162
|
+
}, { global: true });
|
|
163
|
+
// Host tasks outlive their Session's Agent, so archive admission reads the
|
|
164
|
+
// stored rows rather than a live runtime: an idle Session whose reminders
|
|
165
|
+
// are still armed refuses the archive, and an archiving stop deletes those
|
|
166
|
+
// rows instead of letting them deliver into a closed Session.
|
|
167
|
+
ctx.effect(() => {
|
|
168
|
+
const activity = ctx.on('workspace/session-activity', async ({ sessionId }, next) => {
|
|
169
|
+
const active = await this.list({ sessionId });
|
|
170
|
+
const rest = await next();
|
|
171
|
+
if (active.length === 0)
|
|
172
|
+
return rest;
|
|
173
|
+
const own = {
|
|
174
|
+
kind: 'schedule',
|
|
175
|
+
items: active.map(record => ({ id: record.id, label: record.title })),
|
|
176
|
+
};
|
|
177
|
+
return [own, ...rest];
|
|
178
|
+
});
|
|
179
|
+
const stop = ctx.on('workspace/session-stop', async ({ sessionId }) => {
|
|
180
|
+
await this.stopSessionTasks(sessionId);
|
|
181
|
+
});
|
|
182
|
+
return () => {
|
|
183
|
+
stop();
|
|
184
|
+
activity();
|
|
185
|
+
};
|
|
186
|
+
}, 'schedule.archiveAdmission()');
|
|
187
|
+
}
|
|
188
|
+
async [Service.init]() {
|
|
189
|
+
await this.initialized;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Create a reminder bound to the caller-selected Session without activating it.
|
|
193
|
+
*
|
|
194
|
+
* The request must supply a title; a missing, blank-after-trim, or over-long
|
|
195
|
+
* title rejects with `invalid_prompt` instead of deriving one from the prompt.
|
|
196
|
+
* The record is built from the clock reading taken before the request joins the
|
|
197
|
+
* serialized queue, so a create that waits behind a longer operation keeps its
|
|
198
|
+
* request-time anchor and may already be due when the queue reaches it.
|
|
199
|
+
* @param sessionId - Original Session receiving the reminder.
|
|
200
|
+
* @param request - Validated tool selector, required title, and reminder content.
|
|
201
|
+
* @param signal - Optional cancellation checked before persistence begins, including after FIFO waits.
|
|
202
|
+
* @returns The durably stored schedule. Cancellation does not roll back an in-flight write.
|
|
203
|
+
*/
|
|
204
|
+
async create(sessionId, request, signal) {
|
|
205
|
+
if (Number(request.at !== undefined) + Number(request.after_seconds !== undefined)
|
|
206
|
+
+ Number(request.every_seconds !== undefined) + Number(request.daily !== undefined)
|
|
207
|
+
+ Number(request.weekly !== undefined) + Number(request.cron !== undefined) > 1) {
|
|
208
|
+
throw new ScheduleInputError('invalid_selector', 'Exactly one reminder selector is required.');
|
|
209
|
+
}
|
|
210
|
+
const title = scheduleTitle(request.title);
|
|
211
|
+
const id = ScheduleId(`schedule-${randomUUID()}`);
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
let record;
|
|
214
|
+
if (request.at !== undefined) {
|
|
215
|
+
record = createAtScheduleRecord(id, request.prompt, request.at, now, title);
|
|
216
|
+
}
|
|
217
|
+
else if (request.after_seconds !== undefined) {
|
|
218
|
+
record = createAfterScheduleRecord(id, request.prompt, request.after_seconds, now, title);
|
|
219
|
+
}
|
|
220
|
+
else if (request.every_seconds !== undefined) {
|
|
221
|
+
record = createEveryScheduleRecord(id, request.prompt, request.every_seconds, now, title);
|
|
222
|
+
}
|
|
223
|
+
else if (request.daily !== undefined)
|
|
224
|
+
record = createDailyScheduleRecord(id, request.prompt, request.daily, now, title);
|
|
225
|
+
else if (request.weekly !== undefined)
|
|
226
|
+
record = createWeeklyScheduleRecord(id, request.prompt, request.weekly, now, title);
|
|
227
|
+
else if (request.cron !== undefined)
|
|
228
|
+
record = createCronScheduleRecord(id, request.prompt, request.cron, now, title);
|
|
229
|
+
else
|
|
230
|
+
throw new ScheduleInputError('invalid_selector', 'Exactly one reminder selector is required.');
|
|
231
|
+
return this.serialize(async () => {
|
|
232
|
+
const domain = await this.getDomain();
|
|
233
|
+
signal?.throwIfAborted();
|
|
234
|
+
await domain.table('tasks').put(id, {
|
|
235
|
+
sessionId, record, status: 'active', deliveryHistory: { records: [], earlierRecordsUnavailable: false },
|
|
236
|
+
});
|
|
237
|
+
this.emitChanged();
|
|
238
|
+
this.runtime?.requestDrive();
|
|
239
|
+
return record;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Read the selected Session's active tasks without resuming its Agent.
|
|
244
|
+
* @param request - Session whose task list is requested.
|
|
245
|
+
* @returns Persisted reminders in storage order.
|
|
246
|
+
*/
|
|
247
|
+
async list(request) {
|
|
248
|
+
const domain = await this.getDomain();
|
|
249
|
+
return [...domain.table('tasks').entries()]
|
|
250
|
+
.filter(([, task]) => task.sessionId === request.sessionId && task.status === 'active')
|
|
251
|
+
.map(([, task]) => task.record);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Read all active and inactive Host reminders with their original Session bindings.
|
|
255
|
+
* A deleted reminder has no row, so it is absent here.
|
|
256
|
+
* Does not activate Sessions or read Session history.
|
|
257
|
+
* @returns Reminders ordered by scheduledAt ascending, then lexicographically by id.
|
|
258
|
+
*/
|
|
259
|
+
async catalog() {
|
|
260
|
+
const domain = await this.getDomain();
|
|
261
|
+
return [...domain.table('tasks').entries()]
|
|
262
|
+
.map(([, task]) => ({
|
|
263
|
+
...task.record, sessionId: task.sessionId, status: task.status,
|
|
264
|
+
...(task.lastDelivery === undefined ? {} : { lastDelivery: task.lastDelivery }),
|
|
265
|
+
}))
|
|
266
|
+
.sort((a, b) => a.scheduledAt.localeCompare(b.scheduledAt) || a.id.localeCompare(b.id));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Read saved inbox deliveries without activating or reading the original Session.
|
|
270
|
+
* The task's own row supplies its binding, so its records stay readable through this lookup.
|
|
271
|
+
* @param request - Session binding, task identity, explicit limit, and optional exclusive message cursor.
|
|
272
|
+
* @returns Newest-first deliveries in append order, or a task/cursor lookup failure.
|
|
273
|
+
* @throws ScheduleInputError when limit is not a safe integer from 1 through 100.
|
|
274
|
+
*/
|
|
275
|
+
async history(request) {
|
|
276
|
+
if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 100) {
|
|
277
|
+
throw new ScheduleInputError('invalid_rule', 'Delivery history limit must be a safe integer from 1 through 100.');
|
|
278
|
+
}
|
|
279
|
+
const task = (await this.getDomain()).table('tasks').get(request.id);
|
|
280
|
+
if (task === undefined || task.sessionId !== request.sessionId) {
|
|
281
|
+
return { id: request.id, code: 'schedule_not_found' };
|
|
282
|
+
}
|
|
283
|
+
return deliveryHistoryPage(task, request, this.retention);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Delete one task belonging to the selected Session, leaving queued messages intact.
|
|
287
|
+
*
|
|
288
|
+
* The row is removed: the task no longer schedules, leaves `list` and `catalog`, and its
|
|
289
|
+
* saved delivery records go with it.
|
|
290
|
+
* @param request - Session and exact task identity.
|
|
291
|
+
* @param signal - Optional cancellation checked before persistence begins, including after FIFO waits.
|
|
292
|
+
* @returns Whether that Session owned a deleted task. Cancellation does not roll back an in-flight write.
|
|
293
|
+
*/
|
|
294
|
+
async delete(request, signal) {
|
|
295
|
+
return this.serialize(async () => {
|
|
296
|
+
const tasks = (await this.getDomain()).table('tasks');
|
|
297
|
+
signal?.throwIfAborted();
|
|
298
|
+
const current = tasks.get(request.id);
|
|
299
|
+
if (current === undefined || current.sessionId !== request.sessionId) {
|
|
300
|
+
return { id: request.id, deleted: false, code: 'schedule_not_found' };
|
|
301
|
+
}
|
|
302
|
+
await tasks.delete(request.id);
|
|
303
|
+
this.emitChanged();
|
|
304
|
+
this.runtime?.requestDrive();
|
|
305
|
+
return { id: request.id, deleted: true };
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Update the name, instruction, and timing of an active task within the original Session
|
|
310
|
+
* binding without activating the Session or changing saved deliveries.
|
|
311
|
+
*
|
|
312
|
+
* Each supplied field replaces its stored value; an omitted field keeps it. A name or
|
|
313
|
+
* instruction change alone does not reset the committed target.
|
|
314
|
+
* @param request - Task binding, complete observed record, and any combination of timing, name, and instruction.
|
|
315
|
+
* @param signal - Cancellation checked after domain readiness and FIFO waits, before persistence begins.
|
|
316
|
+
* @returns The committed record, unchanged record for a no-op, or a non-mutating input/lookup/conflict result.
|
|
317
|
+
* Storage and lifecycle failures reject; cancellation after a write starts does not roll it back.
|
|
318
|
+
*/
|
|
319
|
+
async update(request, signal) {
|
|
320
|
+
return this.serialize(async () => {
|
|
321
|
+
const tasks = (await this.getDomain()).table('tasks');
|
|
322
|
+
signal?.throwIfAborted();
|
|
323
|
+
const current = tasks.get(request.id);
|
|
324
|
+
if (current === undefined || current.sessionId !== request.sessionId) {
|
|
325
|
+
return { id: request.id, updated: false, code: 'schedule_not_found' };
|
|
326
|
+
}
|
|
327
|
+
if (current.status === 'inactive')
|
|
328
|
+
return { id: request.id, updated: false, code: 'schedule_ended' };
|
|
329
|
+
const result = resolveScheduleUpdate(current.record, request.expected, request.change, Date.now(), request);
|
|
330
|
+
if (!('record' in result) || !result.updated)
|
|
331
|
+
return result;
|
|
332
|
+
await tasks.put(request.id, { ...current, record: result.record });
|
|
333
|
+
this.emitChanged();
|
|
334
|
+
this.runtime?.requestDrive();
|
|
335
|
+
return result;
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Dispatch one post-commit `schedule/changed` notification, containing
|
|
340
|
+
* synchronous listener failures: every call site emits only after its durable
|
|
341
|
+
* task write landed, so a throwing listener must not reject the caller or
|
|
342
|
+
* skip the following `requestDrive()`.
|
|
343
|
+
*/
|
|
344
|
+
emitChanged() {
|
|
345
|
+
try {
|
|
346
|
+
this.ctx.emit('schedule/changed');
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
// Swallows synchronous observer exceptions only: emit dispatches
|
|
350
|
+
// listeners inline and nothing else runs in the try. The event is a
|
|
351
|
+
// notification, not a transaction participant.
|
|
352
|
+
this.ctx.logger.warn(`schedule: schedule/changed listener failed: ${String(error)}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async getDomain() {
|
|
356
|
+
await this.initialized;
|
|
357
|
+
return this.ready;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Remove every active task stored for one Session, inside the queue the tools
|
|
361
|
+
* use.
|
|
362
|
+
*
|
|
363
|
+
* Enumerating and deleting in one queue slot is what makes an archive stop
|
|
364
|
+
* ordered behind a create whose write is still in flight: a stop that read the
|
|
365
|
+
* table outside the queue could miss a row the create was about to commit and
|
|
366
|
+
* leave an armed reminder behind. Re-entering the public `delete()` from here
|
|
367
|
+
* would deadlock on this queue, so the rows are removed directly.
|
|
368
|
+
* @param sessionId - Session whose active Host tasks must stop.
|
|
369
|
+
*/
|
|
370
|
+
async stopSessionTasks(sessionId) {
|
|
371
|
+
await this.serialize(async () => {
|
|
372
|
+
const tasks = (await this.getDomain()).table('tasks');
|
|
373
|
+
const active = [...tasks.entries()]
|
|
374
|
+
.filter(([, task]) => task.sessionId === sessionId && task.status === 'active')
|
|
375
|
+
.map(([, task]) => task.record.id);
|
|
376
|
+
if (active.length === 0)
|
|
377
|
+
return;
|
|
378
|
+
// One row per write: the domain table has no batch delete, and each row is
|
|
379
|
+
// durable on its own, so a failure partway through leaves the rows already
|
|
380
|
+
// removed committed. Observers hear about that change before the failure
|
|
381
|
+
// reaches the caller, which still receives it.
|
|
382
|
+
let removed = false;
|
|
383
|
+
let failure;
|
|
384
|
+
for (const id of active) {
|
|
385
|
+
try {
|
|
386
|
+
await tasks.delete(id);
|
|
387
|
+
removed = true;
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
// Every row is attempted: the archive has already landed, so a row this
|
|
391
|
+
// stop skipped would stay armed in a Session whose runtime then refuses
|
|
392
|
+
// its model steps. A rejection reason that carries no Error still has to
|
|
393
|
+
// report a failure, so it is wrapped before the first one is kept.
|
|
394
|
+
failure ??= error instanceof Error ? error : new Error(`schedule stop failed: ${String(error)}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (removed) {
|
|
398
|
+
this.emitChanged();
|
|
399
|
+
this.runtime?.requestDrive();
|
|
400
|
+
}
|
|
401
|
+
if (failure !== undefined)
|
|
402
|
+
throw failure;
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
serialize(work) {
|
|
406
|
+
if (this.stopping)
|
|
407
|
+
return Promise.reject(new Error('Schedule service is stopping'));
|
|
408
|
+
const pending = this.chain.then(work);
|
|
409
|
+
this.chain = pending.catch(() => undefined); // Preserve FIFO progress after the caller receives the failure.
|
|
410
|
+
return pending;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
})();
|
|
414
|
+
export { ScheduleService };
|
|
415
|
+
export default ScheduleService;
|
|
416
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ContextFormed } from '@qilin-agent/llm';
|
|
2
|
+
declare module '@qilin-agent/llm' {
|
|
3
|
+
interface MessageSourceMap {
|
|
4
|
+
'schedule': {
|
|
5
|
+
kind: 'schedule';
|
|
6
|
+
} & ContextFormed;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
import type { Context } from '@qilin-agent/kylin';
|
|
10
|
+
import type { DeliveryRetentionBounds } from './types.ts';
|
|
11
|
+
import type { ScheduleTask } from './storage.ts';
|
|
12
|
+
/** Largest delay Node timers represent without clamping. */
|
|
13
|
+
export declare const MAX_TIMER_DELAY_MS = 2147483647;
|
|
14
|
+
/** Owns at most one timer across recomputations; delivery and management share the serialized operation. */
|
|
15
|
+
export declare class ScheduleRuntime {
|
|
16
|
+
private readonly ctx;
|
|
17
|
+
private readonly tasks;
|
|
18
|
+
private readonly transact;
|
|
19
|
+
private readonly commit;
|
|
20
|
+
private readonly retention;
|
|
21
|
+
private timer;
|
|
22
|
+
private running;
|
|
23
|
+
private stopping;
|
|
24
|
+
private requested;
|
|
25
|
+
/**
|
|
26
|
+
* @param ctx - Host services used to resume and enqueue.
|
|
27
|
+
* @param tasks - Current durable tasks.
|
|
28
|
+
* @param transact - Serialize delivery against management writes.
|
|
29
|
+
* @param commit - Persist task status, target, receipt, and history together after durable inbox delivery.
|
|
30
|
+
*/
|
|
31
|
+
constructor(ctx: Context, tasks: () => readonly ScheduleTask[], transact: (work: () => Promise<void>) => Promise<void>, commit: (task: ScheduleTask) => Promise<void>, retention: DeliveryRetentionBounds);
|
|
32
|
+
/**
|
|
33
|
+
* Recompute the nearest obligation after startup or a durable change.
|
|
34
|
+
* Dispatch failures are logged; refused admission does not retry automatically.
|
|
35
|
+
*/
|
|
36
|
+
requestDrive(): void;
|
|
37
|
+
/** Stop the timer and drain an accepted delivery before storage closes. */
|
|
38
|
+
dispose(): Promise<void>;
|
|
39
|
+
private clearTimer;
|
|
40
|
+
private drive;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=runtime.d.ts.map
|