@@ -9,6 +9,7 @@ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
99import { existsSync , mkdirSync , readFileSync , writeFileSync } from "node:fs" ;
1010import { homedir } from "node:os" ;
1111import { join } from "node:path" ;
12+ import { createFlairClient } from "../utils/flair-client.js" ;
1213
1314// ---------------------------------------------------------------------------
1415// Types
@@ -49,6 +50,10 @@ export interface PulseConfig {
4950 pollIntervalMs : number ;
5051 remindAfterMs : number ;
5152 ghAgent : string ;
53+ pruneAfterDays : number ;
54+ flairUrl ?: string ;
55+ flairAgentId ?: string ;
56+ flairAgentKey ?: string ;
5257}
5358
5459// Injectable runner type for testing
@@ -57,6 +62,14 @@ export type SyncRunner = (cmd: string, args: string[], opts?: { encoding?: Buffe
5762// Injectable mail sender for testing
5863export type MailSender = ( to : string , body : string , agentId : string ) => void ;
5964
65+ // Injectable Flair publisher for testing (null = disabled)
66+ export type FlairPublisher = (
67+ key : string ,
68+ from : PrState | null ,
69+ to : PrState ,
70+ instance : PrInstance ,
71+ ) => Promise < void > ;
72+
6073// ---------------------------------------------------------------------------
6174// Defaults
6275// ---------------------------------------------------------------------------
@@ -70,6 +83,7 @@ const DEFAULT_CONFIG: PulseConfig = {
7083 pollIntervalMs : 120000 ,
7184 remindAfterMs : 1800000 ,
7285 ghAgent : "flint" ,
86+ pruneAfterDays : 7 ,
7387} ;
7488
7589const PULSE_DIR = join ( homedir ( ) , ".tps" , "pulse" ) ;
@@ -108,6 +122,27 @@ export function saveState(state: PulseState): void {
108122 writeFileSync ( STATE_PATH , JSON . stringify ( state , null , 2 ) , "utf-8" ) ;
109123}
110124
125+ /**
126+ * Remove terminal instances (merged/closed) older than pruneAfterDays.
127+ * Returns the number of instances pruned.
128+ */
129+ export function pruneState ( state : PulseState , pruneAfterDays : number ) : number {
130+ const cutoff = Date . now ( ) - pruneAfterDays * 24 * 60 * 60 * 1000 ;
131+ const terminalStates = new Set < string > ( [ "merged" , "closed" ] ) ;
132+ let pruned = 0 ;
133+ for ( const key of Object . keys ( state . instances ) ) {
134+ const inst = state . instances [ key ] ;
135+ if (
136+ terminalStates . has ( inst . state ) &&
137+ new Date ( inst . lastTransitionAt ) . getTime ( ) < cutoff
138+ ) {
139+ delete state . instances [ key ] ;
140+ pruned ++ ;
141+ }
142+ }
143+ return pruned ;
144+ }
145+
111146// ---------------------------------------------------------------------------
112147// GitHub API
113148// ---------------------------------------------------------------------------
@@ -181,6 +216,7 @@ export function handleTransition(
181216 newState : PrState ,
182217 config : PulseConfig ,
183218 sender : MailSender ,
219+ publisher ?: FlairPublisher ,
184220) : void {
185221 const oldState = instance . state ;
186222 if ( oldState === newState ) return ;
@@ -193,6 +229,13 @@ export function handleTransition(
193229
194230 console . log ( `[pulse] ${ key } : ${ oldState } → ${ newState } ` ) ;
195231
232+ // Publish to Flair (non-blocking, best-effort)
233+ if ( publisher ) {
234+ publisher ( key , oldState , newState , instance ) . catch ( ( e : unknown ) => {
235+ console . warn ( `[pulse] Flair publish failed: ${ ( e as Error ) . message } ` ) ;
236+ } ) ;
237+ }
238+
196239 // Determine mail targets based on transition
197240 switch ( newState ) {
198241 case "opened" : {
@@ -305,6 +348,7 @@ export function pollOnce(
305348 state : PulseState ,
306349 runner : SyncRunner = spawnSync as unknown as SyncRunner ,
307350 sender : MailSender = defaultMailSender ,
351+ publisher ?: FlairPublisher ,
308352) : void {
309353 const now = new Date ( ) . toISOString ( ) ;
310354
@@ -363,12 +407,12 @@ export function pollOnce(
363407
364408 // If PR already has reviews, advance state
365409 if ( computed !== "opened" ) {
366- handleTransition ( key , instance , computed , config , sender ) ;
410+ handleTransition ( key , instance , computed , config , sender , publisher ) ;
367411 }
368412 } else {
369413 // Existing PR — check for state change
370414 existing . title = pr . title ;
371- handleTransition ( key , existing , computed , config , sender ) ;
415+ handleTransition ( key , existing , computed , config , sender , publisher ) ;
372416 }
373417 }
374418
@@ -380,7 +424,7 @@ export function pollOnce(
380424 try {
381425 const prData = ghApi ( `repos/${ repo } /pulls/${ inst . prNumber } ` , config . ghAgent , runner ) as GhPr ;
382426 if ( prData . merged_at ) {
383- handleTransition ( key , inst , "merged" , config , sender ) ;
427+ handleTransition ( key , inst , "merged" , config , sender , publisher ) ;
384428 }
385429 } catch ( e : unknown ) {
386430 console . warn ( `[pulse] Failed to check closed PR ${ key } : ${ ( e as Error ) . message } ` ) ;
@@ -398,23 +442,55 @@ export function pollOnce(
398442// Poll Loop (foreground)
399443// ---------------------------------------------------------------------------
400444
445+ export function makeFlairPublisher ( config : PulseConfig ) : FlairPublisher | undefined {
446+ if ( ! config . flairUrl || ! config . flairAgentId || ! config . flairAgentKey ) return undefined ;
447+ try {
448+ const client = createFlairClient ( config . flairAgentId , config . flairUrl , config . flairAgentKey ) ;
449+ return async ( key : string , from : PrState | null , to : PrState , instance : PrInstance ) => {
450+ // Publish OrgEvent for state transition
451+ await client . publishEvent ( {
452+ kind : `pr.${ to } ` ,
453+ scope : key ,
454+ summary : `PR #${ instance . prNumber } (${ instance . repo } ): ${ from ?? "new" } → ${ to } ` ,
455+ detail : instance . title ,
456+ refId : key ,
457+ } ) ;
458+ // Store as persistent memory (stable id per PR key — same PUT overwrites)
459+ const memId = `${ config . flairAgentId } -pulse-${ key . replace ( / [ ^ a - z 0 - 9 ] / gi, "-" ) } ` ;
460+ const content = `PR ${ key } state: ${ to } . Title: "${ instance . title } ". Transition: ${ from ?? "new" } → ${ to } at ${ instance . lastTransitionAt } .` ;
461+ await client . writeMemory ( memId , content , {
462+ durability : "persistent" ,
463+ type : "fact" ,
464+ tags : [ "pulse" , "pr-lifecycle" , to ] ,
465+ } ) ;
466+ } ;
467+ } catch ( e : unknown ) {
468+ console . warn ( `[pulse] Failed to create Flair publisher: ${ ( e as Error ) . message } ` ) ;
469+ return undefined ;
470+ }
471+ }
472+
401473export async function startPollLoop (
402474 config : PulseConfig ,
403475 state : PulseState ,
404- opts : { dryRun ?: boolean ; runner ?: SyncRunner ; sender ?: MailSender } = { } ,
476+ opts : { dryRun ?: boolean ; runner ?: SyncRunner ; sender ?: MailSender ; publisher ?: FlairPublisher } = { } ,
405477) : Promise < void > {
406478 const runner = opts . runner ?? ( spawnSync as unknown as SyncRunner ) ;
407479 const sender = opts . dryRun
408480 ? ( to : string , body : string , _agentId : string ) => {
409481 console . log ( `[pulse/dry-run] would mail ${ to } : ${ body . slice ( 0 , 80 ) } …` ) ;
410482 }
411483 : ( opts . sender ?? defaultMailSender ) ;
484+ const publisher = opts . dryRun ? undefined : ( opts . publisher ?? makeFlairPublisher ( config ) ) ;
412485
413486 console . log ( `[pulse] Starting poll loop (interval=${ config . pollIntervalMs } ms, repos=${ config . repos . join ( ", " ) } )` ) ;
414487
415488 const poll = ( ) => {
416489 try {
417- pollOnce ( config , state , runner , sender ) ;
490+ // Prune stale terminal instances before each poll
491+ const pruned = pruneState ( state , config . pruneAfterDays ) ;
492+ if ( pruned > 0 ) console . log ( `[pulse] Pruned ${ pruned } completed instance(s) older than ${ config . pruneAfterDays } days` ) ;
493+ pollOnce ( config , state , runner , sender , publisher ) ;
418494 saveState ( state ) ;
419495 } catch ( e : unknown ) {
420496 console . error ( `[pulse] Poll error: ${ ( e as Error ) . message } ` ) ;
0 commit comments