Steps 1-11, still need to do 12
This commit is contained in:
@@ -23,18 +23,32 @@ class ProgressService {
|
||||
* @param {Object} config - Configuration object with operation details
|
||||
* @param {string} config.targetTag - The tag being targeted
|
||||
* @param {number} config.priceAdjustmentPercentage - The percentage adjustment
|
||||
* @param {Object} schedulingContext - Optional scheduling context
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logOperationStart(config) {
|
||||
async logOperationStart(config, schedulingContext = null) {
|
||||
const timestamp = this.formatTimestamp();
|
||||
const content = `
|
||||
## Price Update Operation - ${timestamp}
|
||||
const operationTitle =
|
||||
schedulingContext && schedulingContext.isScheduled
|
||||
? "Scheduled Price Update Operation"
|
||||
: "Price Update Operation";
|
||||
|
||||
let content = `
|
||||
## ${operationTitle} - ${timestamp}
|
||||
|
||||
**Configuration:**
|
||||
- Target Tag: ${config.targetTag}
|
||||
- Price Adjustment: ${config.priceAdjustmentPercentage}%
|
||||
- Started: ${timestamp}
|
||||
`;
|
||||
|
||||
if (schedulingContext && schedulingContext.isScheduled) {
|
||||
content += `- Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}
|
||||
- Original Schedule Input: ${schedulingContext.originalInput}
|
||||
`;
|
||||
}
|
||||
|
||||
content += `
|
||||
**Progress:**
|
||||
`;
|
||||
|
||||
@@ -45,18 +59,32 @@ class ProgressService {
|
||||
* Logs the start of a price rollback operation (Requirements 7.1, 8.3)
|
||||
* @param {Object} config - Configuration object with operation details
|
||||
* @param {string} config.targetTag - The tag being targeted
|
||||
* @param {Object} schedulingContext - Optional scheduling context
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logRollbackStart(config) {
|
||||
async logRollbackStart(config, schedulingContext = null) {
|
||||
const timestamp = this.formatTimestamp();
|
||||
const content = `
|
||||
## Price Rollback Operation - ${timestamp}
|
||||
const operationTitle =
|
||||
schedulingContext && schedulingContext.isScheduled
|
||||
? "Scheduled Price Rollback Operation"
|
||||
: "Price Rollback Operation";
|
||||
|
||||
let content = `
|
||||
## ${operationTitle} - ${timestamp}
|
||||
|
||||
**Configuration:**
|
||||
- Target Tag: ${config.targetTag}
|
||||
- Operation Mode: rollback
|
||||
- Started: ${timestamp}
|
||||
`;
|
||||
|
||||
if (schedulingContext && schedulingContext.isScheduled) {
|
||||
content += `- Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}
|
||||
- Original Schedule Input: ${schedulingContext.originalInput}
|
||||
`;
|
||||
}
|
||||
|
||||
content += `
|
||||
**Progress:**
|
||||
`;
|
||||
|
||||
@@ -117,14 +145,20 @@ class ProgressService {
|
||||
* @param {string} entry.productTitle - Product title
|
||||
* @param {string} entry.variantId - Variant ID (optional)
|
||||
* @param {string} entry.errorMessage - Error message
|
||||
* @param {Object} schedulingContext - Optional scheduling context
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logError(entry) {
|
||||
async logError(entry, schedulingContext = null) {
|
||||
const timestamp = this.formatTimestamp();
|
||||
const variantInfo = entry.variantId ? ` - Variant: ${entry.variantId}` : "";
|
||||
const schedulingInfo =
|
||||
schedulingContext && schedulingContext.isScheduled
|
||||
? ` - Scheduled Operation: ${schedulingContext.scheduledTime.toLocaleString()}`
|
||||
: "";
|
||||
|
||||
const content = `- ❌ **${entry.productTitle}** (${entry.productId})${variantInfo}
|
||||
- Error: ${entry.errorMessage}
|
||||
- Failed: ${timestamp}
|
||||
- Failed: ${timestamp}${schedulingInfo}
|
||||
`;
|
||||
|
||||
await this.appendToProgressFile(content);
|
||||
@@ -257,16 +291,145 @@ ${content}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs detailed error analysis and patterns
|
||||
* @param {Array} errors - Array of error objects from operation results
|
||||
* Logs scheduling confirmation to progress file (Requirements 2.1, 2.3)
|
||||
* @param {Object} schedulingInfo - Scheduling information
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logErrorAnalysis(errors) {
|
||||
async logSchedulingConfirmation(schedulingInfo) {
|
||||
const { scheduledTime, originalInput, operationType, config } =
|
||||
schedulingInfo;
|
||||
const timestamp = this.formatTimestamp();
|
||||
|
||||
const content = `
|
||||
## Scheduled Operation Confirmation - ${timestamp}
|
||||
|
||||
**Scheduling Details:**
|
||||
- Operation Type: ${operationType}
|
||||
- Scheduled Time: ${scheduledTime.toLocaleString()}
|
||||
- Original Input: ${originalInput}
|
||||
- Confirmation Time: ${timestamp}
|
||||
|
||||
**Operation Configuration:**
|
||||
- Target Tag: ${config.targetTag}
|
||||
${
|
||||
operationType === "update"
|
||||
? `- Price Adjustment: ${config.priceAdjustmentPercentage}%`
|
||||
: ""
|
||||
}
|
||||
- Shop Domain: ${config.shopDomain}
|
||||
|
||||
**Status:** Waiting for scheduled execution time
|
||||
|
||||
`;
|
||||
|
||||
await this.appendToProgressFile(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs scheduled execution start to progress file (Requirements 2.3, 5.4)
|
||||
* @param {Object} executionInfo - Execution information
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logScheduledExecutionStart(executionInfo) {
|
||||
const { scheduledTime, actualTime, operationType } = executionInfo;
|
||||
const timestamp = this.formatTimestamp();
|
||||
const delay = actualTime.getTime() - scheduledTime.getTime();
|
||||
const delayText =
|
||||
Math.abs(delay) < 1000
|
||||
? "on time"
|
||||
: delay > 0
|
||||
? `${Math.round(delay / 1000)}s late`
|
||||
: `${Math.round(Math.abs(delay) / 1000)}s early`;
|
||||
|
||||
const content = `
|
||||
**Scheduled Execution Started - ${timestamp}**
|
||||
- Operation Type: ${operationType}
|
||||
- Scheduled Time: ${scheduledTime.toLocaleString()}
|
||||
- Actual Start Time: ${actualTime.toLocaleString()}
|
||||
- Timing: ${delayText}
|
||||
|
||||
`;
|
||||
|
||||
await this.appendToProgressFile(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs scheduled operation cancellation to progress file (Requirements 3.1, 3.2)
|
||||
* @param {Object} cancellationInfo - Cancellation information
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logScheduledOperationCancellation(cancellationInfo) {
|
||||
const { scheduledTime, cancelledTime, operationType, reason } =
|
||||
cancellationInfo;
|
||||
const timestamp = this.formatTimestamp();
|
||||
const timeRemaining = scheduledTime.getTime() - cancelledTime.getTime();
|
||||
const remainingText = this.formatTimeRemaining(timeRemaining);
|
||||
|
||||
const content = `
|
||||
## Scheduled Operation Cancelled - ${timestamp}
|
||||
|
||||
**Cancellation Details:**
|
||||
- Operation Type: ${operationType}
|
||||
- Scheduled Time: ${scheduledTime.toLocaleString()}
|
||||
- Cancelled Time: ${cancelledTime.toLocaleString()}
|
||||
- Time Remaining: ${remainingText}
|
||||
- Reason: ${reason}
|
||||
|
||||
**Status:** Operation cancelled before execution
|
||||
|
||||
---
|
||||
|
||||
`;
|
||||
|
||||
await this.appendToProgressFile(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time remaining into human-readable string
|
||||
* @param {number} milliseconds - Time remaining in milliseconds
|
||||
* @returns {string} Formatted time string (e.g., "2h 30m 15s")
|
||||
*/
|
||||
formatTimeRemaining(milliseconds) {
|
||||
if (milliseconds <= 0) {
|
||||
return "0s";
|
||||
}
|
||||
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
const remainingHours = hours % 24;
|
||||
const remainingMinutes = minutes % 60;
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
const parts = [];
|
||||
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (remainingHours > 0) parts.push(`${remainingHours}h`);
|
||||
if (remainingMinutes > 0) parts.push(`${remainingMinutes}m`);
|
||||
if (remainingSeconds > 0 || parts.length === 0)
|
||||
parts.push(`${remainingSeconds}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs detailed error analysis and patterns
|
||||
* @param {Array} errors - Array of error objects from operation results
|
||||
* @param {Object} schedulingContext - Optional scheduling context
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logErrorAnalysis(errors, schedulingContext = null) {
|
||||
if (!errors || errors.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = this.formatTimestamp();
|
||||
const analysisTitle =
|
||||
schedulingContext && schedulingContext.isScheduled
|
||||
? "Scheduled Operation Error Analysis"
|
||||
: "Error Analysis";
|
||||
|
||||
// Categorize errors by type
|
||||
const errorCategories = {};
|
||||
@@ -310,8 +473,18 @@ ${content}`;
|
||||
});
|
||||
|
||||
let content = `
|
||||
**Error Analysis - ${timestamp}**
|
||||
**${analysisTitle} - ${timestamp}**
|
||||
`;
|
||||
|
||||
if (schedulingContext && schedulingContext.isScheduled) {
|
||||
content += `
|
||||
**Scheduling Context:**
|
||||
- Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}
|
||||
- Original Schedule Input: ${schedulingContext.originalInput}
|
||||
`;
|
||||
}
|
||||
|
||||
content += `
|
||||
**Error Summary by Category:**
|
||||
`;
|
||||
|
||||
|
||||
640
src/services/schedule.js
Normal file
640
src/services/schedule.js
Normal file
@@ -0,0 +1,640 @@
|
||||
/**
|
||||
* ScheduleService - Handles scheduling functionality for delayed execution
|
||||
* Supports datetime parsing, validation, delay calculation, and countdown display
|
||||
*/
|
||||
class ScheduleService {
|
||||
constructor(logger) {
|
||||
this.logger = logger;
|
||||
this.cancelRequested = false;
|
||||
this.countdownInterval = null;
|
||||
this.currentTimeoutId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate scheduled time from environment variable
|
||||
* @param {string} scheduledTimeString - ISO 8601 datetime string
|
||||
* @returns {Date} Parsed date object
|
||||
* @throws {Error} If datetime format is invalid or in the past
|
||||
*/
|
||||
parseScheduledTime(scheduledTimeString) {
|
||||
// Enhanced input validation with clear error messages
|
||||
if (!scheduledTimeString) {
|
||||
throw new Error(
|
||||
"❌ Scheduled time is required but not provided.\n" +
|
||||
"💡 Set the SCHEDULED_EXECUTION_TIME environment variable with a valid datetime.\n" +
|
||||
"📝 Example: SCHEDULED_EXECUTION_TIME='2024-12-25T10:30:00'"
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof scheduledTimeString !== "string") {
|
||||
throw new Error(
|
||||
"❌ Scheduled time must be provided as a string.\n" +
|
||||
`📊 Received type: ${typeof scheduledTimeString}\n` +
|
||||
"💡 Ensure SCHEDULED_EXECUTION_TIME is set as a string value."
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedInput = scheduledTimeString.trim();
|
||||
if (trimmedInput === "") {
|
||||
throw new Error(
|
||||
"❌ Scheduled time cannot be empty or contain only whitespace.\n" +
|
||||
"💡 Provide a valid ISO 8601 datetime string.\n" +
|
||||
"📝 Example: SCHEDULED_EXECUTION_TIME='2024-12-25T10:30:00'"
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced datetime format validation with detailed error messages
|
||||
const iso8601Regex =
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?([+-]\d{2}:\d{2}|Z)?$/;
|
||||
|
||||
if (!iso8601Regex.test(trimmedInput)) {
|
||||
const commonFormats = [
|
||||
"YYYY-MM-DDTHH:MM:SS (e.g., '2024-12-25T10:30:00')",
|
||||
"YYYY-MM-DDTHH:MM:SSZ (e.g., '2024-12-25T10:30:00Z')",
|
||||
"YYYY-MM-DDTHH:MM:SS+HH:MM (e.g., '2024-12-25T10:30:00-05:00')",
|
||||
"YYYY-MM-DDTHH:MM:SS.sssZ (e.g., '2024-12-25T10:30:00.000Z')",
|
||||
];
|
||||
|
||||
throw new Error(
|
||||
`❌ Invalid datetime format: "${trimmedInput}"\n\n` +
|
||||
"📋 The datetime must be in ISO 8601 format. Accepted formats:\n" +
|
||||
commonFormats.map((format) => ` • ${format}`).join("\n") +
|
||||
"\n\n" +
|
||||
"🔍 Common issues to check:\n" +
|
||||
" • Use 'T' to separate date and time (not space)\n" +
|
||||
" • Use 24-hour format (00-23 for hours)\n" +
|
||||
" • Ensure month and day are two digits (01-12, 01-31)\n" +
|
||||
" • Include timezone if needed (+HH:MM, -HH:MM, or Z for UTC)\n\n" +
|
||||
"💡 Tip: Use your local timezone or add 'Z' for UTC"
|
||||
);
|
||||
}
|
||||
|
||||
// Additional validation for datetime component values before parsing
|
||||
const dateParts = trimmedInput.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/
|
||||
);
|
||||
if (dateParts) {
|
||||
const [, year, month, day, hour, minute, second] = dateParts;
|
||||
const yearNum = parseInt(year);
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
const hourNum = parseInt(hour);
|
||||
const minuteNum = parseInt(minute);
|
||||
const secondNum = parseInt(second);
|
||||
|
||||
const valueIssues = [];
|
||||
if (yearNum < 1970 || yearNum > 3000)
|
||||
valueIssues.push(`Year ${year} seems unusual (expected 1970-3000)`);
|
||||
if (monthNum < 1 || monthNum > 12)
|
||||
valueIssues.push(`Month ${month} must be 01-12`);
|
||||
if (dayNum < 1 || dayNum > 31)
|
||||
valueIssues.push(`Day ${day} must be 01-31`);
|
||||
if (hourNum > 23)
|
||||
valueIssues.push(`Hour ${hour} must be 00-23 (24-hour format)`);
|
||||
if (minuteNum > 59) valueIssues.push(`Minute ${minute} must be 00-59`);
|
||||
if (secondNum > 59) valueIssues.push(`Second ${second} must be 00-59`);
|
||||
|
||||
if (valueIssues.length > 0) {
|
||||
throw new Error(
|
||||
`❌ Invalid datetime values: "${trimmedInput}"\n\n` +
|
||||
"🔍 Detected issues:\n" +
|
||||
valueIssues.map((issue) => ` • ${issue}`).join("\n") +
|
||||
"\n\n" +
|
||||
"💡 Common fixes:\n" +
|
||||
" • Check if the date exists (e.g., February 30th doesn't exist)\n" +
|
||||
" • Verify month is 01-12, not 0-11\n" +
|
||||
" • Ensure day is valid for the given month and year\n" +
|
||||
" • Use 24-hour format for time (00-23 for hours)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to parse the datetime with enhanced error handling
|
||||
let scheduledTime;
|
||||
try {
|
||||
scheduledTime = new Date(trimmedInput);
|
||||
} catch (parseError) {
|
||||
throw new Error(
|
||||
`❌ Failed to parse datetime: "${trimmedInput}"\n` +
|
||||
`🔧 Parse error: ${parseError.message}\n` +
|
||||
"💡 Please verify the datetime values are valid (e.g., month 1-12, day 1-31)"
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced validation for parsed date
|
||||
if (isNaN(scheduledTime.getTime())) {
|
||||
// Provide specific guidance based on common datetime issues
|
||||
const dateParts = trimmedInput.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/
|
||||
);
|
||||
let specificGuidance = "";
|
||||
|
||||
if (dateParts) {
|
||||
const [, year, month, day, hour, minute, second] = dateParts;
|
||||
const yearNum = parseInt(year);
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
const hourNum = parseInt(hour);
|
||||
const minuteNum = parseInt(minute);
|
||||
const secondNum = parseInt(second);
|
||||
|
||||
const issues = [];
|
||||
if (yearNum < 1970 || yearNum > 3000)
|
||||
issues.push(`Year ${year} seems unusual`);
|
||||
if (monthNum < 1 || monthNum > 12)
|
||||
issues.push(`Month ${month} must be 01-12`);
|
||||
if (dayNum < 1 || dayNum > 31) issues.push(`Day ${day} must be 01-31`);
|
||||
if (hourNum > 23) issues.push(`Hour ${hour} must be 00-23`);
|
||||
if (minuteNum > 59) issues.push(`Minute ${minute} must be 00-59`);
|
||||
if (secondNum > 59) issues.push(`Second ${second} must be 00-59`);
|
||||
|
||||
if (issues.length > 0) {
|
||||
specificGuidance =
|
||||
"\n🔍 Detected issues:\n" +
|
||||
issues.map((issue) => ` • ${issue}`).join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`❌ Invalid datetime values: "${trimmedInput}"\n` +
|
||||
"📊 The datetime format is correct, but the values are invalid.\n" +
|
||||
specificGuidance +
|
||||
"\n\n" +
|
||||
"💡 Common fixes:\n" +
|
||||
" • Check if the date exists (e.g., February 30th doesn't exist)\n" +
|
||||
" • Verify month is 01-12, not 0-11\n" +
|
||||
" • Ensure day is valid for the given month and year\n" +
|
||||
" • Use 24-hour format for time (00-23 for hours)"
|
||||
);
|
||||
}
|
||||
|
||||
const currentTime = new Date();
|
||||
|
||||
// Enhanced past datetime validation with helpful context
|
||||
if (scheduledTime <= currentTime) {
|
||||
const timeDifference = currentTime.getTime() - scheduledTime.getTime();
|
||||
const minutesDiff = Math.floor(timeDifference / (1000 * 60));
|
||||
const hoursDiff = Math.floor(timeDifference / (1000 * 60 * 60));
|
||||
const daysDiff = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
|
||||
let timeAgoText = "";
|
||||
if (daysDiff > 0) {
|
||||
timeAgoText = `${daysDiff} day${daysDiff > 1 ? "s" : ""} ago`;
|
||||
} else if (hoursDiff > 0) {
|
||||
timeAgoText = `${hoursDiff} hour${hoursDiff > 1 ? "s" : ""} ago`;
|
||||
} else if (minutesDiff > 0) {
|
||||
timeAgoText = `${minutesDiff} minute${minutesDiff > 1 ? "s" : ""} ago`;
|
||||
} else {
|
||||
timeAgoText = "just now";
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`❌ Scheduled time is in the past: "${trimmedInput}"\n\n` +
|
||||
`📅 Scheduled time: ${scheduledTime.toLocaleString()} (${timeAgoText})\n` +
|
||||
`🕐 Current time: ${currentTime.toLocaleString()}\n\n` +
|
||||
"💡 Solutions:\n" +
|
||||
" • Set a future datetime for the scheduled operation\n" +
|
||||
" • Check your system clock if the time seems incorrect\n" +
|
||||
" • Consider timezone differences if using a specific timezone\n\n" +
|
||||
"📝 Example for 1 hour from now:\n" +
|
||||
` SCHEDULED_EXECUTION_TIME='${new Date(
|
||||
currentTime.getTime() + 60 * 60 * 1000
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 19)}'`
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced distant future validation with detailed warning
|
||||
const sevenDaysFromNow = new Date(
|
||||
currentTime.getTime() + 7 * 24 * 60 * 60 * 1000
|
||||
);
|
||||
if (scheduledTime > sevenDaysFromNow) {
|
||||
const daysDiff = Math.ceil(
|
||||
(scheduledTime.getTime() - currentTime.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
// Display comprehensive warning with context
|
||||
console.warn(
|
||||
`\n⚠️ WARNING: Distant Future Scheduling Detected\n` +
|
||||
`📅 Scheduled time: ${scheduledTime.toLocaleString()}\n` +
|
||||
`📊 Days from now: ${daysDiff} days\n` +
|
||||
`🕐 Current time: ${currentTime.toLocaleString()}\n\n` +
|
||||
`🤔 This operation is scheduled more than 7 days in the future.\n` +
|
||||
`💭 Please verify this is intentional, as:\n` +
|
||||
` • Long-running processes may be interrupted by system restarts\n` +
|
||||
` • Product data or pricing strategies might change\n` +
|
||||
` • API tokens or store configuration could be updated\n\n` +
|
||||
`✅ If this is correct, the operation will proceed as scheduled.\n` +
|
||||
`❌ If this is a mistake, press Ctrl+C to cancel and update the datetime.\n`
|
||||
);
|
||||
|
||||
// Log the warning for audit purposes
|
||||
if (this.logger) {
|
||||
this.logger
|
||||
.warning(
|
||||
`Scheduled operation set for distant future: ${daysDiff} days from now (${scheduledTime.toISOString()})`
|
||||
)
|
||||
.catch((err) => {
|
||||
console.error("Failed to log distant future warning:", err.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return scheduledTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate milliseconds until scheduled execution
|
||||
* @param {Date} scheduledTime - Target execution time
|
||||
* @returns {number} Milliseconds until execution
|
||||
*/
|
||||
calculateDelay(scheduledTime) {
|
||||
const currentTime = new Date();
|
||||
const delay = scheduledTime.getTime() - currentTime.getTime();
|
||||
|
||||
// Ensure delay is not negative (shouldn't happen after validation, but safety check)
|
||||
return Math.max(0, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time remaining into human-readable string
|
||||
* @param {number} milliseconds - Time remaining in milliseconds
|
||||
* @returns {string} Formatted time string (e.g., "2h 30m 15s")
|
||||
*/
|
||||
formatTimeRemaining(milliseconds) {
|
||||
if (milliseconds <= 0) {
|
||||
return "0s";
|
||||
}
|
||||
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
const remainingHours = hours % 24;
|
||||
const remainingMinutes = minutes % 60;
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
const parts = [];
|
||||
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (remainingHours > 0) parts.push(`${remainingHours}h`);
|
||||
if (remainingMinutes > 0) parts.push(`${remainingMinutes}m`);
|
||||
if (remainingSeconds > 0 || parts.length === 0)
|
||||
parts.push(`${remainingSeconds}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Display scheduling confirmation and countdown
|
||||
* @param {Date} scheduledTime - Target execution time
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async displayScheduleInfo(scheduledTime) {
|
||||
const delay = this.calculateDelay(scheduledTime);
|
||||
const timeRemaining = this.formatTimeRemaining(delay);
|
||||
|
||||
// Display initial scheduling confirmation
|
||||
await this.logger.info(
|
||||
`Operation scheduled for: ${scheduledTime.toLocaleString()}`
|
||||
);
|
||||
await this.logger.info(`Time remaining: ${timeRemaining}`);
|
||||
await this.logger.info("Press Ctrl+C to cancel the scheduled operation");
|
||||
|
||||
// Start countdown display (update every 30 seconds for efficiency)
|
||||
this.startCountdownDisplay(scheduledTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start countdown display with periodic updates
|
||||
* @param {Date} scheduledTime - Target execution time
|
||||
*/
|
||||
startCountdownDisplay(scheduledTime) {
|
||||
// Clear any existing countdown
|
||||
this.stopCountdownDisplay();
|
||||
|
||||
// Update countdown every 30 seconds
|
||||
this.countdownInterval = setInterval(() => {
|
||||
if (this.cancelRequested) {
|
||||
this.stopCountdownDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = this.calculateDelay(scheduledTime);
|
||||
|
||||
if (delay <= 0) {
|
||||
this.stopCountdownDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeRemaining = this.formatTimeRemaining(delay);
|
||||
// Use console.log for countdown updates to avoid async issues in interval
|
||||
console.log(
|
||||
`[${new Date()
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(/\.\d{3}Z$/, "")}] INFO: Time remaining: ${timeRemaining}`
|
||||
);
|
||||
}, 30000); // Update every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop countdown display
|
||||
*/
|
||||
stopCountdownDisplay() {
|
||||
if (this.countdownInterval) {
|
||||
clearInterval(this.countdownInterval);
|
||||
this.countdownInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until scheduled time with cancellation support
|
||||
* @param {Date} scheduledTime - Target execution time
|
||||
* @param {Function} onCancel - Callback function to execute on cancellation
|
||||
* @returns {Promise<boolean>} True if execution should proceed, false if cancelled
|
||||
*/
|
||||
async waitUntilScheduledTime(scheduledTime, onCancel) {
|
||||
const delay = this.calculateDelay(scheduledTime);
|
||||
|
||||
if (delay <= 0) {
|
||||
return true; // Execute immediately
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
|
||||
// Set timeout for scheduled execution
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
|
||||
this.stopCountdownDisplay();
|
||||
this.currentTimeoutId = null;
|
||||
|
||||
if (!this.cancelRequested) {
|
||||
// Use console.log for immediate execution message
|
||||
console.log(
|
||||
`[${new Date()
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(
|
||||
/\.\d{3}Z$/,
|
||||
""
|
||||
)}] INFO: Scheduled time reached. Starting operation...`
|
||||
);
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
}, delay);
|
||||
|
||||
// Store timeout ID for cleanup
|
||||
this.currentTimeoutId = timeoutId;
|
||||
|
||||
// Set up cancellation check mechanism
|
||||
// The main signal handlers will call cleanup() which sets cancelRequested
|
||||
const checkCancellation = () => {
|
||||
if (resolved) return;
|
||||
|
||||
if (this.cancelRequested) {
|
||||
resolved = true;
|
||||
clearTimeout(timeoutId);
|
||||
this.stopCountdownDisplay();
|
||||
this.currentTimeoutId = null;
|
||||
|
||||
if (onCancel && typeof onCancel === "function") {
|
||||
onCancel();
|
||||
}
|
||||
|
||||
resolve(false);
|
||||
} else if (!resolved) {
|
||||
// Check again in 100ms
|
||||
setTimeout(checkCancellation, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Start cancellation checking
|
||||
setTimeout(checkCancellation, 100);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the scheduled operation
|
||||
* @param {Function} operationCallback - The operation to execute
|
||||
* @returns {Promise<number>} Exit code from the operation
|
||||
*/
|
||||
async executeScheduledOperation(operationCallback) {
|
||||
try {
|
||||
await this.logger.info("Executing scheduled operation...");
|
||||
const result = await operationCallback();
|
||||
await this.logger.info("Scheduled operation completed successfully");
|
||||
return result || 0;
|
||||
} catch (error) {
|
||||
await this.logger.error(`Scheduled operation failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources and request cancellation
|
||||
*/
|
||||
cleanup() {
|
||||
this.stopCountdownDisplay();
|
||||
this.cancelRequested = true;
|
||||
|
||||
// Clear any active timeout
|
||||
if (this.currentTimeoutId) {
|
||||
clearTimeout(this.currentTimeoutId);
|
||||
this.currentTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the service state (for testing or reuse)
|
||||
*/
|
||||
reset() {
|
||||
this.stopCountdownDisplay();
|
||||
this.cancelRequested = false;
|
||||
|
||||
if (this.currentTimeoutId) {
|
||||
clearTimeout(this.currentTimeoutId);
|
||||
this.currentTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate scheduling configuration and provide comprehensive error handling
|
||||
* @param {string} scheduledTimeString - Raw scheduled time string from environment
|
||||
* @returns {Object} Validation result with parsed time or error details
|
||||
*/
|
||||
validateSchedulingConfiguration(scheduledTimeString) {
|
||||
try {
|
||||
const scheduledTime = this.parseScheduledTime(scheduledTimeString);
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
scheduledTime: scheduledTime,
|
||||
originalInput: scheduledTimeString,
|
||||
validationError: null,
|
||||
warningMessage: null,
|
||||
};
|
||||
} catch (error) {
|
||||
// Categorize the error for better handling with more specific detection
|
||||
let errorCategory = "unknown";
|
||||
let helpfulSuggestions = [];
|
||||
|
||||
// Check for missing input first (highest priority)
|
||||
if (
|
||||
error.message.includes("required") ||
|
||||
error.message.includes("empty") ||
|
||||
error.message.includes("provided")
|
||||
) {
|
||||
errorCategory = "missing_input";
|
||||
helpfulSuggestions = [
|
||||
"Set the SCHEDULED_EXECUTION_TIME environment variable",
|
||||
"Ensure the value is not empty or whitespace only",
|
||||
"Use a valid ISO 8601 datetime string",
|
||||
];
|
||||
}
|
||||
// Check for past time (high priority)
|
||||
else if (error.message.includes("past")) {
|
||||
errorCategory = "past_time";
|
||||
helpfulSuggestions = [
|
||||
"Set a future datetime for the scheduled operation",
|
||||
"Check your system clock if the time seems incorrect",
|
||||
"Consider timezone differences when scheduling",
|
||||
];
|
||||
}
|
||||
// Check for format issues first (more specific patterns)
|
||||
else if (
|
||||
error.message.includes("❌ Invalid datetime format") ||
|
||||
error.message.includes("Invalid datetime format") ||
|
||||
error.message.includes("ISO 8601")
|
||||
) {
|
||||
errorCategory = "format";
|
||||
helpfulSuggestions = [
|
||||
"Use ISO 8601 format: YYYY-MM-DDTHH:MM:SS",
|
||||
"Add timezone if needed: +HH:MM, -HH:MM, or Z for UTC",
|
||||
"Separate date and time with 'T', not a space",
|
||||
];
|
||||
}
|
||||
// Check for invalid values (month, day, hour issues) - specific patterns
|
||||
else if (
|
||||
error.message.includes("❌ Invalid datetime values") ||
|
||||
error.message.includes("Invalid datetime values") ||
|
||||
error.message.includes("Month") ||
|
||||
error.message.includes("Day") ||
|
||||
error.message.includes("Hour") ||
|
||||
error.message.includes("must be")
|
||||
) {
|
||||
errorCategory = "invalid_values";
|
||||
helpfulSuggestions = [
|
||||
"Check if the date exists (e.g., February 30th doesn't exist)",
|
||||
"Verify month is 01-12, day is valid for the month",
|
||||
"Use 24-hour format for time (00-23 for hours)",
|
||||
];
|
||||
}
|
||||
// Check for parse errors (catch remaining format-related errors)
|
||||
else if (
|
||||
error.message.includes("parse") ||
|
||||
error.message.includes("format")
|
||||
) {
|
||||
errorCategory = "format";
|
||||
helpfulSuggestions = [
|
||||
"Use ISO 8601 format: YYYY-MM-DDTHH:MM:SS",
|
||||
"Add timezone if needed: +HH:MM, -HH:MM, or Z for UTC",
|
||||
"Separate date and time with 'T', not a space",
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
scheduledTime: null,
|
||||
originalInput: scheduledTimeString,
|
||||
validationError: error.message,
|
||||
errorCategory: errorCategory,
|
||||
suggestions: helpfulSuggestions,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display comprehensive error information for scheduling failures
|
||||
* @param {Object} validationResult - Result from validateSchedulingConfiguration
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async displaySchedulingError(validationResult) {
|
||||
if (validationResult.isValid) {
|
||||
return; // No error to display
|
||||
}
|
||||
|
||||
// Display error header
|
||||
console.error("\n" + "=".repeat(60));
|
||||
console.error("🚨 SCHEDULING CONFIGURATION ERROR");
|
||||
console.error("=".repeat(60));
|
||||
|
||||
// Display the main error message
|
||||
console.error("\n" + validationResult.validationError);
|
||||
|
||||
// Display additional context if available
|
||||
if (validationResult.originalInput) {
|
||||
console.error(`\n📝 Input received: "${validationResult.originalInput}"`);
|
||||
}
|
||||
|
||||
// Display category-specific help
|
||||
if (
|
||||
validationResult.suggestions &&
|
||||
validationResult.suggestions.length > 0
|
||||
) {
|
||||
console.error("\n💡 Suggestions to fix this issue:");
|
||||
validationResult.suggestions.forEach((suggestion) => {
|
||||
console.error(` • ${suggestion}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Display general help information
|
||||
console.error("\n📚 Additional Resources:");
|
||||
console.error(" • Check .env.example for configuration examples");
|
||||
console.error(" • Verify your system timezone settings");
|
||||
console.error(" • Use online ISO 8601 datetime generators if needed");
|
||||
|
||||
console.error("\n" + "=".repeat(60) + "\n");
|
||||
|
||||
// Log the error to the progress file if logger is available
|
||||
if (this.logger) {
|
||||
try {
|
||||
await this.logger.error(
|
||||
`Scheduling configuration error: ${validationResult.errorCategory} - ${validationResult.validationError}`
|
||||
);
|
||||
} catch (loggingError) {
|
||||
console.error("Failed to log scheduling error:", loggingError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scheduling errors with proper exit codes and user guidance
|
||||
* @param {string} scheduledTimeString - Raw scheduled time string from environment
|
||||
* @returns {Promise<number>} Exit code (0 for success, 1 for error)
|
||||
*/
|
||||
async handleSchedulingValidation(scheduledTimeString) {
|
||||
const validationResult =
|
||||
this.validateSchedulingConfiguration(scheduledTimeString);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
await this.displaySchedulingError(validationResult);
|
||||
return 1; // Error exit code
|
||||
}
|
||||
|
||||
// If validation passed, store the parsed time for later use
|
||||
this.validatedScheduledTime = validationResult.scheduledTime;
|
||||
return 0; // Success exit code
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ScheduleService;
|
||||
Reference in New Issue
Block a user