@@ -22,6 +22,8 @@ import type {
2222 CreateProjectRequest ,
2323 ExchangePollRequest ,
2424 ExchangeSubscribeRequest ,
25+ FederationExchangeEvent ,
26+ FederationExchangeStatus ,
2527 FederationInboxResult ,
2628 FederationIssueRecord ,
2729 FollowStatus ,
@@ -47,6 +49,7 @@ type StoredFederationState = {
4749 readonly issues : ReadonlyArray < FederationIssueRecord >
4850 readonly follows : ReadonlyArray < FollowSubscription >
4951 readonly processedOutboxItems : ReadonlyArray < string >
52+ readonly exchangeEvents ?: ReadonlyArray < FederationExchangeEvent > | undefined
5053 readonly localActorKeys ?: LocalActorKeys | undefined
5154}
5255
@@ -97,12 +100,14 @@ const jsonLdContentType = "application/ld+json; profile=\"https://www.w3.org/ns/
97100const activityAcceptHeader = `${ jsonLdContentType } , ${ activityJsonContentType } , application/json`
98101const defaultExchangeQueue = "code"
99102const stateVersion = 1 as const
103+ const exchangeEventLimit = 100
100104
101105const issueStore : Map < string , FederationIssueRecord > = new Map ( )
102106const followStore : Map < string , FollowSubscription > = new Map ( )
103107const followByActivityId : Map < string , string > = new Map ( )
104108const followByActorObject : Map < string , string > = new Map ( )
105109const processedOutboxItems : Set < string > = new Set ( )
110+ let exchangeEvents : ReadonlyArray < FederationExchangeEvent > = [ ]
106111let localActorKeys : LocalActorKeys | null = null
107112let stateLoaded = false
108113
@@ -120,6 +125,33 @@ const asNonEmptyString = (value: unknown): string | null =>
120125const readOptionalString = ( record : JsonRecord , key : string ) : string | undefined =>
121126 asNonEmptyString ( record [ key ] ) ?? undefined
122127
128+ type FederationExchangeEventDraft = Omit < FederationExchangeEvent , "id" | "occurredAt" > & {
129+ readonly occurredAt ?: string | undefined
130+ }
131+
132+ const exchangeSubscriptionTarget = ( subscription : FollowSubscription ) : string =>
133+ subscription . subscriptionName ?? subscription . remoteActor ?? subscription . object
134+
135+ const findExchangeSubscriptionByActor = ( actor : string | undefined ) : FollowSubscription | undefined =>
136+ actor === undefined
137+ ? undefined
138+ : [ ...followStore . values ( ) ] . find ( ( subscription ) =>
139+ subscription . remoteOutbox !== undefined &&
140+ ( subscription . remoteActor === actor || subscription . object === actor )
141+ )
142+
143+ const recordExchangeEvent = ( event : FederationExchangeEventDraft ) : FederationExchangeEvent => {
144+ const { occurredAt, ...details } = event
145+ const stored : FederationExchangeEvent = {
146+ id : randomUUID ( ) ,
147+ occurredAt : occurredAt ?? nowIso ( ) ,
148+ ...details
149+ }
150+ exchangeEvents = [ ...exchangeEvents , stored ] . slice ( - exchangeEventLimit )
151+ persistFederationStateBestEffort ( )
152+ return stored
153+ }
154+
123155const readRequiredString = (
124156 record : JsonRecord ,
125157 key : string ,
@@ -302,6 +334,7 @@ const serializeState = (): StoredFederationState => ({
302334 issues : [ ...issueStore . values ( ) ] ,
303335 follows : [ ...followStore . values ( ) ] ,
304336 processedOutboxItems : [ ...processedOutboxItems ] ,
337+ exchangeEvents,
305338 ...( localActorKeys === null ? { } : { localActorKeys } )
306339} )
307340
@@ -377,6 +410,7 @@ const hydrateState = (state: StoredFederationState): void => {
377410 followByActivityId . clear ( )
378411 followByActorObject . clear ( )
379412 processedOutboxItems . clear ( )
413+ exchangeEvents = [ ]
380414
381415 for ( const issue of state . issues ?? [ ] ) {
382416 issueStore . set ( issue . issueId , issue )
@@ -387,6 +421,7 @@ const hydrateState = (state: StoredFederationState): void => {
387421 for ( const item of state . processedOutboxItems ?? [ ] ) {
388422 processedOutboxItems . add ( item )
389423 }
424+ exchangeEvents = [ ...( state . exchangeEvents ?? [ ] ) ] . slice ( - exchangeEventLimit )
390425 localActorKeys = state . localActorKeys ?? null
391426}
392427
@@ -740,6 +775,22 @@ const ingestCreateTicket = (
740775 return issue
741776 } )
742777
778+ const recordIssueReceivedEvent = (
779+ issue : FederationIssueRecord ,
780+ options : IngestOptions
781+ ) : void => {
782+ const remoteActor = issue . actor ?? issue . ticket . attributedTo
783+ const subscription = options . subscription ?? findExchangeSubscriptionByActor ( remoteActor )
784+ recordExchangeEvent ( {
785+ kind : "inbox.issue.received" ,
786+ subscriptionId : subscription ?. id ,
787+ target : subscription === undefined ? undefined : exchangeSubscriptionTarget ( subscription ) ,
788+ queue : subscription ?. queue ,
789+ issueId : issue . issueId ,
790+ remoteActor : subscription ?. remoteActor ?? remoteActor
791+ } )
792+ }
793+
743794// CHANGE: support ForgeFed issue inputs and ActivityPub inbox transitions in API mode.
744795// WHY: issue #233 requires ForgeFed/ActivityPub subscription and task intake.
745796// QUOTE(ТЗ): "Осталось forgefed допподержать" + "Законнектишь к exchange"
@@ -768,23 +819,34 @@ export const ingestFederationInbox = (
768819
769820 if ( hasType ( record , "Offer" ) ) {
770821 const issue = yield * _ ( ingestOfferTicket ( record ) )
822+ recordIssueReceivedEvent ( issue , options )
771823 return { kind : "issue.offer" , issue }
772824 }
773825
774826 if ( hasType ( record , "Create" ) ) {
775827 const issue = yield * _ ( ingestCreateTicket ( record , options ) )
828+ recordIssueReceivedEvent ( issue , options )
776829 return { kind : "issue.create" , issue }
777830 }
778831
779832 if ( hasType ( record , "Ticket" ) ) {
780833 const issue = yield * _ ( ingestDirectTicket ( record ) )
834+ recordIssueReceivedEvent ( issue , options )
781835 return { kind : "issue.ticket" , issue }
782836 }
783837
784838 if ( hasType ( record , "Accept" ) || hasType ( record , "Reject" ) ) {
785839 const subscription = yield * _ ( resolveFollowFromInbox ( record ) )
786840 const status : FollowStatus = hasType ( record , "Accept" ) ? "accepted" : "rejected"
787841 const updated = updateFollowStatus ( subscription , status )
842+ recordExchangeEvent ( {
843+ kind : status === "accepted" ? "inbox.follow.accept" : "inbox.follow.reject" ,
844+ subscriptionId : updated . id ,
845+ target : exchangeSubscriptionTarget ( updated ) ,
846+ queue : updated . queue ,
847+ status : updated . status ,
848+ remoteActor : updated . remoteActor
849+ } )
788850 return status === "accepted"
789851 ? { kind : "follow.accept" , subscription : updated }
790852 : { kind : "follow.reject" , subscription : updated }
@@ -1242,6 +1304,14 @@ export const ensureExchangeSubscription = (
12421304 if ( inbox !== undefined ) {
12431305 yield * _ ( sendJsonLd ( context , inbox , activity ) . pipe ( Effect . ignore ) )
12441306 }
1307+ recordExchangeEvent ( {
1308+ kind : "follow.sent" ,
1309+ subscriptionId : subscription . id ,
1310+ target : exchangeSubscriptionTarget ( subscription ) ,
1311+ queue : subscription . queue ,
1312+ status : subscription . status ,
1313+ remoteActor : subscription . remoteActor
1314+ } )
12451315
12461316 return { subscription, activity }
12471317 } )
@@ -1255,12 +1325,65 @@ export const listFollowSubscriptions = (): ReadonlyArray<FollowSubscription> =>
12551325export const listExchangeSubscriptions = ( ) : ReadonlyArray < FollowSubscription > =>
12561326 listFollowSubscriptions ( ) . filter ( ( subscription ) => subscription . remoteOutbox !== undefined )
12571327
1328+ const latestIso = ( values : ReadonlyArray < string | undefined > ) : string | undefined =>
1329+ values
1330+ . filter ( ( value ) : value is string => value !== undefined && value . length > 0 )
1331+ . sort ( )
1332+ . at ( - 1 )
1333+
1334+ export const makeFederationExchangeStatus = (
1335+ context : FederationContext
1336+ ) : FederationExchangeStatus => {
1337+ const subscriptions = listExchangeSubscriptions ( )
1338+ const recentEvents = [ ...exchangeEvents ] . sort ( ( left , right ) => right . occurredAt . localeCompare ( left . occurredAt ) )
1339+ const accepted = subscriptions . filter ( ( subscription ) => subscription . status === "accepted" ) . length
1340+ const pending = subscriptions . filter ( ( subscription ) => subscription . status === "pending" ) . length
1341+ const rejected = subscriptions . filter ( ( subscription ) => subscription . status === "rejected" ) . length
1342+ const inboxEventTimes = exchangeEvents
1343+ . filter ( ( event ) => event . kind === "inbox.follow.accept" || event . kind === "inbox.follow.reject" || event . kind === "inbox.issue.received" )
1344+ . map ( ( event ) => event . occurredAt )
1345+ const acceptedTransitionTimes = subscriptions
1346+ . filter ( ( subscription ) => subscription . status === "accepted" || subscription . status === "rejected" )
1347+ . map ( ( subscription ) => subscription . updatedAt )
1348+
1349+ return {
1350+ publicActor : context . actorId ,
1351+ summary : {
1352+ subscriptions : subscriptions . length ,
1353+ accepted,
1354+ pending,
1355+ rejected,
1356+ issues : issueStore . size ,
1357+ processedOutboxItems : processedOutboxItems . size ,
1358+ lastInboxAt : latestIso ( [ ...inboxEventTimes , ...acceptedTransitionTimes ] ) ,
1359+ lastPollAt : latestIso (
1360+ exchangeEvents
1361+ . filter ( ( event ) => event . kind === "poll.completed" )
1362+ . map ( ( event ) => event . occurredAt )
1363+ )
1364+ } ,
1365+ subscriptions : subscriptions . map ( ( subscription ) => ( {
1366+ id : subscription . id ,
1367+ target : exchangeSubscriptionTarget ( subscription ) ,
1368+ queue : subscription . queue ,
1369+ status : subscription . status ,
1370+ remoteActor : subscription . remoteActor ,
1371+ remoteInbox : subscription . remoteInbox ,
1372+ remoteOutbox : subscription . remoteOutbox ,
1373+ createdAt : subscription . createdAt ,
1374+ updatedAt : subscription . updatedAt
1375+ } ) ) ,
1376+ recentEvents
1377+ }
1378+ }
1379+
12581380export const clearFederationState = ( ) : void => {
12591381 issueStore . clear ( )
12601382 followStore . clear ( )
12611383 followByActivityId . clear ( )
12621384 followByActorObject . clear ( )
12631385 processedOutboxItems . clear ( )
1386+ exchangeEvents = [ ]
12641387 localActorKeys = null
12651388 stateLoaded = true
12661389}
@@ -1377,8 +1500,19 @@ export const pollExchangeOutboxes = (
13771500 }
13781501 }
13791502
1503+ const polledAt = nowIso ( )
1504+ recordExchangeEvent ( {
1505+ kind : "poll.completed" ,
1506+ occurredAt : polledAt ,
1507+ target : request . target ,
1508+ totalItems,
1509+ newItems,
1510+ processedItems,
1511+ failedItems
1512+ } )
1513+
13801514 return {
1381- polledAt : nowIso ( ) ,
1515+ polledAt,
13821516 subscriptions : subscriptions . length ,
13831517 totalItems,
13841518 newItems,
0 commit comments