feat(task): add task labels and push-based task events
This commit is contained in:
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-01-26 - 4.1.0 - feat(task)
|
||||||
|
add task labels and push-based task events
|
||||||
|
|
||||||
|
- Introduce Task labels: Task accepts labels in constructor and exposes setLabel/getLabel/removeLabel/hasLabel; labels are included (shallow copy) in getMetadata().
|
||||||
|
- Add push-based events: Task.eventSubject (rxjs Subject<ITaskEvent>) emits 'started','step','completed','failed' with timestamp; 'step' includes stepName and 'failed' includes error string.
|
||||||
|
- Task now emits events during lifecycle: emits 'started' at run start, 'step' on notifyStep, and 'completed' or 'failed' when finished or errored. getMetadata() now includes labels.
|
||||||
|
- TaskManager aggregates task events into taskSubject, subscribes on addTask and unsubscribes on removeTask/stop; includes helper methods getTasksByLabel and getTasksMetadataByLabel.
|
||||||
|
- Public API updated: exported ITaskEvent and TTaskEventType in ts/index.ts and interfaces updated (labels in metadata, new event types).
|
||||||
|
- Tests and docs: added test/test.12.labels-and-events.ts and updated readme.hints.md to document labels and push-based events.
|
||||||
|
|
||||||
## 2026-01-25 - 4.0.0 - BREAKING CHANGE(taskbuffer)
|
## 2026-01-25 - 4.0.0 - BREAKING CHANGE(taskbuffer)
|
||||||
Change default Task error handling: trigger() now rejects when taskFunction throws; add catchErrors option (default false) to preserve previous swallow behavior; track errors (lastError, errorCount) and expose them in metadata; improve error propagation and logging across runners, chains, parallels and debounced tasks; add tests and documentation for new behavior.
|
Change default Task error handling: trigger() now rejects when taskFunction throws; add catchErrors option (default false) to preserve previous swallow behavior; track errors (lastError, errorCount) and expose them in metadata; improve error propagation and logging across runners, chains, parallels and debounced tasks; add tests and documentation for new behavior.
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,20 @@
|
|||||||
- **BufferRunner**: When `catchErrors: false`, buffered task errors now reject the trigger promise (via `CycleCounter.informOfCycleError`) instead of silently resolving with `undefined`
|
- **BufferRunner**: When `catchErrors: false`, buffered task errors now reject the trigger promise (via `CycleCounter.informOfCycleError`) instead of silently resolving with `undefined`
|
||||||
- **TaskChain stubs completed**: `removeTask(task)` returns `boolean`, `shiftTask()` returns `Task | undefined`
|
- **TaskChain stubs completed**: `removeTask(task)` returns `boolean`, `shiftTask()` returns `Task | undefined`
|
||||||
|
|
||||||
|
## Task Labels (v4.1.0+)
|
||||||
|
- `Task` constructor accepts optional `labels?: Record<string, string>`
|
||||||
|
- Helper methods: `setLabel(key, value)`, `getLabel(key)`, `removeLabel(key)`, `hasLabel(key, value?)`
|
||||||
|
- `getMetadata()` includes `labels` (shallow copy)
|
||||||
|
- `TaskManager.getTasksByLabel(key, value)` returns matching `Task[]`
|
||||||
|
- `TaskManager.getTasksMetadataByLabel(key, value)` returns matching `ITaskMetadata[]`
|
||||||
|
|
||||||
|
## Push-Based Events (v4.1.0+)
|
||||||
|
- `Task.eventSubject`: rxjs `Subject<ITaskEvent>` emitting `'started'`, `'step'`, `'completed'`, `'failed'` events
|
||||||
|
- `TaskManager.taskSubject`: aggregated `Subject<ITaskEvent>` from all added tasks
|
||||||
|
- `TaskManager.removeTask(task)` unsubscribes and removes from map
|
||||||
|
- `TaskManager.stop()` cleans up all event subscriptions
|
||||||
|
- Exported types: `ITaskEvent`, `TTaskEventType`
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
- Source in `ts/`, web components in `ts_web/`
|
- Source in `ts/`, web components in `ts_web/`
|
||||||
- Tests in `test/` - naming: `*.node.ts`, `*.browser.ts`, `*.both.ts`
|
- Tests in `test/` - naming: `*.node.ts`, `*.browser.ts`, `*.both.ts`
|
||||||
|
|||||||
249
test/test.12.labels-and-events.ts
Normal file
249
test/test.12.labels-and-events.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as taskbuffer from '../ts/index.js';
|
||||||
|
import type { ITaskEvent } from '../ts/index.js';
|
||||||
|
|
||||||
|
// ─── Labels ───
|
||||||
|
|
||||||
|
tap.test('should accept labels in constructor', async () => {
|
||||||
|
const task = new taskbuffer.Task({
|
||||||
|
name: 'labelled-task',
|
||||||
|
taskFunction: async () => 'ok',
|
||||||
|
labels: { userId: 'u1', tenantId: 't1' },
|
||||||
|
});
|
||||||
|
expect(task.labels).toEqual({ userId: 'u1', tenantId: 't1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should default labels to empty object', async () => {
|
||||||
|
const task = new taskbuffer.Task({
|
||||||
|
name: 'no-labels-task',
|
||||||
|
taskFunction: async () => 'ok',
|
||||||
|
});
|
||||||
|
expect(task.labels).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('setLabel / getLabel / removeLabel / hasLabel should work', async () => {
|
||||||
|
const task = new taskbuffer.Task({
|
||||||
|
name: 'label-helpers-task',
|
||||||
|
taskFunction: async () => 'ok',
|
||||||
|
});
|
||||||
|
|
||||||
|
task.setLabel('env', 'prod');
|
||||||
|
expect(task.getLabel('env')).toEqual('prod');
|
||||||
|
expect(task.hasLabel('env')).toBeTrue();
|
||||||
|
expect(task.hasLabel('env', 'prod')).toBeTrue();
|
||||||
|
expect(task.hasLabel('env', 'dev')).toBeFalse();
|
||||||
|
expect(task.hasLabel('missing')).toBeFalse();
|
||||||
|
|
||||||
|
const removed = task.removeLabel('env');
|
||||||
|
expect(removed).toBeTrue();
|
||||||
|
expect(task.getLabel('env')).toBeUndefined();
|
||||||
|
|
||||||
|
const removedAgain = task.removeLabel('env');
|
||||||
|
expect(removedAgain).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('getMetadata() should include labels', async () => {
|
||||||
|
const task = new taskbuffer.Task({
|
||||||
|
name: 'metadata-labels-task',
|
||||||
|
taskFunction: async () => 'ok',
|
||||||
|
labels: { region: 'eu' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = task.getMetadata();
|
||||||
|
expect(meta.labels).toEqual({ region: 'eu' });
|
||||||
|
|
||||||
|
// Returned labels should be a copy
|
||||||
|
meta.labels!['region'] = 'us';
|
||||||
|
expect(task.labels['region']).toEqual('eu');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('TaskManager.getTasksByLabel should filter correctly', async () => {
|
||||||
|
const manager = new taskbuffer.TaskManager();
|
||||||
|
const t1 = new taskbuffer.Task({
|
||||||
|
name: 'label-filter-1',
|
||||||
|
taskFunction: async () => 'ok',
|
||||||
|
labels: { userId: 'alice' },
|
||||||
|
});
|
||||||
|
const t2 = new taskbuffer.Task({
|
||||||
|
name: 'label-filter-2',
|
||||||
|
taskFunction: async () => 'ok',
|
||||||
|
labels: { userId: 'bob' },
|
||||||
|
});
|
||||||
|
const t3 = new taskbuffer.Task({
|
||||||
|
name: 'label-filter-3',
|
||||||
|
taskFunction: async () => 'ok',
|
||||||
|
labels: { userId: 'alice' },
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.addTask(t1);
|
||||||
|
manager.addTask(t2);
|
||||||
|
manager.addTask(t3);
|
||||||
|
|
||||||
|
const aliceTasks = manager.getTasksByLabel('userId', 'alice');
|
||||||
|
expect(aliceTasks.length).toEqual(2);
|
||||||
|
expect(aliceTasks.map((t) => t.name).sort()).toEqual(['label-filter-1', 'label-filter-3']);
|
||||||
|
|
||||||
|
const bobMeta = manager.getTasksMetadataByLabel('userId', 'bob');
|
||||||
|
expect(bobMeta.length).toEqual(1);
|
||||||
|
expect(bobMeta[0].name).toEqual('label-filter-2');
|
||||||
|
|
||||||
|
await manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Events ───
|
||||||
|
|
||||||
|
tap.test('should emit started + completed on successful trigger', async () => {
|
||||||
|
const events: ITaskEvent[] = [];
|
||||||
|
const task = new taskbuffer.Task({
|
||||||
|
name: 'event-success-task',
|
||||||
|
taskFunction: async () => 'ok',
|
||||||
|
});
|
||||||
|
|
||||||
|
task.eventSubject.subscribe((e) => events.push(e));
|
||||||
|
await task.trigger();
|
||||||
|
|
||||||
|
expect(events.length).toEqual(2);
|
||||||
|
expect(events[0].type).toEqual('started');
|
||||||
|
expect(events[1].type).toEqual('completed');
|
||||||
|
expect(events[0].task.name).toEqual('event-success-task');
|
||||||
|
expect(typeof events[0].timestamp).toEqual('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should emit step events on notifyStep', async () => {
|
||||||
|
const steps = [
|
||||||
|
{ name: 'build', description: 'Build artifacts', percentage: 50 },
|
||||||
|
{ name: 'deploy', description: 'Deploy to prod', percentage: 50 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const events: ITaskEvent[] = [];
|
||||||
|
const task = new taskbuffer.Task({
|
||||||
|
name: 'step-event-task',
|
||||||
|
steps,
|
||||||
|
taskFunction: async () => {
|
||||||
|
task.notifyStep('build');
|
||||||
|
task.notifyStep('deploy');
|
||||||
|
return 'done';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
task.eventSubject.subscribe((e) => events.push(e));
|
||||||
|
await task.trigger();
|
||||||
|
|
||||||
|
const stepEvents = events.filter((e) => e.type === 'step');
|
||||||
|
expect(stepEvents.length).toEqual(2);
|
||||||
|
expect(stepEvents[0].stepName).toEqual('build');
|
||||||
|
expect(stepEvents[1].stepName).toEqual('deploy');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should emit started + failed on error', async () => {
|
||||||
|
const events: ITaskEvent[] = [];
|
||||||
|
const task = new taskbuffer.Task({
|
||||||
|
name: 'event-fail-task',
|
||||||
|
taskFunction: async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
task.eventSubject.subscribe((e) => events.push(e));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await task.trigger();
|
||||||
|
} catch {
|
||||||
|
// expected
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(events.length).toEqual(2);
|
||||||
|
expect(events[0].type).toEqual('started');
|
||||||
|
expect(events[1].type).toEqual('failed');
|
||||||
|
expect(events[1].error).toEqual('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should emit failed via done.then path when catchErrors is true', async () => {
|
||||||
|
const events: ITaskEvent[] = [];
|
||||||
|
const task = new taskbuffer.Task({
|
||||||
|
name: 'event-catch-fail-task',
|
||||||
|
catchErrors: true,
|
||||||
|
taskFunction: async () => {
|
||||||
|
throw new Error('swallowed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
task.eventSubject.subscribe((e) => events.push(e));
|
||||||
|
await task.trigger();
|
||||||
|
|
||||||
|
const types = events.map((e) => e.type);
|
||||||
|
expect(types).toContain('started');
|
||||||
|
expect(types).toContain('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('TaskManager.taskSubject should aggregate events from added tasks', async () => {
|
||||||
|
const manager = new taskbuffer.TaskManager();
|
||||||
|
const events: ITaskEvent[] = [];
|
||||||
|
|
||||||
|
const t1 = new taskbuffer.Task({
|
||||||
|
name: 'agg-task-1',
|
||||||
|
taskFunction: async () => 'a',
|
||||||
|
});
|
||||||
|
const t2 = new taskbuffer.Task({
|
||||||
|
name: 'agg-task-2',
|
||||||
|
taskFunction: async () => 'b',
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.addTask(t1);
|
||||||
|
manager.addTask(t2);
|
||||||
|
|
||||||
|
manager.taskSubject.subscribe((e) => events.push(e));
|
||||||
|
|
||||||
|
await t1.trigger();
|
||||||
|
await t2.trigger();
|
||||||
|
|
||||||
|
const names = [...new Set(events.map((e) => e.task.name))];
|
||||||
|
expect(names.sort()).toEqual(['agg-task-1', 'agg-task-2']);
|
||||||
|
expect(events.filter((e) => e.type === 'started').length).toEqual(2);
|
||||||
|
expect(events.filter((e) => e.type === 'completed').length).toEqual(2);
|
||||||
|
|
||||||
|
await manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('events should stop after removeTask', async () => {
|
||||||
|
const manager = new taskbuffer.TaskManager();
|
||||||
|
const events: ITaskEvent[] = [];
|
||||||
|
|
||||||
|
const task = new taskbuffer.Task({
|
||||||
|
name: 'removable-event-task',
|
||||||
|
taskFunction: async () => 'ok',
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.addTask(task);
|
||||||
|
manager.taskSubject.subscribe((e) => events.push(e));
|
||||||
|
|
||||||
|
await task.trigger();
|
||||||
|
const countBefore = events.length;
|
||||||
|
expect(countBefore).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
manager.removeTask(task);
|
||||||
|
|
||||||
|
// Trigger again — events should NOT appear on manager subject
|
||||||
|
await task.trigger();
|
||||||
|
expect(events.length).toEqual(countBefore);
|
||||||
|
|
||||||
|
await manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('event metadata snapshots should include correct labels', async () => {
|
||||||
|
const events: ITaskEvent[] = [];
|
||||||
|
const task = new taskbuffer.Task({
|
||||||
|
name: 'labelled-event-task',
|
||||||
|
taskFunction: async () => 'ok',
|
||||||
|
labels: { team: 'platform' },
|
||||||
|
});
|
||||||
|
|
||||||
|
task.eventSubject.subscribe((e) => events.push(e));
|
||||||
|
await task.trigger();
|
||||||
|
|
||||||
|
for (const e of events) {
|
||||||
|
expect(e.task.labels).toEqual({ team: 'platform' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/taskbuffer',
|
name: '@push.rocks/taskbuffer',
|
||||||
version: '4.0.0',
|
version: '4.1.0',
|
||||||
description: 'A flexible task management library supporting TypeScript, allowing for task buffering, scheduling, and execution with dependency management.'
|
description: 'A flexible task management library supporting TypeScript, allowing for task buffering, scheduling, and execution with dependency management.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export { TaskStep } from './taskbuffer.classes.taskstep.js';
|
|||||||
export type { ITaskStep } from './taskbuffer.classes.taskstep.js';
|
export type { ITaskStep } from './taskbuffer.classes.taskstep.js';
|
||||||
|
|
||||||
// Metadata interfaces
|
// Metadata interfaces
|
||||||
export type { ITaskMetadata, ITaskExecutionReport, IScheduledTaskInfo } from './taskbuffer.interfaces.js';
|
export type { ITaskMetadata, ITaskExecutionReport, IScheduledTaskInfo, ITaskEvent, TTaskEventType } from './taskbuffer.interfaces.js';
|
||||||
|
|
||||||
import * as distributedCoordination from './taskbuffer.classes.distributedcoordinator.js';
|
import * as distributedCoordination from './taskbuffer.classes.distributedcoordinator.js';
|
||||||
export { distributedCoordination };
|
export { distributedCoordination };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as plugins from './taskbuffer.plugins.js';
|
|||||||
import { BufferRunner } from './taskbuffer.classes.bufferrunner.js';
|
import { BufferRunner } from './taskbuffer.classes.bufferrunner.js';
|
||||||
import { CycleCounter } from './taskbuffer.classes.cyclecounter.js';
|
import { CycleCounter } from './taskbuffer.classes.cyclecounter.js';
|
||||||
import { TaskStep, type ITaskStep } from './taskbuffer.classes.taskstep.js';
|
import { TaskStep, type ITaskStep } from './taskbuffer.classes.taskstep.js';
|
||||||
import type { ITaskMetadata } from './taskbuffer.interfaces.js';
|
import type { ITaskMetadata, ITaskEvent, TTaskEventType } from './taskbuffer.interfaces.js';
|
||||||
|
|
||||||
import { logger } from './taskbuffer.logging.js';
|
import { logger } from './taskbuffer.logging.js';
|
||||||
|
|
||||||
@@ -91,6 +91,7 @@ export class Task<T = undefined, TSteps extends ReadonlyArray<{ name: string; de
|
|||||||
// Reset steps and error state at the beginning of task execution
|
// Reset steps and error state at the beginning of task execution
|
||||||
taskToRun.resetSteps();
|
taskToRun.resetSteps();
|
||||||
taskToRun.lastError = undefined;
|
taskToRun.lastError = undefined;
|
||||||
|
taskToRun.emitEvent('started');
|
||||||
|
|
||||||
done.promise
|
done.promise
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -98,6 +99,7 @@ export class Task<T = undefined, TSteps extends ReadonlyArray<{ name: string; de
|
|||||||
|
|
||||||
// Complete all steps when task finishes
|
// Complete all steps when task finishes
|
||||||
taskToRun.completeAllSteps();
|
taskToRun.completeAllSteps();
|
||||||
|
taskToRun.emitEvent(taskToRun.lastError ? 'failed' : 'completed');
|
||||||
|
|
||||||
// When the task has finished running, resolve the finished promise
|
// When the task has finished running, resolve the finished promise
|
||||||
taskToRun.resolveFinished();
|
taskToRun.resolveFinished();
|
||||||
@@ -109,6 +111,7 @@ export class Task<T = undefined, TSteps extends ReadonlyArray<{ name: string; de
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
taskToRun.running = false;
|
taskToRun.running = false;
|
||||||
|
taskToRun.emitEvent('failed', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
|
||||||
// Resolve finished so blocking dependants don't hang
|
// Resolve finished so blocking dependants don't hang
|
||||||
taskToRun.resolveFinished();
|
taskToRun.resolveFinished();
|
||||||
@@ -218,6 +221,8 @@ export class Task<T = undefined, TSteps extends ReadonlyArray<{ name: string; de
|
|||||||
public catchErrors: boolean = false;
|
public catchErrors: boolean = false;
|
||||||
public lastError?: Error;
|
public lastError?: Error;
|
||||||
public errorCount: number = 0;
|
public errorCount: number = 0;
|
||||||
|
public labels: Record<string, string> = {};
|
||||||
|
public readonly eventSubject = new plugins.smartrx.rxjs.Subject<ITaskEvent>();
|
||||||
|
|
||||||
public get idle() {
|
public get idle() {
|
||||||
return !this.running;
|
return !this.running;
|
||||||
@@ -227,6 +232,38 @@ export class Task<T = undefined, TSteps extends ReadonlyArray<{ name: string; de
|
|||||||
this.lastError = undefined;
|
this.lastError = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setLabel(key: string, value: string): void {
|
||||||
|
this.labels[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLabel(key: string): string | undefined {
|
||||||
|
return this.labels[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeLabel(key: string): boolean {
|
||||||
|
if (key in this.labels) {
|
||||||
|
delete this.labels[key];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasLabel(key: string, value?: string): boolean {
|
||||||
|
if (value !== undefined) {
|
||||||
|
return this.labels[key] === value;
|
||||||
|
}
|
||||||
|
return key in this.labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitEvent(type: TTaskEventType, extra?: Partial<ITaskEvent>): void {
|
||||||
|
this.eventSubject.next({
|
||||||
|
type,
|
||||||
|
task: this.getMetadata(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public taskSetup: ITaskSetupFunction<T>;
|
public taskSetup: ITaskSetupFunction<T>;
|
||||||
public setupValue: T;
|
public setupValue: T;
|
||||||
|
|
||||||
@@ -247,6 +284,7 @@ export class Task<T = undefined, TSteps extends ReadonlyArray<{ name: string; de
|
|||||||
taskSetup?: ITaskSetupFunction<T>;
|
taskSetup?: ITaskSetupFunction<T>;
|
||||||
steps?: TSteps;
|
steps?: TSteps;
|
||||||
catchErrors?: boolean;
|
catchErrors?: boolean;
|
||||||
|
labels?: Record<string, string>;
|
||||||
}) {
|
}) {
|
||||||
this.taskFunction = optionsArg.taskFunction;
|
this.taskFunction = optionsArg.taskFunction;
|
||||||
this.preTask = optionsArg.preTask;
|
this.preTask = optionsArg.preTask;
|
||||||
@@ -257,6 +295,7 @@ export class Task<T = undefined, TSteps extends ReadonlyArray<{ name: string; de
|
|||||||
this.name = optionsArg.name;
|
this.name = optionsArg.name;
|
||||||
this.taskSetup = optionsArg.taskSetup;
|
this.taskSetup = optionsArg.taskSetup;
|
||||||
this.catchErrors = optionsArg.catchErrors ?? false;
|
this.catchErrors = optionsArg.catchErrors ?? false;
|
||||||
|
this.labels = optionsArg.labels ? { ...optionsArg.labels } : {};
|
||||||
|
|
||||||
// Initialize steps if provided
|
// Initialize steps if provided
|
||||||
if (optionsArg.steps) {
|
if (optionsArg.steps) {
|
||||||
@@ -309,8 +348,8 @@ export class Task<T = undefined, TSteps extends ReadonlyArray<{ name: string; de
|
|||||||
if (step) {
|
if (step) {
|
||||||
step.start();
|
step.start();
|
||||||
this.currentStepName = stepName as string;
|
this.currentStepName = stepName as string;
|
||||||
|
this.emitEvent('step', { stepName: stepName as string });
|
||||||
// Emit event for frontend updates (could be enhanced with event emitter)
|
|
||||||
if (this.name) {
|
if (this.name) {
|
||||||
logger.log('info', `Task ${this.name}: Starting step "${stepName}" - ${step.description}`);
|
logger.log('info', `Task ${this.name}: Starting step "${stepName}" - ${step.description}`);
|
||||||
}
|
}
|
||||||
@@ -369,6 +408,7 @@ export class Task<T = undefined, TSteps extends ReadonlyArray<{ name: string; de
|
|||||||
cronSchedule: this.cronJob?.cronExpression,
|
cronSchedule: this.cronJob?.cronExpression,
|
||||||
lastError: this.lastError?.message,
|
lastError: this.lastError?.message,
|
||||||
errorCount: this.errorCount,
|
errorCount: this.errorCount,
|
||||||
|
labels: { ...this.labels },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
AbstractDistributedCoordinator,
|
AbstractDistributedCoordinator,
|
||||||
type IDistributedTaskRequestResult,
|
type IDistributedTaskRequestResult,
|
||||||
} from './taskbuffer.classes.distributedcoordinator.js';
|
} from './taskbuffer.classes.distributedcoordinator.js';
|
||||||
import type { ITaskMetadata, ITaskExecutionReport, IScheduledTaskInfo } from './taskbuffer.interfaces.js';
|
import type { ITaskMetadata, ITaskExecutionReport, IScheduledTaskInfo, ITaskEvent } from './taskbuffer.interfaces.js';
|
||||||
import { logger } from './taskbuffer.logging.js';
|
import { logger } from './taskbuffer.logging.js';
|
||||||
|
|
||||||
export interface ICronJob {
|
export interface ICronJob {
|
||||||
@@ -20,6 +20,8 @@ export interface ITaskManagerConstructorOptions {
|
|||||||
export class TaskManager {
|
export class TaskManager {
|
||||||
public randomId = plugins.smartunique.shortId();
|
public randomId = plugins.smartunique.shortId();
|
||||||
public taskMap = new plugins.lik.ObjectMap<Task<any, any>>();
|
public taskMap = new plugins.lik.ObjectMap<Task<any, any>>();
|
||||||
|
public readonly taskSubject = new plugins.smartrx.rxjs.Subject<ITaskEvent>();
|
||||||
|
private taskSubscriptions = new Map<Task<any, any>, plugins.smartrx.rxjs.Subscription>();
|
||||||
private cronJobManager = new plugins.smarttime.CronManager();
|
private cronJobManager = new plugins.smarttime.CronManager();
|
||||||
public options: ITaskManagerConstructorOptions = {
|
public options: ITaskManagerConstructorOptions = {
|
||||||
distributedCoordinator: null,
|
distributedCoordinator: null,
|
||||||
@@ -38,6 +40,19 @@ export class TaskManager {
|
|||||||
throw new Error('Task must have a name to be added to taskManager');
|
throw new Error('Task must have a name to be added to taskManager');
|
||||||
}
|
}
|
||||||
this.taskMap.add(task);
|
this.taskMap.add(task);
|
||||||
|
const subscription = task.eventSubject.subscribe((event) => {
|
||||||
|
this.taskSubject.next(event);
|
||||||
|
});
|
||||||
|
this.taskSubscriptions.set(task, subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeTask(task: Task<any, any>): void {
|
||||||
|
this.taskMap.remove(task);
|
||||||
|
const subscription = this.taskSubscriptions.get(task);
|
||||||
|
if (subscription) {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
this.taskSubscriptions.delete(task);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public addAndScheduleTask(task: Task<any, any>, cronString: string) {
|
public addAndScheduleTask(task: Task<any, any>, cronString: string) {
|
||||||
@@ -150,6 +165,10 @@ export class TaskManager {
|
|||||||
if (this.options.distributedCoordinator) {
|
if (this.options.distributedCoordinator) {
|
||||||
await this.options.distributedCoordinator.stop();
|
await this.options.distributedCoordinator.stop();
|
||||||
}
|
}
|
||||||
|
for (const [, subscription] of this.taskSubscriptions) {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
this.taskSubscriptions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get metadata for a specific task
|
// Get metadata for a specific task
|
||||||
@@ -198,6 +217,14 @@ export class TaskManager {
|
|||||||
return scheduledRuns;
|
return scheduledRuns;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getTasksByLabel(key: string, value: string): Task<any, any>[] {
|
||||||
|
return this.taskMap.getArray().filter(task => task.labels[key] === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTasksMetadataByLabel(key: string, value: string): ITaskMetadata[] {
|
||||||
|
return this.getTasksByLabel(key, value).map(task => task.getMetadata());
|
||||||
|
}
|
||||||
|
|
||||||
// Add, execute, and remove a task while collecting metadata
|
// Add, execute, and remove a task while collecting metadata
|
||||||
public async addExecuteRemoveTask<T, TSteps extends ReadonlyArray<{ name: string; description: string; percentage: number }>>(
|
public async addExecuteRemoveTask<T, TSteps extends ReadonlyArray<{ name: string; description: string; percentage: number }>>(
|
||||||
task: Task<T, TSteps>,
|
task: Task<T, TSteps>,
|
||||||
@@ -236,7 +263,7 @@ export class TaskManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Remove task from manager
|
// Remove task from manager
|
||||||
this.taskMap.remove(task);
|
this.removeTask(task);
|
||||||
|
|
||||||
// Deschedule if it was scheduled
|
// Deschedule if it was scheduled
|
||||||
if (options?.schedule && task.name) {
|
if (options?.schedule && task.name) {
|
||||||
@@ -260,7 +287,7 @@ export class TaskManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Remove task from manager even on error
|
// Remove task from manager even on error
|
||||||
this.taskMap.remove(task);
|
this.removeTask(task);
|
||||||
|
|
||||||
// Deschedule if it was scheduled
|
// Deschedule if it was scheduled
|
||||||
if (options?.schedule && task.name) {
|
if (options?.schedule && task.name) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface ITaskMetadata {
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
errorCount?: number;
|
errorCount?: number;
|
||||||
|
labels?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITaskExecutionReport {
|
export interface ITaskExecutionReport {
|
||||||
@@ -38,4 +39,14 @@ export interface IScheduledTaskInfo {
|
|||||||
lastRun?: Date;
|
lastRun?: Date;
|
||||||
steps?: ITaskStep[];
|
steps?: ITaskStep[];
|
||||||
metadata?: ITaskMetadata;
|
metadata?: ITaskMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TTaskEventType = 'started' | 'step' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
export interface ITaskEvent {
|
||||||
|
type: TTaskEventType;
|
||||||
|
task: ITaskMetadata;
|
||||||
|
timestamp: number;
|
||||||
|
stepName?: string; // present when type === 'step'
|
||||||
|
error?: string; // present when type === 'failed'
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/taskbuffer',
|
name: '@push.rocks/taskbuffer',
|
||||||
version: '4.0.0',
|
version: '4.1.0',
|
||||||
description: 'A flexible task management library supporting TypeScript, allowing for task buffering, scheduling, and execution with dependency management.'
|
description: 'A flexible task management library supporting TypeScript, allowing for task buffering, scheduling, and execution with dependency management.'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user