feat(core): Add step-based progress tracking, task metadata and enhanced TaskManager scheduling/metadata APIs

This commit is contained in:
2025-09-06 13:36:04 +00:00
parent 6c9b975029
commit 9784a5eacf
12 changed files with 1191 additions and 140 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/taskbuffer',
version: '3.1.10',
version: '3.2.0',
description: 'A flexible task management library supporting TypeScript, allowing for task buffering, scheduling, and execution with dependency management.'
}

View File

@@ -1,10 +1,18 @@
export { Task } from './taskbuffer.classes.task.js';
export type { ITaskFunction } from './taskbuffer.classes.task.js';
export type { ITaskFunction, StepNames } from './taskbuffer.classes.task.js';
export { Taskchain } from './taskbuffer.classes.taskchain.js';
export { Taskparallel } from './taskbuffer.classes.taskparallel.js';
export { TaskManager } from './taskbuffer.classes.taskmanager.js';
export { TaskOnce } from './taskbuffer.classes.taskonce.js';
export { TaskRunner } from './taskbuffer.classes.taskrunner.js';
export { TaskDebounced } from './taskbuffer.classes.taskdebounced.js';
// Task step system
export { TaskStep } from './taskbuffer.classes.taskstep.js';
export type { ITaskStep } from './taskbuffer.classes.taskstep.js';
// Metadata interfaces
export type { ITaskMetadata, ITaskExecutionReport, IScheduledTaskInfo } from './taskbuffer.interfaces.js';
import * as distributedCoordination from './taskbuffer.classes.distributedcoordinator.js';
export { distributedCoordination };

View File

@@ -1,6 +1,8 @@
import * as plugins from './taskbuffer.plugins.js';
import { BufferRunner } from './taskbuffer.classes.bufferrunner.js';
import { CycleCounter } from './taskbuffer.classes.cyclecounter.js';
import { TaskStep, type ITaskStep } from './taskbuffer.classes.taskstep.js';
import type { ITaskMetadata } from './taskbuffer.interfaces.js';
import { logger } from './taskbuffer.logging.js';
@@ -14,18 +16,21 @@ export interface ITaskSetupFunction<T = undefined> {
export type TPreOrAfterTaskFunction = () => Task<any>;
export class Task<T = undefined> {
public static extractTask<T = undefined>(
preOrAfterTaskArg: Task<T> | TPreOrAfterTaskFunction,
): Task<T> {
// Type helper to extract step names from array
export type StepNames<T> = T extends ReadonlyArray<{ name: infer N }> ? N : never;
export class Task<T = undefined, TSteps extends ReadonlyArray<{ name: string; description: string; percentage: number }> = []> {
public static extractTask<T = undefined, TSteps extends ReadonlyArray<{ name: string; description: string; percentage: number }> = []>(
preOrAfterTaskArg: Task<T, TSteps> | TPreOrAfterTaskFunction,
): Task<T, TSteps> {
switch (true) {
case !preOrAfterTaskArg:
return null;
case preOrAfterTaskArg instanceof Task:
return preOrAfterTaskArg as Task<T>;
return preOrAfterTaskArg as Task<T, TSteps>;
case typeof preOrAfterTaskArg === 'function':
const taskFunction = preOrAfterTaskArg as TPreOrAfterTaskFunction;
return taskFunction();
return taskFunction() as unknown as Task<T, TSteps>;
default:
return null;
}
@@ -45,9 +50,9 @@ export class Task<T = undefined> {
}
};
public static isTaskTouched<T = undefined>(
taskArg: Task<T> | TPreOrAfterTaskFunction,
touchedTasksArray: Task<T>[],
public static isTaskTouched<T = undefined, TSteps extends ReadonlyArray<{ name: string; description: string; percentage: number }> = []>(
taskArg: Task<T, TSteps> | TPreOrAfterTaskFunction,
touchedTasksArray: Task<T, TSteps>[],
): boolean {
const taskToCheck = Task.extractTask(taskArg);
let result = false;
@@ -59,9 +64,9 @@ export class Task<T = undefined> {
return result;
}
public static runTask = async <T>(
taskArg: Task<T> | TPreOrAfterTaskFunction,
optionsArg: { x?: any; touchedTasksArray?: Task<T>[] },
public static runTask = async <T, TSteps extends ReadonlyArray<{ name: string; description: string; percentage: number }> = []>(
taskArg: Task<T, TSteps> | TPreOrAfterTaskFunction,
optionsArg: { x?: any; touchedTasksArray?: Task<T, TSteps>[] },
) => {
const taskToRun = Task.extractTask(taskArg);
const done = plugins.smartpromise.defer();
@@ -80,9 +85,17 @@ export class Task<T = undefined> {
}
taskToRun.running = true;
taskToRun.runCount++;
taskToRun.lastRun = new Date();
// Reset steps at the beginning of task execution
taskToRun.resetSteps();
done.promise.then(async () => {
taskToRun.running = false;
// Complete all steps when task finishes
taskToRun.completeAllSteps();
// When the task has finished running, resolve the finished promise
taskToRun.resolveFinished();
@@ -98,7 +111,7 @@ export class Task<T = undefined> {
...optionsArg,
};
const x = options.x;
const touchedTasksArray: Task<T>[] = options.touchedTasksArray;
const touchedTasksArray: Task<T, TSteps>[] = options.touchedTasksArray;
touchedTasksArray.push(taskToRun);
@@ -158,8 +171,8 @@ export class Task<T = undefined> {
public execDelay: number;
public timeout: number;
public preTask: Task<T> | TPreOrAfterTaskFunction;
public afterTask: Task<T> | TPreOrAfterTaskFunction;
public preTask: Task<T, any> | TPreOrAfterTaskFunction;
public afterTask: Task<T, any> | TPreOrAfterTaskFunction;
// Add a list to store the blocking tasks
public blockingTasks: Task[] = [];
@@ -171,6 +184,8 @@ export class Task<T = undefined> {
public running: boolean = false;
public bufferRunner = new BufferRunner(this);
public cycleCounter = new CycleCounter(this);
public lastRun?: Date;
public runCount: number = 0;
public get idle() {
return !this.running;
@@ -179,15 +194,22 @@ export class Task<T = undefined> {
public taskSetup: ITaskSetupFunction<T>;
public setupValue: T;
// Step tracking properties
private steps = new Map<string, TaskStep>();
private stepProgress = new Map<string, number>();
public currentStepName?: string;
private providedSteps?: TSteps;
constructor(optionsArg: {
taskFunction: ITaskFunction<T>;
preTask?: Task<T> | TPreOrAfterTaskFunction;
afterTask?: Task<T> | TPreOrAfterTaskFunction;
preTask?: Task<T, any> | TPreOrAfterTaskFunction;
afterTask?: Task<T, any> | TPreOrAfterTaskFunction;
buffered?: boolean;
bufferMax?: number;
execDelay?: number;
name?: string;
taskSetup?: ITaskSetupFunction<T>;
steps?: TSteps;
}) {
this.taskFunction = optionsArg.taskFunction;
this.preTask = optionsArg.preTask;
@@ -198,6 +220,19 @@ export class Task<T = undefined> {
this.name = optionsArg.name;
this.taskSetup = optionsArg.taskSetup;
// Initialize steps if provided
if (optionsArg.steps) {
this.providedSteps = optionsArg.steps;
for (const stepConfig of optionsArg.steps) {
const step = new TaskStep({
name: stepConfig.name,
description: stepConfig.description,
percentage: stepConfig.percentage,
});
this.steps.set(stepConfig.name, step);
}
}
// Create the finished promise
this.finished = new Promise((resolve) => {
this.resolveFinished = resolve;
@@ -213,10 +248,102 @@ export class Task<T = undefined> {
}
public triggerUnBuffered(x?: any): Promise<any> {
return Task.runTask<T>(this, { x: x });
return Task.runTask<T, TSteps>(this, { x: x });
}
public triggerBuffered(x?: any): Promise<any> {
return this.bufferRunner.trigger(x);
}
// Step notification method with typed step names
public notifyStep(stepName: StepNames<TSteps>): void {
// Complete previous step if exists
if (this.currentStepName) {
const prevStep = this.steps.get(this.currentStepName);
if (prevStep && prevStep.status === 'active') {
prevStep.complete();
this.stepProgress.set(this.currentStepName, prevStep.percentage);
}
}
// Start new step
const step = this.steps.get(stepName as string);
if (step) {
step.start();
this.currentStepName = stepName as string;
// Emit event for frontend updates (could be enhanced with event emitter)
if (this.name) {
logger.log('info', `Task ${this.name}: Starting step "${stepName}" - ${step.description}`);
}
}
}
// Get current progress based on completed steps
public getProgress(): number {
let totalProgress = 0;
for (const [stepName, percentage] of this.stepProgress) {
totalProgress += percentage;
}
// Add partial progress of current step if exists
if (this.currentStepName) {
const currentStep = this.steps.get(this.currentStepName);
if (currentStep && currentStep.status === 'active') {
// Could add partial progress calculation here if needed
// For now, we'll consider active steps as 50% complete
totalProgress += currentStep.percentage * 0.5;
}
}
return Math.min(100, Math.round(totalProgress));
}
// Get all steps metadata
public getStepsMetadata(): ITaskStep[] {
return Array.from(this.steps.values()).map(step => step.toJSON());
}
// Get task metadata
public getMetadata(): ITaskMetadata {
return {
name: this.name || 'unnamed',
version: this.version,
status: this.running ? 'running' : 'idle',
steps: this.getStepsMetadata(),
currentStep: this.currentStepName,
currentProgress: this.getProgress(),
runCount: this.runCount,
buffered: this.buffered,
bufferMax: this.bufferMax,
timeout: this.timeout,
cronSchedule: this.cronJob?.cronExpression,
};
}
// Reset all steps to pending state
public resetSteps(): void {
this.steps.forEach(step => step.reset());
this.stepProgress.clear();
this.currentStepName = undefined;
}
// Complete all remaining steps (useful for cleanup)
private completeAllSteps(): void {
if (this.currentStepName) {
const currentStep = this.steps.get(this.currentStepName);
if (currentStep && currentStep.status === 'active') {
currentStep.complete();
this.stepProgress.set(this.currentStepName, currentStep.percentage);
}
}
// Mark any pending steps as completed (in case of early task completion)
this.steps.forEach((step, name) => {
if (step.status === 'pending') {
// Don't add their percentage to progress since they weren't actually executed
step.status = 'completed';
}
});
}
}

View File

@@ -4,6 +4,7 @@ import {
AbstractDistributedCoordinator,
type IDistributedTaskRequestResult,
} from './taskbuffer.classes.distributedcoordinator.js';
import type { ITaskMetadata, ITaskExecutionReport, IScheduledTaskInfo } from './taskbuffer.interfaces.js';
export interface ICronJob {
cronString: string;
@@ -17,7 +18,7 @@ export interface ITaskManagerConstructorOptions {
export class TaskManager {
public randomId = plugins.smartunique.shortId();
public taskMap = new plugins.lik.ObjectMap<Task>();
public taskMap = new plugins.lik.ObjectMap<Task<any, any>>();
private cronJobManager = new plugins.smarttime.CronManager();
public options: ITaskManagerConstructorOptions = {
distributedCoordinator: null,
@@ -27,18 +28,18 @@ export class TaskManager {
this.options = Object.assign(this.options, options);
}
public getTaskByName(taskName: string): Task {
public getTaskByName(taskName: string): Task<any, any> {
return this.taskMap.findSync((task) => task.name === taskName);
}
public addTask(task: Task): void {
public addTask(task: Task<any, any>): void {
if (!task.name) {
throw new Error('Task must have a name to be added to taskManager');
}
this.taskMap.add(task);
}
public addAndScheduleTask(task: Task, cronString: string) {
public addAndScheduleTask(task: Task<any, any>, cronString: string) {
this.addTask(task);
this.scheduleTaskByName(task.name, cronString);
}
@@ -51,7 +52,7 @@ export class TaskManager {
return taskToTrigger.trigger();
}
public async triggerTask(task: Task) {
public async triggerTask(task: Task<any, any>) {
return task.trigger();
}
@@ -63,7 +64,7 @@ export class TaskManager {
this.handleTaskScheduling(taskToSchedule, cronString);
}
private handleTaskScheduling(task: Task, cronString: string) {
private handleTaskScheduling(task: Task<any, any>, cronString: string) {
const cronJob = this.cronJobManager.addCronjob(
cronString,
async (triggerTime: number) => {
@@ -86,7 +87,7 @@ export class TaskManager {
task.cronJob = cronJob;
}
private logTaskState(task: Task) {
private logTaskState(task: Task<any, any>) {
console.log(`Taskbuffer schedule triggered task >>${task.name}<<`);
const bufferState = task.buffered
? `buffered with max ${task.bufferMax} buffered calls`
@@ -95,7 +96,7 @@ export class TaskManager {
}
private async performDistributedConsultation(
task: Task,
task: Task<any, any>,
triggerTime: number,
): Promise<IDistributedTaskRequestResult> {
console.log('Found a distributed coordinator, performing consultation.');
@@ -123,7 +124,7 @@ export class TaskManager {
}
}
public async descheduleTask(task: Task) {
public async descheduleTask(task: Task<any, any>) {
await this.descheduleTaskByName(task.name);
}
@@ -145,4 +146,123 @@ export class TaskManager {
await this.options.distributedCoordinator.stop();
}
}
// Get metadata for a specific task
public getTaskMetadata(taskName: string): ITaskMetadata | null {
const task = this.getTaskByName(taskName);
if (!task) return null;
return task.getMetadata();
}
// Get metadata for all tasks
public getAllTasksMetadata(): ITaskMetadata[] {
return this.taskMap.getArray().map(task => task.getMetadata());
}
// Get scheduled tasks with their schedules and next run times
public getScheduledTasks(): IScheduledTaskInfo[] {
const scheduledTasks: IScheduledTaskInfo[] = [];
for (const task of this.taskMap.getArray()) {
if (task.cronJob) {
scheduledTasks.push({
name: task.name || 'unnamed',
schedule: task.cronJob.cronExpression,
nextRun: new Date(task.cronJob.getNextExecutionTime()),
lastRun: task.lastRun,
steps: task.getStepsMetadata?.(),
metadata: task.getMetadata(),
});
}
}
return scheduledTasks;
}
// Get next scheduled runs across all tasks
public getNextScheduledRuns(limit: number = 10): Array<{ taskName: string; nextRun: Date; schedule: string }> {
const scheduledRuns = this.getScheduledTasks()
.map(task => ({
taskName: task.name,
nextRun: task.nextRun,
schedule: task.schedule,
}))
.sort((a, b) => a.nextRun.getTime() - b.nextRun.getTime())
.slice(0, limit);
return scheduledRuns;
}
// Add, execute, and remove a task while collecting metadata
public async addExecuteRemoveTask<T, TSteps extends ReadonlyArray<{ name: string; description: string; percentage: number }>>(
task: Task<T, TSteps>,
options?: {
schedule?: string;
trackProgress?: boolean;
}
): Promise<ITaskExecutionReport> {
// Add task to manager
this.addTask(task);
// Optionally schedule it
if (options?.schedule) {
this.scheduleTaskByName(task.name!, options.schedule);
}
const startTime = Date.now();
const progressUpdates: Array<{ stepName: string; timestamp: number }> = [];
try {
// Execute the task
const result = await task.trigger();
// Collect execution report
const report: ITaskExecutionReport = {
taskName: task.name || 'unnamed',
startTime,
endTime: Date.now(),
duration: Date.now() - startTime,
steps: task.getStepsMetadata(),
stepsCompleted: task.getStepsMetadata()
.filter(step => step.status === 'completed')
.map(step => step.name),
progress: task.getProgress(),
result,
};
// Remove task from manager
this.taskMap.remove(task);
// Deschedule if it was scheduled
if (options?.schedule && task.name) {
this.descheduleTaskByName(task.name);
}
return report;
} catch (error) {
// Create error report
const errorReport: ITaskExecutionReport = {
taskName: task.name || 'unnamed',
startTime,
endTime: Date.now(),
duration: Date.now() - startTime,
steps: task.getStepsMetadata(),
stepsCompleted: task.getStepsMetadata()
.filter(step => step.status === 'completed')
.map(step => step.name),
progress: task.getProgress(),
error: error as Error,
};
// Remove task from manager even on error
this.taskMap.remove(task);
// Deschedule if it was scheduled
if (options?.schedule && task.name) {
this.descheduleTaskByName(task.name);
}
throw errorReport;
}
}
}

View File

@@ -0,0 +1,57 @@
export interface ITaskStep {
name: string;
description: string;
percentage: number; // Weight of this step (0-100)
status: 'pending' | 'active' | 'completed';
startTime?: number;
endTime?: number;
duration?: number;
}
export class TaskStep implements ITaskStep {
public name: string;
public description: string;
public percentage: number;
public status: 'pending' | 'active' | 'completed' = 'pending';
public startTime?: number;
public endTime?: number;
public duration?: number;
constructor(config: { name: string; description: string; percentage: number }) {
this.name = config.name;
this.description = config.description;
this.percentage = config.percentage;
}
public start(): void {
this.status = 'active';
this.startTime = Date.now();
}
public complete(): void {
if (this.startTime) {
this.endTime = Date.now();
this.duration = this.endTime - this.startTime;
}
this.status = 'completed';
}
public reset(): void {
this.status = 'pending';
this.startTime = undefined;
this.endTime = undefined;
this.duration = undefined;
}
public toJSON(): ITaskStep {
return {
name: this.name,
description: this.description,
percentage: this.percentage,
status: this.status,
startTime: this.startTime,
endTime: this.endTime,
duration: this.duration,
};
}
}

View File

@@ -0,0 +1,39 @@
import type { ITaskStep } from './taskbuffer.classes.taskstep.js';
export interface ITaskMetadata {
name: string;
version?: string;
status: 'idle' | 'running' | 'completed' | 'failed';
steps: ITaskStep[];
currentStep?: string;
currentProgress: number; // 0-100
lastRun?: Date;
nextRun?: Date; // For scheduled tasks
runCount: number;
averageDuration?: number;
cronSchedule?: string;
buffered?: boolean;
bufferMax?: number;
timeout?: number;
}
export interface ITaskExecutionReport {
taskName: string;
startTime: number;
endTime: number;
duration: number;
steps: ITaskStep[];
stepsCompleted: string[];
progress: number;
result?: any;
error?: Error;
}
export interface IScheduledTaskInfo {
name: string;
schedule: string;
nextRun: Date;
lastRun?: Date;
steps?: ITaskStep[];
metadata?: ITaskMetadata;
}