207 lines
5.8 KiB
TypeScript
207 lines
5.8 KiB
TypeScript
import "server-only";
|
|
|
|
import { satisfiesCapability } from "../capability-context";
|
|
import {
|
|
fail,
|
|
type HousekeepingCapabilityContext,
|
|
type HousekeepingErrorCode,
|
|
type HousekeepingInboxSource,
|
|
type HousekeepingWorkItem,
|
|
ok,
|
|
} from "../contracts";
|
|
import { createCorrelationId } from "../correlation";
|
|
import { isSafeHousekeepingHref } from "../housekeeping-href";
|
|
import {
|
|
type HousekeepingProvider,
|
|
runProvider,
|
|
} from "../providers/run-provider";
|
|
import {
|
|
createHousekeepingRegistry,
|
|
type HousekeepingRegistry,
|
|
} from "../registry";
|
|
|
|
export const INBOX_POLICY = Object.freeze({
|
|
timeoutMs: 2_000,
|
|
combinedLimit: 200,
|
|
dedupe: "sourceId:itemId",
|
|
} as const);
|
|
|
|
export interface HousekeepingInboxResponse {
|
|
readonly items: readonly HousekeepingWorkItem[];
|
|
readonly errors: readonly {
|
|
readonly sourceId: string;
|
|
readonly code: HousekeepingErrorCode;
|
|
}[];
|
|
readonly correlationId: string;
|
|
}
|
|
|
|
export interface HousekeepingInboxServiceDependencies {
|
|
readonly registry: HousekeepingRegistry;
|
|
readonly createCorrelationId?: () => string;
|
|
readonly timeoutMs?: number;
|
|
}
|
|
|
|
const priorityRank = {
|
|
critical: 0,
|
|
high: 1,
|
|
normal: 2,
|
|
low: 3,
|
|
} as const;
|
|
|
|
const severities = new Set(["info", "warning", "critical"]);
|
|
const priorities = new Set(Object.keys(priorityRank));
|
|
const freshnessValues = new Set(["fresh", "stale"]);
|
|
|
|
function hasUsableCapability(
|
|
value: unknown,
|
|
): value is HousekeepingWorkItem["capability"] {
|
|
if (value === null || typeof value !== "object") return false;
|
|
const capability = value as { mode?: unknown; slugs?: unknown };
|
|
return (
|
|
(capability.mode === "any" || capability.mode === "all") &&
|
|
Array.isArray(capability.slugs) &&
|
|
capability.slugs.length > 0 &&
|
|
capability.slugs.every((slug) => typeof slug === "string")
|
|
);
|
|
}
|
|
|
|
function isValidWorkItem(
|
|
item: HousekeepingWorkItem,
|
|
source: HousekeepingInboxSource,
|
|
context: HousekeepingCapabilityContext,
|
|
): boolean {
|
|
return (
|
|
item !== null &&
|
|
typeof item === "object" &&
|
|
item.sourceId === source.id &&
|
|
item.domain === source.owner &&
|
|
typeof item.itemId === "string" &&
|
|
item.itemId.trim().length > 0 &&
|
|
typeof item.deduplicationKey === "string" &&
|
|
item.deduplicationKey.trim().length > 0 &&
|
|
severities.has(item.severity) &&
|
|
priorities.has(item.priority) &&
|
|
Number.isFinite(item.ageMs) &&
|
|
item.ageMs >= 0 &&
|
|
typeof item.state === "string" &&
|
|
item.state.trim().length > 0 &&
|
|
typeof item.occurredAt === "string" &&
|
|
Number.isFinite(Date.parse(item.occurredAt)) &&
|
|
typeof item.titleKey === "string" &&
|
|
item.titleKey.trim().length > 0 &&
|
|
isSafeHousekeepingHref(item.href) &&
|
|
freshnessValues.has(item.freshness) &&
|
|
Array.isArray(item.actions) &&
|
|
hasUsableCapability(item.capability) &&
|
|
satisfiesCapability(context, item.capability)
|
|
);
|
|
}
|
|
|
|
function isPermittedAction(
|
|
action: HousekeepingWorkItem["actions"][number],
|
|
context: HousekeepingCapabilityContext,
|
|
): boolean {
|
|
return (
|
|
action !== null &&
|
|
typeof action === "object" &&
|
|
typeof action.id === "string" &&
|
|
action.id.trim().length > 0 &&
|
|
typeof action.labelKey === "string" &&
|
|
action.labelKey.trim().length > 0 &&
|
|
typeof action.commandId === "string" &&
|
|
action.commandId.trim().length > 0 &&
|
|
hasUsableCapability(action.capability) &&
|
|
satisfiesCapability(context, action.capability)
|
|
);
|
|
}
|
|
|
|
function sourceProvider(
|
|
source: HousekeepingInboxSource,
|
|
): HousekeepingProvider<HousekeepingWorkItem> {
|
|
return {
|
|
id: source.id,
|
|
capability: source.capability,
|
|
async run(context, signal) {
|
|
const result = await source.getItems(context, signal);
|
|
if (!result.ok) return result;
|
|
if (result.data.availability !== "available") {
|
|
return fail(
|
|
"DEPENDENCY_UNAVAILABLE",
|
|
"errors.housekeeping.dependencyUnavailable",
|
|
result.correlationId,
|
|
);
|
|
}
|
|
return ok(result.data.items, result.correlationId);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createHousekeepingInboxService({
|
|
registry,
|
|
createCorrelationId: correlationIdFactory = createCorrelationId,
|
|
timeoutMs = INBOX_POLICY.timeoutMs,
|
|
}: HousekeepingInboxServiceDependencies) {
|
|
return async function loadInbox(
|
|
context: HousekeepingCapabilityContext,
|
|
): Promise<HousekeepingInboxResponse> {
|
|
const sources = registry.domains.flatMap((domain) => {
|
|
if (!satisfiesCapability(context, domain.capability)) return [];
|
|
return domain.inboxSources.filter((source) =>
|
|
satisfiesCapability(context, source.capability),
|
|
);
|
|
});
|
|
const outcomes = await Promise.all(
|
|
sources.map((source) =>
|
|
runProvider(sourceProvider(source), context, timeoutMs),
|
|
),
|
|
);
|
|
const sourceById = new Map(sources.map((source) => [source.id, source]));
|
|
const seen = new Set<string>();
|
|
const items: HousekeepingWorkItem[] = [];
|
|
|
|
for (const outcome of outcomes) {
|
|
const source = sourceById.get(outcome.providerId);
|
|
if (!source) continue;
|
|
for (const item of outcome.items) {
|
|
if (!isValidWorkItem(item, source, context)) continue;
|
|
const key = JSON.stringify([item.sourceId, item.itemId]);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
items.push({
|
|
...item,
|
|
actions: item.actions.filter((action) =>
|
|
isPermittedAction(action, context),
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
items.sort((left, right) => {
|
|
const priority =
|
|
priorityRank[left.priority] - priorityRank[right.priority];
|
|
if (priority !== 0) return priority;
|
|
return right.ageMs - left.ageMs;
|
|
});
|
|
|
|
return {
|
|
items: items.slice(0, INBOX_POLICY.combinedLimit),
|
|
errors: outcomes.flatMap((outcome) =>
|
|
outcome.error
|
|
? [{ sourceId: outcome.providerId, code: outcome.error }]
|
|
: [],
|
|
),
|
|
correlationId: correlationIdFactory(),
|
|
};
|
|
};
|
|
}
|
|
|
|
export async function loadHousekeepingInbox(
|
|
context: HousekeepingCapabilityContext,
|
|
): Promise<HousekeepingInboxResponse> {
|
|
const { HOUSEKEEPING_MANIFESTS } = await import("../../manifests");
|
|
const service = createHousekeepingInboxService({
|
|
registry: createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS),
|
|
});
|
|
return service(context);
|
|
}
|