feat(task): add task labels and push-based task events

This commit is contained in:
2026-01-26 00:39:30 +00:00
parent 9a3a3e3eab
commit 6030fb2805
9 changed files with 360 additions and 9 deletions

View File

@@ -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.

View File

@@ -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`

View 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();

View File

@@ -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.'
} }

View File

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

View File

@@ -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 },
}; };
} }

View File

@@ -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) {

View File

@@ -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 {
@@ -39,3 +40,13 @@ export interface IScheduledTaskInfo {
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'
}

View File

@@ -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.'
} }