55 * CHANGELOG:
66 * - 2025-03-11 03:16:11: (Schedulable Rules - Implementation) Initial implementation with parseTimeInterval, SchedulableRule interface, and SchedulableRulesManager class
77 * - 2025-03-11 03:16:58: (Schedulable Rules - Implementation) Fixed TypeScript errors related to imports and type annotations
8+ * - 2025-03-11 06:30:00: (Schedulable Rules - Persistence) Added persistence for rule execution times using VSCode global state
9+ * - 2025-03-11 06:36:30: (Schedulable Rules - Async) Fixed async handling for non-awaited markRuleAsExecuted calls
810 *
911 * PURPOSE:
1012 * This file handles loading and management of time-based rule files (.clinerules-5m, etc.)
1315 * METHODS:
1416 * - parseTimeInterval(): Parses time intervals from filenames
1517 * - SchedulableRulesManager.constructor(): Initializes a new rules manager
18+ * - SchedulableRulesManager.setContext(): Sets the VSCode extension context for state persistence
19+ * - SchedulableRulesManager.loadExecutionTimes(): Loads execution times from global state
20+ * - SchedulableRulesManager.saveExecutionTimes(): Saves execution times to global state
1621 * - SchedulableRulesManager.resetAllRules(): Resets all rule execution times
1722 * - SchedulableRulesManager.loadSchedulableRules(): Loads all schedulable rules from a directory
1823 * - SchedulableRulesManager.shouldExecuteRule(): Checks if a rule should be executed
2328
2429import * as path from "path"
2530import * as fs from "fs/promises"
31+ import * as vscode from "vscode"
2632import { logger } from "../../../utils/logging"
2733
2834/**
@@ -82,21 +88,129 @@ export function parseTimeInterval(timeStr: string): { interval: number; unit: st
8288 */
8389export class SchedulableRulesManager {
8490 private lastExecutionTimes : Map < string , number > = new Map ( )
91+ private context : vscode . ExtensionContext | null = null
92+ private outputChannel : vscode . OutputChannel | null = null
93+ private readonly STORAGE_KEY = "schedulableRules.lastExecutionTimes"
8594
8695 /**
8796 * Create a new SchedulableRulesManager
8897 */
8998 constructor ( ) {
90- this . resetAllRules ( )
9199 logger . info ( "SchedulableRulesManager initialized" )
92100 }
93101
102+ /**
103+ * Set the extension context for persistence
104+ * @param context - VSCode extension context
105+ */
106+ public setContext ( context : vscode . ExtensionContext ) : void {
107+ this . context = context
108+ this . loadExecutionTimes ( )
109+ logger . debug ( "SchedulableRulesManager context set" )
110+ this . log ( "debug" , "SchedulableRulesManager context set" )
111+ }
112+
113+ /**
114+ * Set the output channel for logging
115+ * @param outputChannel - VSCode output channel
116+ */
117+ public setOutputChannel ( outputChannel : vscode . OutputChannel ) : void {
118+ this . outputChannel = outputChannel
119+ this . log ( "info" , "SchedulableRulesManager output channel set" )
120+ }
121+
122+ /**
123+ * Log a message to both the outputChannel (if available) and the logger
124+ * @param level - Log level
125+ * @param message - Message to log
126+ */
127+ private log ( level : "debug" | "info" | "warn" | "error" , message : string ) : void {
128+ // Add timestamp for better time tracking
129+ const timestamp = new Date ( ) . toLocaleTimeString ( )
130+ const formattedMessage = `[${ timestamp } ] [SchedulableRules] ${ message } `
131+
132+ // Always show output channel when logging
133+ if ( this . outputChannel ) {
134+ this . outputChannel . appendLine ( formattedMessage )
135+
136+ // Show the output panel for important messages
137+ if ( level === "info" || level === "warn" || level === "error" ) {
138+ this . outputChannel . show ( true )
139+ }
140+ }
141+
142+ // Also log to the regular logger for completeness
143+ switch ( level ) {
144+ case "debug" :
145+ logger . debug ( message )
146+ break
147+ case "info" :
148+ logger . info ( message )
149+ break
150+ case "warn" :
151+ logger . warn ( message )
152+ break
153+ case "error" :
154+ logger . error ( message )
155+ break
156+ }
157+ }
158+
159+ /**
160+ * Load execution times from global state
161+ */
162+ private loadExecutionTimes ( ) : void {
163+ if ( ! this . context ) {
164+ this . log ( "warn" , "Cannot load execution times: context not set" )
165+ return
166+ }
167+
168+ try {
169+ const savedTimes = this . context . globalState . get < Record < string , number > > ( this . STORAGE_KEY )
170+ if ( savedTimes ) {
171+ this . lastExecutionTimes = new Map ( Object . entries ( savedTimes ) )
172+ this . log ( "debug" , `Loaded ${ this . lastExecutionTimes . size } rule execution times from storage` )
173+ }
174+ } catch ( err ) {
175+ this . log ( "error" , `Failed to load execution times: ${ err instanceof Error ? err . message : String ( err ) } ` )
176+ }
177+ }
178+
179+ /**
180+ * Save execution times to global state
181+ */
182+ private saveExecutionTimes ( ) : Promise < void > {
183+ if ( ! this . context ) {
184+ this . log ( "warn" , "Cannot save execution times: context not set" )
185+ return Promise . resolve ( )
186+ }
187+
188+ try {
189+ const timesObject = Object . fromEntries ( this . lastExecutionTimes . entries ( ) )
190+ // Convert Thenable to Promise and handle errors
191+ return Promise . resolve ( this . context . globalState . update ( this . STORAGE_KEY , timesObject ) )
192+ . then ( ( ) => {
193+ this . log ( "debug" , `Saved ${ this . lastExecutionTimes . size } rule execution times to storage` )
194+ } )
195+ . catch ( ( err : unknown ) => {
196+ this . log (
197+ "error" ,
198+ `Failed to save execution times: ${ err instanceof Error ? err . message : String ( err ) } ` ,
199+ )
200+ } )
201+ } catch ( err : unknown ) {
202+ this . log ( "error" , `Failed to save execution times: ${ err instanceof Error ? err . message : String ( err ) } ` )
203+ return Promise . resolve ( )
204+ }
205+ }
206+
94207 /**
95208 * Reset all rule execution times
96209 */
97- public resetAllRules ( ) : void {
210+ public resetAllRules ( ) : Promise < void > {
98211 this . lastExecutionTimes . clear ( )
99- logger . debug ( "All schedulable rules reset" )
212+ this . log ( "debug" , "All schedulable rules reset" )
213+ return this . saveExecutionTimes ( )
100214 }
101215
102216 /**
@@ -106,12 +220,12 @@ export class SchedulableRulesManager {
106220 */
107221 public async loadSchedulableRules ( cwd : string ) : Promise < SchedulableRule [ ] > {
108222 try {
109- logger . debug ( `Loading schedulable rules from: ${ cwd } ` )
223+ this . log ( "debug" , `Loading schedulable rules from: ${ cwd } ` )
110224 const files = await fs . readdir ( cwd )
111225
112226 // Filter for files matching the pattern .clinerules-\d+[smhd]
113227 const ruleFiles = files . filter ( ( file : string ) => / ^ \. c l i n e r u l e s - \d + [ s m h d ] $ / . test ( file ) )
114- logger . debug ( `Found ${ ruleFiles . length } schedulable rule files` )
228+ this . log ( "debug" , `Found ${ ruleFiles . length } schedulable rule files` )
115229
116230 const rules : SchedulableRule [ ] = [ ]
117231
@@ -134,48 +248,89 @@ export class SchedulableRulesManager {
134248 lastExecuted : this . lastExecutionTimes . get ( file ) || 0 ,
135249 } )
136250
137- logger . debug ( `Loaded rule file: ${ file } , interval: ${ display } ` )
251+ this . log ( "debug" , `Loaded rule file: ${ file } , interval: ${ display } ` )
138252 } catch ( err ) {
139- logger . error (
253+ this . log (
254+ "error" ,
140255 `Failed to parse schedulable rule file ${ file } : ${ err instanceof Error ? err . message : String ( err ) } ` ,
141256 )
142257 }
143258 }
144259
145260 return rules
146261 } catch ( err ) {
147- logger . error ( `Failed to load schedulable rules: ${ err instanceof Error ? err . message : String ( err ) } ` )
262+ this . log ( "error" , `Failed to load schedulable rules: ${ err instanceof Error ? err . message : String ( err ) } ` )
148263 return [ ]
149264 }
150265 }
151266
152267 /**
153268 * Check if a rule should be executed based on its interval
154269 * @param rule - The rule to check
155- * @returns True if the rule should be executed, false otherwise
156- */
270+ /**
271+ * Check if a rule should be executed based on its interval
272+ * @param rule - The rule to check
273+ * @returns True if the rule should be executed, false otherwise
274+ */
157275 public shouldExecuteRule ( rule : SchedulableRule ) : boolean {
158276 const now = Date . now ( )
159277 const lastExecution = this . lastExecutionTimes . get ( rule . fileName ) || 0
160- const shouldExecute = now - lastExecution >= rule . interval
278+ const timeElapsed = now - lastExecution
279+ const timeRemaining = Math . max ( 0 , rule . interval - timeElapsed )
280+ const shouldExecute = timeElapsed >= rule . interval
281+
282+ // Format time remaining in a human-readable format
283+ const formatTimeRemaining = ( ) : string => {
284+ if ( timeRemaining === 0 ) return "ready now"
285+
286+ const seconds = Math . floor ( timeRemaining / 1000 ) % 60
287+ const minutes = Math . floor ( timeRemaining / ( 1000 * 60 ) ) % 60
288+ const hours = Math . floor ( timeRemaining / ( 1000 * 60 * 60 ) )
289+
290+ const parts = [ ]
291+ if ( hours > 0 ) parts . push ( `${ hours } h` )
292+ if ( minutes > 0 ) parts . push ( `${ minutes } m` )
293+ if ( seconds > 0 || parts . length === 0 ) parts . push ( `${ seconds } s` )
161294
295+ return parts . join ( " " )
296+ }
297+
298+ // Always log at "info" level for better visibility in the output panel
162299 if ( shouldExecute ) {
163- logger . debug (
164- `Rule ${ rule . fileName } should be executed (last executed: ${ new Date ( lastExecution ) . toISOString ( ) } )` ,
300+ this . log (
301+ "info" ,
302+ `Rule ${ rule . fileName } is ready to execute (last executed: ${ lastExecution > 0 ? new Date ( lastExecution ) . toISOString ( ) : "never" } )` ,
303+ )
304+ } else {
305+ const nextRunTimeFormatted = new Date ( lastExecution + rule . interval ) . toLocaleTimeString ( )
306+ this . log (
307+ "info" , // Changed from debug to info for visibility
308+ `Rule ${ rule . fileName } will execute in ${ formatTimeRemaining ( ) } at ${ nextRunTimeFormatted } (last executed: ${ new Date ( lastExecution ) . toISOString ( ) } )` ,
165309 )
166310 }
167311
168312 return shouldExecute
169313 }
170-
171- /**
172- * Mark a rule as executed
314+ /*
315+ * Non-blocking method that saves the execution time to storage
316+ * without requiring the caller to await
317+ *
173318 * @param rule - The rule to mark as executed
174319 */
175320 public markRuleAsExecuted ( rule : SchedulableRule ) : void {
176321 const now = Date . now ( )
177322 this . lastExecutionTimes . set ( rule . fileName , now )
178- logger . info ( `Rule ${ rule . fileName } marked as executed at ${ new Date ( now ) . toISOString ( ) } ` )
323+ this . log ( "info" , `Rule ${ rule . fileName } marked as executed at ${ new Date ( now ) . toISOString ( ) } ` )
324+
325+ // Save to persistent storage without blocking
326+ // This ensures that even if the caller doesn't await the promise,
327+ // the execution times will still be saved to global state
328+ Promise . resolve ( this . saveExecutionTimes ( ) ) . catch ( ( err : unknown ) => {
329+ this . log (
330+ "error" ,
331+ `Failed to save execution times for ${ rule . fileName } : ${ err instanceof Error ? err . message : String ( err ) } ` ,
332+ )
333+ } )
179334 }
180335
181336 /**
@@ -186,7 +341,7 @@ export class SchedulableRulesManager {
186341 public async getExecutableRules ( cwd : string ) : Promise < SchedulableRule [ ] > {
187342 const rules = await this . loadSchedulableRules ( cwd )
188343 const executableRules = rules . filter ( ( rule ) => this . shouldExecuteRule ( rule ) )
189- logger . debug ( `Found ${ executableRules . length } executable rules out of ${ rules . length } total rules` )
344+ this . log ( "debug" , `Found ${ executableRules . length } executable rules out of ${ rules . length } total rules` )
190345 return executableRules
191346 }
192347
@@ -195,20 +350,55 @@ export class SchedulableRulesManager {
195350 * @param cwd - The current working directory
196351 * @returns Promise resolving to an array of rules with next execution time
197352 */
198- public async getAllRules ( cwd : string ) : Promise < Array < SchedulableRule & { nextExecution : number } > > {
353+ public async getAllRules (
354+ cwd : string ,
355+ ) : Promise < Array < SchedulableRule & { nextExecution : number ; nextExecutionTimestamp : number } > > {
199356 const rules = await this . loadSchedulableRules ( cwd )
200357 const now = Date . now ( )
201358
202359 return rules . map ( ( rule ) => {
203360 const lastExecution = this . lastExecutionTimes . get ( rule . fileName ) || 0
204- const nextExecution = lastExecution + rule . interval
205- const timeRemaining = Math . max ( 0 , nextExecution - now )
361+ const nextExecutionTimestamp = lastExecution + rule . interval
362+ const timeRemaining = Math . max ( 0 , nextExecutionTimestamp - now )
363+
364+ // Format time remaining in a human-readable format
365+ const timeUntilNextRun = this . formatTimeRemaining ( timeRemaining )
366+
367+ // Format next run time as a nice clock time
368+ const nextRunTime = new Date ( nextExecutionTimestamp ) . toLocaleTimeString ( undefined , {
369+ hour : "2-digit" ,
370+ minute : "2-digit" ,
371+ second : "2-digit" ,
372+ } )
206373
207374 return {
208375 ...rule ,
209376 lastExecuted : lastExecution ,
210377 nextExecution : timeRemaining ,
378+ nextExecutionTimestamp : nextExecutionTimestamp , // Add absolute timestamp to enable UI countdown
379+ timeUntilNextRun : timeUntilNextRun , // Human-readable time remaining
380+ nextRunTime : nextRunTime , // Clock time of next execution
211381 }
212382 } )
213383 }
384+
385+ /**
386+ * Format milliseconds into a human-readable time format
387+ * @param milliseconds - Time in milliseconds
388+ * @returns Human-readable time string
389+ */
390+ private formatTimeRemaining ( milliseconds : number ) : string {
391+ if ( milliseconds === 0 ) return "ready now"
392+
393+ const seconds = Math . floor ( milliseconds / 1000 ) % 60
394+ const minutes = Math . floor ( milliseconds / ( 1000 * 60 ) ) % 60
395+ const hours = Math . floor ( milliseconds / ( 1000 * 60 * 60 ) )
396+
397+ const parts = [ ]
398+ if ( hours > 0 ) parts . push ( `${ hours } h` )
399+ if ( minutes > 0 ) parts . push ( `${ minutes } m` )
400+ if ( seconds > 0 || parts . length === 0 ) parts . push ( `${ seconds } s` )
401+
402+ return parts . join ( " " )
403+ }
214404}
0 commit comments