+
+ Last Sync
+ ${formatTimeAgo(ch.last_sync_at)}
-
-
${formatTimeAgo(ch.last_post_at)}
-
Last Post
+
+ Last Post
+ ${formatTimeAgo(ch.last_post_at)}
-
-
${ch.events_posted}
-
Events Posted
+
+ Events Posted
+ ${ch.events_posted}
+
+
+ Failures
+ ${ch.consecutive_failures}
- ${consecutiveFailuresHtml}
- ${ch.error ? `
` : ""}
+ ${ch.last_extended_sync_date ? `
Extended Sync${formatTimeAgo(ch.last_extended_sync_date)}
` : ""}
+ ${ch.error ? `
` : ""}
`;
diff --git a/src/api/index.ts b/src/api/index.ts
index 5bea6e9..4481120 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -78,6 +78,9 @@ function formatChannelStats(
status: s.status,
error: s.error,
consecutive_failures: s.consecutiveFailures,
+ last_extended_sync_date: s.lastExtendedSyncDate
+ ? new Date(s.lastExtendedSyncDate).toISOString()
+ : null,
};
}
return result;
@@ -90,6 +93,7 @@ interface ChannelStatsResponse {
status: "healthy" | "degraded" | "error";
error: string | null;
consecutive_failures: number;
+ last_extended_sync_date?: string | null;
}
/**
diff --git a/src/channels/calendar/index.ts b/src/channels/calendar/index.ts
index 847742e..5a2717a 100644
--- a/src/channels/calendar/index.ts
+++ b/src/channels/calendar/index.ts
@@ -182,6 +182,9 @@ export class CalendarChannel implements Channel {
status: this.state.status as ChannelStats["status"],
error: this.state.error,
consecutiveFailures: this.state.consecutiveFailures ?? 0,
+ lastExtendedSyncDate: this.state.lastExtendedSyncDate
+ ? new Date(this.state.lastExtendedSyncDate).getTime()
+ : null,
};
}
// Fallback to in-memory state for tests
@@ -192,6 +195,9 @@ export class CalendarChannel implements Channel {
status: this.memoryState.status as ChannelStats["status"],
error: this.memoryState.error,
consecutiveFailures: this.memoryState.consecutiveFailures,
+ lastExtendedSyncDate: this.memoryState.lastExtendedSyncDate
+ ? new Date(this.memoryState.lastExtendedSyncDate).getTime()
+ : null,
};
}
diff --git a/src/channels/index.ts b/src/channels/index.ts
index c2c70f7..5315162 100644
--- a/src/channels/index.ts
+++ b/src/channels/index.ts
@@ -25,6 +25,8 @@ export interface ChannelStats {
error: string | null;
/** Number of consecutive failures (timeouts or errors). Resets on success. */
consecutiveFailures: number;
+ /** When the last extended sync (30-day window) occurred (calendar channel only). */
+ lastExtendedSyncDate?: number | null;
}
// --- Channel interface ---
diff --git a/src/channels/telegram/index.ts b/src/channels/telegram/index.ts
index eaffaf4..56aee51 100644
--- a/src/channels/telegram/index.ts
+++ b/src/channels/telegram/index.ts
@@ -114,8 +114,12 @@ export class TelegramChannel implements Channel {
while (this.running) {
try {
await this.pollTelegramUpdates();
- // Reset backoff on success
+ // Reset backoff and error state on success
this.ingestionBackoffMs = 0;
+ const s = this.state ?? this.memoryState;
+ s.status = "healthy";
+ s.error = null;
+ s.consecutiveFailures = 0;
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
log(`ingestion error: ${errorMsg}`);
@@ -222,10 +226,12 @@ export class TelegramChannel implements Channel {
while (this.running) {
try {
const delivered = await this.deliverOutboxMessages();
- if (delivered > 0) {
- // Reset backoff on successful delivery
- this.deliveryBackoffMs = 0;
- } else {
+ // Reset backoff and error state on success (even if no messages)
+ this.deliveryBackoffMs = 0;
+ const s = this.state ?? this.memoryState;
+ s.status = "healthy";
+ s.error = null;
+ if (delivered === 0) {
// No messages, wait before next poll
await this.sleep(pollInterval);
}
diff --git a/test/telegram-channel.test.ts b/test/telegram-channel.test.ts
index a3f2fbf..ae95b0a 100644
--- a/test/telegram-channel.test.ts
+++ b/test/telegram-channel.test.ts
@@ -441,10 +441,10 @@ describe("TelegramChannel error handling", () => {
async () => {
errorCount++;
errorTimes.push(Date.now());
- if (errorCount <= 3) {
+ if (errorCount <= 2) {
throw new Error("Simulated API failure");
}
- // After 3 errors, succeed and stop
+ // After 2 errors, succeed and stop
await new Promise((r) => setTimeout(r, 100));
return [];
},
@@ -453,17 +453,17 @@ describe("TelegramChannel error handling", () => {
const channel = new TelegramChannel(cortex, DEFAULT_CONFIG);
await channel.start();
- // Wait for at least one backoff cycle (1s min) plus buffer
- await new Promise((r) => setTimeout(r, 1200));
+ // Wait for: error 1 (immediate) + 1s backoff + error 2 + 2s backoff + success + buffer
+ await new Promise((r) => setTimeout(r, 3500));
await channel.stop();
// Verify at least 2 errors occurred (first immediate, second after 1s backoff)
expect(errorCount).toBeGreaterThanOrEqual(2);
- // Verify stats show degraded status
+ // After recovery (successful poll returns []), status should be healthy
const stats = channel.getStats();
- expect(stats.consecutiveFailures).toBeGreaterThanOrEqual(1);
- expect(stats.status).toBe("degraded");
+ expect(stats.status).toBe("healthy");
+ expect(stats.consecutiveFailures).toBe(0);
// Verify backoff is applied (gap between errors should be ~1000ms)
if (errorTimes.length >= 2) {