zayra-task-engine 0.0.1
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/README.md +268 -0
- package/package.json +32 -0
- package/src/errors.js +62 -0
- package/src/events.js +124 -0
- package/src/executor.js +110 -0
- package/src/index.js +23 -0
- package/src/manager.js +328 -0
- package/src/permissions.js +113 -0
- package/src/scheduler.js +90 -0
- package/src/task.js +189 -0
- package/src/types.js +87 -0
- package/src/workflow.js +60 -0
package/src/manager.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* manager.js
|
|
3
|
+
*
|
|
4
|
+
* TaskManager (spec sections: "Task Manager System", "AI
|
|
5
|
+
* Integration", "Workflow System", "Tool Integration", "Memory
|
|
6
|
+
* Integration", "Scheduling Support"). The single class consumers of
|
|
7
|
+
* zayra-task-engine interact with.
|
|
8
|
+
*
|
|
9
|
+
* Everything zayra-task-engine integrates with — an AI orchestrator,
|
|
10
|
+
* zayra-tools, zayra-memory, a permission source — is accepted as an
|
|
11
|
+
* injected adapter, never imported directly. This keeps the package
|
|
12
|
+
* dependency-free (matching every other zayra-* package's empty
|
|
13
|
+
* "dependencies") and means none of those integrated packages need
|
|
14
|
+
* to exist yet for this one to work standalone.
|
|
15
|
+
*
|
|
16
|
+
* Per spec's "Important Rules": no frontend/UI code, no hardcoded
|
|
17
|
+
* workflows, no AI provider logic lives here — only planning and
|
|
18
|
+
* execution flow.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Task } from "./task.js";
|
|
22
|
+
import { defineWorkflow, workflowProgress } from "./workflow.js";
|
|
23
|
+
import { executeStep } from "./executor.js";
|
|
24
|
+
import { TaskPermissionManager } from "./permissions.js";
|
|
25
|
+
import { TaskScheduler } from "./scheduler.js";
|
|
26
|
+
import { EventBus, TaskEvent } from "./events.js";
|
|
27
|
+
import { TaskType, TaskState } from "./types.js";
|
|
28
|
+
import { TaskError } from "./errors.js";
|
|
29
|
+
|
|
30
|
+
export class TaskManager {
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} [options]
|
|
33
|
+
* @param {TaskPermissionManager} [options.permissions] - Injected permission manager. A permissive default (nothing blocked, no permissions required) is created if omitted.
|
|
34
|
+
* @param {(step: object, context: object) => Promise<any>} [options.toolRunner] - Injected step runner, typically backed by zayra-tools' ToolRegistry + executeTool(). Required for any task whose steps carry an `action` that needs a real tool call.
|
|
35
|
+
* @param {{ save: (entry: object) => Promise<any> }} [options.memoryAdapter] - Injected zayra-memory-shaped adapter. If given, `completeTask()` saves a summary of the finished task through it.
|
|
36
|
+
* @param {{ planSteps?: (task: Task) => Promise<(string|object)[]> }} [options.aiOrchestrator] - Injected zayra-ai-orchestrator-shaped adapter. If given, `startTask()` calls `planSteps(task)` to generate steps for AI_GENERATED tasks created with no steps of their own.
|
|
37
|
+
* @param {number} [options.stepTimeoutMs] - Default per-step execution timeout, in ms.
|
|
38
|
+
*/
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
/** @type {Map<string, Task>} */
|
|
41
|
+
this.tasks = new Map();
|
|
42
|
+
this.events = new EventBus();
|
|
43
|
+
this.permissions = options.permissions ?? new TaskPermissionManager();
|
|
44
|
+
this.toolRunner = options.toolRunner ?? null;
|
|
45
|
+
this.memoryAdapter = options.memoryAdapter ?? null;
|
|
46
|
+
this.aiOrchestrator = options.aiOrchestrator ?? null;
|
|
47
|
+
this.scheduler = new TaskScheduler();
|
|
48
|
+
this.stepTimeoutMs = options.stepTimeoutMs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* ------------------------------------------------------------------ */
|
|
52
|
+
/* Task Manager System: createTask / startTask / pauseTask / */
|
|
53
|
+
/* resumeTask / cancelTask / completeTask */
|
|
54
|
+
/* ------------------------------------------------------------------ */
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates and registers a new task. Does not start it.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} input - Same shape as the Task constructor: { name, type?, steps?, metadata? }.
|
|
60
|
+
* @returns {Task}
|
|
61
|
+
* @throws {TaskError} If the input is invalid.
|
|
62
|
+
*/
|
|
63
|
+
createTask(input = {}) {
|
|
64
|
+
const task = new Task(input);
|
|
65
|
+
this.tasks.set(task.id, task);
|
|
66
|
+
this.events.emit(TaskEvent.CREATED, { task });
|
|
67
|
+
return task;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Convenience for the spec's "Workflow System" example: creates a
|
|
72
|
+
* multi-step task directly from an ordered list of step names or
|
|
73
|
+
* definitions.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* manager.createWorkflow("Research topic", [
|
|
77
|
+
* "Search information",
|
|
78
|
+
* "Analyze results",
|
|
79
|
+
* "Generate summary",
|
|
80
|
+
* ]);
|
|
81
|
+
*
|
|
82
|
+
* @param {string} name
|
|
83
|
+
* @param {(string|object)[]} steps
|
|
84
|
+
* @param {object} [options] - Extra Task fields (type, metadata, etc.). type defaults to MULTI_STEP.
|
|
85
|
+
* @returns {Task}
|
|
86
|
+
*/
|
|
87
|
+
createWorkflow(name, steps, options = {}) {
|
|
88
|
+
const normalizedSteps = defineWorkflow(steps);
|
|
89
|
+
return this.createTask({
|
|
90
|
+
...options,
|
|
91
|
+
name,
|
|
92
|
+
type: options.type ?? TaskType.MULTI_STEP,
|
|
93
|
+
steps: normalizedSteps,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} taskId
|
|
99
|
+
* @returns {Task}
|
|
100
|
+
* @throws {TaskError} If no task with that id exists.
|
|
101
|
+
*/
|
|
102
|
+
getTask(taskId) {
|
|
103
|
+
const task = this.tasks.get(taskId);
|
|
104
|
+
if (!task) {
|
|
105
|
+
throw new TaskError(`No task found with id "${taskId}".`, { taskId });
|
|
106
|
+
}
|
|
107
|
+
return task;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {object} [options]
|
|
112
|
+
* @param {string} [options.state] - Restrict to a single TaskState.
|
|
113
|
+
* @returns {Task[]}
|
|
114
|
+
*/
|
|
115
|
+
listTasks(options = {}) {
|
|
116
|
+
const all = [...this.tasks.values()];
|
|
117
|
+
return options.state ? all.filter((task) => task.state === options.state) : all;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Starts a task: runs the permission check (per "Security Rules" —
|
|
122
|
+
* a task is never executed without passing this first), transitions
|
|
123
|
+
* it to RUNNING, and — for an AI_GENERATED task created with no
|
|
124
|
+
* steps — asks the injected AI orchestrator to plan them ("AI
|
|
125
|
+
* Integration": create tasks, plan steps, execute actions, monitor
|
|
126
|
+
* progress). A task with steps then runs each one in order; a
|
|
127
|
+
* plain task with no steps just becomes RUNNING and waits for the
|
|
128
|
+
* caller to call completeTask()/failTask() once its single action
|
|
129
|
+
* is done.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} taskId
|
|
132
|
+
* @returns {Promise<Task>}
|
|
133
|
+
* @throws {TaskError} If the task doesn't exist, isn't in a startable state, or is blocked by the permission check.
|
|
134
|
+
*/
|
|
135
|
+
async startTask(taskId) {
|
|
136
|
+
const task = this.getTask(taskId);
|
|
137
|
+
|
|
138
|
+
const check = this.permissions.checkTask(task);
|
|
139
|
+
if (!check.allowed) {
|
|
140
|
+
throw new TaskError(`Task "${taskId}" was blocked by the permission check (${check.reason}).`, {
|
|
141
|
+
taskId,
|
|
142
|
+
code: "PERMISSION_DENIED",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (task.type === TaskType.AI_GENERATED && task.steps.length === 0 && this.aiOrchestrator?.planSteps) {
|
|
147
|
+
const planned = await this.aiOrchestrator.planSteps(task);
|
|
148
|
+
task.steps = defineWorkflow(planned);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
task.transitionTo(TaskState.RUNNING);
|
|
152
|
+
this.events.emit(TaskEvent.STARTED, { task });
|
|
153
|
+
|
|
154
|
+
if (task.hasWorkflow) {
|
|
155
|
+
await this._runSteps(task);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return task;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Runs a task's remaining steps in order (spec's "Workflow System"
|
|
163
|
+
* step sequence), stopping early if the task is paused or
|
|
164
|
+
* cancelled mid-run, and failing/completing the task automatically
|
|
165
|
+
* based on the outcome.
|
|
166
|
+
* @param {Task} task
|
|
167
|
+
* @private
|
|
168
|
+
*/
|
|
169
|
+
async _runSteps(task) {
|
|
170
|
+
for (let i = task.currentStepIndex; i < task.steps.length; i++) {
|
|
171
|
+
if (task.state !== TaskState.RUNNING) {
|
|
172
|
+
return; // paused or cancelled since the loop started
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const step = task.steps[i];
|
|
176
|
+
task.currentStepIndex = i;
|
|
177
|
+
step.status = "running";
|
|
178
|
+
this.events.emit(TaskEvent.STEP_STARTED, { task, step });
|
|
179
|
+
|
|
180
|
+
const result = await executeStep(step, this.toolRunner, { task }, this.stepTimeoutMs);
|
|
181
|
+
|
|
182
|
+
if (result.success) {
|
|
183
|
+
step.status = "completed";
|
|
184
|
+
step.result = result.data;
|
|
185
|
+
this.events.emit(TaskEvent.STEP_COMPLETED, { task, step });
|
|
186
|
+
} else {
|
|
187
|
+
step.status = "failed";
|
|
188
|
+
step.error = result.error;
|
|
189
|
+
await this.failTask(task.id, result.error);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (task.state === TaskState.RUNNING) {
|
|
195
|
+
await this.completeTask(task.id);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Pauses a running task (moves it to WAITING).
|
|
201
|
+
* @param {string} taskId
|
|
202
|
+
* @returns {Task}
|
|
203
|
+
*/
|
|
204
|
+
pauseTask(taskId) {
|
|
205
|
+
const task = this.getTask(taskId);
|
|
206
|
+
task.transitionTo(TaskState.WAITING);
|
|
207
|
+
this.events.emit(TaskEvent.PAUSED, { task });
|
|
208
|
+
return task;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resumes a paused (WAITING) task, continuing any remaining
|
|
213
|
+
* workflow steps from where it left off.
|
|
214
|
+
* @param {string} taskId
|
|
215
|
+
* @returns {Promise<Task>}
|
|
216
|
+
*/
|
|
217
|
+
async resumeTask(taskId) {
|
|
218
|
+
const task = this.getTask(taskId);
|
|
219
|
+
task.transitionTo(TaskState.RUNNING);
|
|
220
|
+
this.events.emit(TaskEvent.RESUMED, { task });
|
|
221
|
+
|
|
222
|
+
if (task.hasWorkflow) {
|
|
223
|
+
await this._runSteps(task);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return task;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Cancels a task and clears any pending schedule for it.
|
|
231
|
+
* @param {string} taskId
|
|
232
|
+
* @returns {Task}
|
|
233
|
+
*/
|
|
234
|
+
cancelTask(taskId) {
|
|
235
|
+
const task = this.getTask(taskId);
|
|
236
|
+
this.scheduler.cancel(taskId);
|
|
237
|
+
task.transitionTo(TaskState.CANCELLED);
|
|
238
|
+
this.events.emit(TaskEvent.CANCELLED, { task });
|
|
239
|
+
return task;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Marks a task completed, and — if a memory adapter was injected —
|
|
244
|
+
* saves a summary of its result for future context ("Memory
|
|
245
|
+
* Integration": completed tasks support storing useful information
|
|
246
|
+
* through zayra-memory).
|
|
247
|
+
*
|
|
248
|
+
* @param {string} taskId
|
|
249
|
+
* @param {any} [result] - Final result to attach to the task, if not already set by its steps.
|
|
250
|
+
* @returns {Promise<Task>}
|
|
251
|
+
*/
|
|
252
|
+
async completeTask(taskId, result) {
|
|
253
|
+
const task = this.getTask(taskId);
|
|
254
|
+
|
|
255
|
+
if (result !== undefined) {
|
|
256
|
+
task.result = result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
task.transitionTo(TaskState.COMPLETED);
|
|
260
|
+
this.events.emit(TaskEvent.COMPLETED, { task });
|
|
261
|
+
|
|
262
|
+
if (this.memoryAdapter?.save) {
|
|
263
|
+
await this.memoryAdapter.save({
|
|
264
|
+
content: `Completed task "${task.name}" (${task.type}).`,
|
|
265
|
+
metadata: { taskId: task.id, taskType: task.type, result: task.result },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return task;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Marks a task failed.
|
|
274
|
+
* @param {string} taskId
|
|
275
|
+
* @param {Error|{ name?: string, message?: string }|any} [error]
|
|
276
|
+
* @returns {Promise<Task>}
|
|
277
|
+
*/
|
|
278
|
+
async failTask(taskId, error) {
|
|
279
|
+
const task = this.getTask(taskId);
|
|
280
|
+
task.error = error instanceof Error ? { name: error.name, message: error.message } : (error ?? null);
|
|
281
|
+
task.transitionTo(TaskState.FAILED);
|
|
282
|
+
this.events.emit(TaskEvent.FAILED, { task });
|
|
283
|
+
return task;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ------------------------------------------------------------------ */
|
|
287
|
+
/* Scheduling Support */
|
|
288
|
+
/* ------------------------------------------------------------------ */
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Schedules a task to auto-start later. Intended for tasks created
|
|
292
|
+
* with type TaskType.SCHEDULED, though this isn't enforced.
|
|
293
|
+
*
|
|
294
|
+
* @param {string} taskId
|
|
295
|
+
* @param {{ delayMs?: number, at?: string|Date }} when
|
|
296
|
+
* @returns {Task}
|
|
297
|
+
*/
|
|
298
|
+
scheduleTask(taskId, when) {
|
|
299
|
+
const task = this.getTask(taskId);
|
|
300
|
+
this.scheduler.schedule(taskId, () => {
|
|
301
|
+
this.startTask(taskId).catch((err) => this.failTask(taskId, err));
|
|
302
|
+
}, when);
|
|
303
|
+
return task;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Cancels a task's pending schedule without cancelling the task
|
|
308
|
+
* itself.
|
|
309
|
+
* @param {string} taskId
|
|
310
|
+
*/
|
|
311
|
+
unscheduleTask(taskId) {
|
|
312
|
+
this.scheduler.cancel(taskId);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* ------------------------------------------------------------------ */
|
|
316
|
+
/* Progress */
|
|
317
|
+
/* ------------------------------------------------------------------ */
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @param {string} taskId
|
|
321
|
+
* @returns {{ total: number, completed: number, failed: number, remaining: number, percent: number }}
|
|
322
|
+
*/
|
|
323
|
+
getProgress(taskId) {
|
|
324
|
+
return workflowProgress(this.getTask(taskId).steps);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export default TaskManager;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permissions.js
|
|
3
|
+
*
|
|
4
|
+
* Security Rules (spec section: "Security Rules"). Tasks must not
|
|
5
|
+
* execute unsafe operations, bypass permissions, or expose secrets —
|
|
6
|
+
* this module is the permission check that manager.js runs before
|
|
7
|
+
* starting any task.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately the same shape as zayra-tools' PermissionManager (a
|
|
10
|
+
* permission is only granted if both the system and the user allow
|
|
11
|
+
* it), so a single set of granted permissions can be shared across
|
|
12
|
+
* both packages by an integrating application if it chooses to.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} TaskPermissionCheckResult
|
|
17
|
+
* @property {boolean} allowed
|
|
18
|
+
* @property {string[]} missing - Required permissions that weren't granted (empty if allowed, or if the task type itself was blocked rather than missing a specific permission).
|
|
19
|
+
* @property {string|null} reason - "blocked_type" | "missing_permissions" | null
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Tracks which task types may run and which permissions have been
|
|
24
|
+
* granted, and checks a task's requirements against both.
|
|
25
|
+
*
|
|
26
|
+
* A task declares the permissions it needs via
|
|
27
|
+
* `task.metadata.permissions` (e.g. `["filesystem:write",
|
|
28
|
+
* "network:external"]`) — a task with no declared permissions is
|
|
29
|
+
* allowed to run as long as its type isn't blocked.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* const permissions = new TaskPermissionManager();
|
|
33
|
+
* permissions.grantSystemPermission("network:external");
|
|
34
|
+
* permissions.grantUserPermission("network:external");
|
|
35
|
+
* permissions.checkTask(task); // { allowed: true, missing: [], reason: null }
|
|
36
|
+
*/
|
|
37
|
+
export class TaskPermissionManager {
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} [options]
|
|
40
|
+
* @param {string[]} [options.blockedTaskTypes] - Task types that may never run, e.g. ["automation"].
|
|
41
|
+
* @param {string[]} [options.userPermissions] - Permission strings the user has granted.
|
|
42
|
+
* @param {string[]} [options.systemPermissions] - Permission strings the system/environment allows.
|
|
43
|
+
*/
|
|
44
|
+
constructor(options = {}) {
|
|
45
|
+
this.blockedTaskTypes = new Set(options.blockedTaskTypes ?? []);
|
|
46
|
+
this.userPermissions = new Set(options.userPermissions ?? []);
|
|
47
|
+
this.systemPermissions = new Set(options.systemPermissions ?? []);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A permission is granted only if both the system and the user
|
|
52
|
+
* allow it.
|
|
53
|
+
* @param {string} permission
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
hasPermission(permission) {
|
|
57
|
+
return this.systemPermissions.has(permission) && this.userPermissions.has(permission);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Checks whether a task's type is allowed to run at all, and
|
|
62
|
+
* whether every permission it declares has been granted.
|
|
63
|
+
*
|
|
64
|
+
* @param {import("./task.js").Task} task
|
|
65
|
+
* @returns {TaskPermissionCheckResult}
|
|
66
|
+
*/
|
|
67
|
+
checkTask(task) {
|
|
68
|
+
if (this.blockedTaskTypes.has(task.type)) {
|
|
69
|
+
return { allowed: false, missing: [], reason: "blocked_type" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const required = Array.isArray(task.metadata?.permissions) ? task.metadata.permissions : [];
|
|
73
|
+
const missing = required.filter((permission) => !this.hasPermission(permission));
|
|
74
|
+
|
|
75
|
+
if (missing.length > 0) {
|
|
76
|
+
return { allowed: false, missing, reason: "missing_permissions" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { allowed: true, missing: [], reason: null };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {string} type */
|
|
83
|
+
blockTaskType(type) {
|
|
84
|
+
this.blockedTaskTypes.add(type);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @param {string} type */
|
|
88
|
+
allowTaskType(type) {
|
|
89
|
+
this.blockedTaskTypes.delete(type);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {string} permission */
|
|
93
|
+
grantUserPermission(permission) {
|
|
94
|
+
this.userPermissions.add(permission);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {string} permission */
|
|
98
|
+
revokeUserPermission(permission) {
|
|
99
|
+
this.userPermissions.delete(permission);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @param {string} permission */
|
|
103
|
+
grantSystemPermission(permission) {
|
|
104
|
+
this.systemPermissions.add(permission);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {string} permission */
|
|
108
|
+
revokeSystemPermission(permission) {
|
|
109
|
+
this.systemPermissions.delete(permission);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default TaskPermissionManager;
|
package/src/scheduler.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler.js
|
|
3
|
+
*
|
|
4
|
+
* Scheduling Support (spec section: "Scheduling Support") — prepared
|
|
5
|
+
* support for timers, scheduled execution, and background tasks.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately minimal in 0.0.1: a delay- or absolute-time-based
|
|
8
|
+
* setTimeout scheduler, matching "Prepare support for" rather than
|
|
9
|
+
* shipping a full cron system. A later version can swap this for
|
|
10
|
+
* recurring/cron-style schedules without changing TaskScheduler's
|
|
11
|
+
* public shape (schedule/cancel/isScheduled/clear).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { TaskError } from "./errors.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Schedules callbacks to run later, keyed by task id so a task can
|
|
18
|
+
* never have two pending schedules at once.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const scheduler = new TaskScheduler();
|
|
22
|
+
* scheduler.schedule(task.id, () => manager.startTask(task.id), { delayMs: 60_000 });
|
|
23
|
+
*/
|
|
24
|
+
export class TaskScheduler {
|
|
25
|
+
constructor() {
|
|
26
|
+
/** @type {Map<string, NodeJS.Timeout>} */
|
|
27
|
+
this._timers = new Map();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Schedules a callback to run once, either after a delay or at an
|
|
32
|
+
* absolute time. Replaces any existing schedule for the same
|
|
33
|
+
* taskId.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} taskId
|
|
36
|
+
* @param {() => void} callback
|
|
37
|
+
* @param {object} when
|
|
38
|
+
* @param {number} [when.delayMs] - Milliseconds from now.
|
|
39
|
+
* @param {string|Date} [when.at] - Absolute time to run at.
|
|
40
|
+
* @throws {TaskError} If neither delayMs nor at is given.
|
|
41
|
+
*/
|
|
42
|
+
schedule(taskId, callback, when = {}) {
|
|
43
|
+
let delayMs = when.delayMs;
|
|
44
|
+
|
|
45
|
+
if (delayMs === undefined && when.at) {
|
|
46
|
+
const target = when.at instanceof Date ? when.at : new Date(when.at);
|
|
47
|
+
delayMs = target.getTime() - Date.now();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof delayMs !== "number" || Number.isNaN(delayMs)) {
|
|
51
|
+
throw new TaskError('scheduleTask() requires "delayMs" or an "at" time.', { taskId });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.cancel(taskId);
|
|
55
|
+
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
this._timers.delete(taskId);
|
|
58
|
+
callback();
|
|
59
|
+
}, Math.max(0, delayMs));
|
|
60
|
+
|
|
61
|
+
this._timers.set(taskId, timer);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Cancels a task's pending schedule, if any. Safe to call for a
|
|
66
|
+
* taskId with no schedule.
|
|
67
|
+
* @param {string} taskId
|
|
68
|
+
*/
|
|
69
|
+
cancel(taskId) {
|
|
70
|
+
const timer = this._timers.get(taskId);
|
|
71
|
+
if (timer) {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
this._timers.delete(taskId);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {string} taskId @returns {boolean} */
|
|
78
|
+
isScheduled(taskId) {
|
|
79
|
+
return this._timers.has(taskId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Cancels every pending schedule — used on shutdown. */
|
|
83
|
+
clear() {
|
|
84
|
+
for (const taskId of this._timers.keys()) {
|
|
85
|
+
this.cancel(taskId);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default TaskScheduler;
|