Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions dashboard/src/pages/stats.astro
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ const tabs = [
events_posted: number;
status: "healthy" | "degraded" | "error";
error: string | null;
consecutive_failures: number;
}

interface ServiceStats {
Expand Down Expand Up @@ -565,6 +566,13 @@ const tabs = [
? "bg-amber-nerv"
: "bg-red-nerv";

const consecutiveFailuresHtml = ch.consecutive_failures > 0
? `<div>
<div class="font-mono text-lg text-red-nerv">${ch.consecutive_failures}</div>
<div class="font-mono text-xs text-red-nerv">Consecutive Failures</div>
</div>`
: "";

return `
<div class="p-3 bg-nerv-bg/50 rounded border border-nerv-border">
<div class="flex items-center justify-between mb-3">
Expand All @@ -587,6 +595,7 @@ const tabs = [
<div class="font-mono text-lg text-amber-nerv">${ch.events_posted}</div>
<div class="font-mono text-xs text-text-muted">Events Posted</div>
</div>
${consecutiveFailuresHtml}
${ch.error ? `<div class="col-span-2"><div class="font-mono text-xs text-red-nerv truncate" title="${ch.error}">${ch.error}</div></div>` : ""}
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function formatChannelStats(
events_posted: s.eventsPosted,
status: s.status,
error: s.error,
consecutive_failures: s.consecutiveFailures,
};
}
return result;
Expand All @@ -88,6 +89,7 @@ interface ChannelStatsResponse {
events_posted: number;
status: "healthy" | "degraded" | "error";
error: string | null;
consecutive_failures: number;
}

/**
Expand Down
57 changes: 41 additions & 16 deletions src/channels/calendar/apple-calendar.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
/**
* Read Apple Calendar events via osascript (JXA).
*
* macOS only. Returns empty array on failure (non-macOS, no Calendar access, etc.).
* macOS only. Returns Result with error type indicating failure reason.
*/

import { createLogger } from "@shetty4l/core/log";
import { err, ok, type Result } from "@shetty4l/core/result";

const log = createLogger("wilson:calendar");

Expand All @@ -20,6 +21,18 @@ export interface CalendarEvent {
calendarName: string;
}

/** Error types for calendar read failures */
export type CalendarReadError =
| { type: "timeout" }
| { type: "osascript_failed"; exitCode: number; stderr: string }
| { type: "parse_error"; message: string }
| { type: "exception"; message: string };

export type ReadAppleCalendarResult = Result<
CalendarEvent[],
CalendarReadError
>;

// --- JXA script ---

function buildJxaScript(
Expand Down Expand Up @@ -103,7 +116,7 @@ const defaultSpawn: SpawnFn = async (cmd) => {
stderr: string;
}>((resolve) => {
timeoutId = setTimeout(() => {
proc.kill();
proc.kill(9); // SIGKILL for hard termination
log("osascript timed out after 60s");
resolve({ exitCode: -1, stdout: "", stderr: "osascript timed out" });
}, SPAWN_TIMEOUT_MS);
Expand All @@ -128,15 +141,15 @@ export interface ReadAppleCalendarOptions {
* Read Apple Calendar events for the next N days.
*
* Uses osascript with JXA (JavaScript for Automation) to query Calendar.app.
* Returns empty array on any failure — never throws.
* Returns Result type — Ok with events or Err with typed error.
*
* @param options.lookAheadDays - Number of days to look ahead
* @param options.includeCalendars - Optional list of calendar names to include (case-insensitive)
* @param options.spawn - Optional spawn function for dependency injection
*/
export async function readAppleCalendar(
options: ReadAppleCalendarOptions,
): Promise<CalendarEvent[]> {
): Promise<ReadAppleCalendarResult> {
const { lookAheadDays, includeCalendars, spawn = defaultSpawn } = options;
try {
const script = buildJxaScript(lookAheadDays, includeCalendars);
Expand All @@ -149,26 +162,38 @@ export async function readAppleCalendar(
]);

if (exitCode !== 0) {
const timedOut = stderr.includes("timed out");
log(`osascript failed (exit ${exitCode}): ${stderr.trim()}`);
return [];
if (timedOut) {
return err({ type: "timeout" });
}
return err({ type: "osascript_failed", exitCode, stderr: stderr.trim() });
}

const trimmed = stdout.trim();
if (!trimmed) {
return [];
return ok([]);
}

const events = JSON.parse(trimmed) as CalendarEvent[];
if (!Array.isArray(events)) {
log("osascript returned non-array result");
return [];
try {
const events = JSON.parse(trimmed) as CalendarEvent[];
if (!Array.isArray(events)) {
log("osascript returned non-array result");
return err({
type: "parse_error",
message: "osascript returned non-array result",
});
}
return ok(events);
} catch (parseErr) {
const message =
parseErr instanceof Error ? parseErr.message : String(parseErr);
log(`failed to parse calendar JSON: ${message}`);
return err({ type: "parse_error", message });
}

return events;
} catch (e) {
log(
`failed to read Apple Calendar: ${e instanceof Error ? e.message : String(e)}`,
);
return [];
const message = e instanceof Error ? e.message : String(e);
log(`failed to read Apple Calendar: ${message}`);
return err({ type: "exception", message });
}
}
Loading