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/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;
@@ -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;