366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
import type {
|
|
GlobalTask,
|
|
TeamChangeEvent,
|
|
TeamProvisioningProgress,
|
|
TeamSummary,
|
|
} from '@shared/types';
|
|
|
|
export type LaunchIoGovernorOperationKey = 'teams:list' | 'teams:getAllTasks';
|
|
|
|
type GovernedPayload = TeamSummary[] | GlobalTask[];
|
|
type CloneFn<T> = (value: T) => T;
|
|
|
|
interface LaunchIoGovernorLogger {
|
|
debug?: (message: string) => void;
|
|
warn?: (message: string) => void;
|
|
}
|
|
|
|
export interface LaunchIoGovernorOptions {
|
|
quietWindowMs?: number;
|
|
maxStaleAgeMs?: number;
|
|
stuckLaunchPressureMs?: number;
|
|
warningCooldownMs?: number;
|
|
now?: () => number;
|
|
logger?: LaunchIoGovernorLogger;
|
|
}
|
|
|
|
interface ActiveLaunch {
|
|
teamName: string;
|
|
source: string;
|
|
startedAt: number;
|
|
updatedAt: number;
|
|
}
|
|
|
|
interface CachedValue<T> {
|
|
value: T;
|
|
cachedAt: number;
|
|
}
|
|
|
|
interface OperationState<T> {
|
|
key: LaunchIoGovernorOperationKey;
|
|
cache: CachedValue<T> | null;
|
|
dirty: boolean;
|
|
generation: number;
|
|
inFlight: Promise<T> | null;
|
|
loadFresh: (() => Promise<T>) | null;
|
|
clone: CloneFn<T> | null;
|
|
scheduledRefresh: ReturnType<typeof setTimeout> | null;
|
|
lastWarningAt: number;
|
|
}
|
|
|
|
export const DEFAULT_LAUNCH_IO_QUIET_WINDOW_MS = 3_000;
|
|
export const DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS = 15_000;
|
|
export const DEFAULT_LAUNCH_IO_STUCK_PRESSURE_MS = 10 * 60_000;
|
|
const DEFAULT_WARNING_COOLDOWN_MS = 10_000;
|
|
|
|
const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'cancelled', 'disconnected']);
|
|
|
|
export function cloneLaunchIoGovernorPayload<T extends GovernedPayload>(value: T): T {
|
|
if (typeof structuredClone === 'function') {
|
|
return structuredClone(value);
|
|
}
|
|
return JSON.parse(JSON.stringify(value)) as T;
|
|
}
|
|
|
|
export class LaunchIoGovernor {
|
|
private readonly quietWindowMs: number;
|
|
private readonly maxStaleAgeMs: number;
|
|
private readonly stuckLaunchPressureMs: number;
|
|
private readonly warningCooldownMs: number;
|
|
private readonly now: () => number;
|
|
private readonly logger: LaunchIoGovernorLogger;
|
|
private readonly activeLaunches = new Map<string, ActiveLaunch>();
|
|
private readonly operations = new Map<
|
|
LaunchIoGovernorOperationKey,
|
|
OperationState<GovernedPayload>
|
|
>();
|
|
private quietUntil = 0;
|
|
|
|
constructor(options: LaunchIoGovernorOptions = {}) {
|
|
this.quietWindowMs = options.quietWindowMs ?? DEFAULT_LAUNCH_IO_QUIET_WINDOW_MS;
|
|
this.maxStaleAgeMs = options.maxStaleAgeMs ?? DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS;
|
|
this.stuckLaunchPressureMs =
|
|
options.stuckLaunchPressureMs ?? DEFAULT_LAUNCH_IO_STUCK_PRESSURE_MS;
|
|
this.warningCooldownMs = options.warningCooldownMs ?? DEFAULT_WARNING_COOLDOWN_MS;
|
|
this.now = options.now ?? (() => Date.now());
|
|
this.logger = options.logger ?? {};
|
|
this.operations.set('teams:list', this.createOperationState('teams:list'));
|
|
this.operations.set('teams:getAllTasks', this.createOperationState('teams:getAllTasks'));
|
|
}
|
|
|
|
noteLaunchIntent(teamName: string, source = 'unknown'): void {
|
|
const normalized = this.normalizeTeamName(teamName);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
const now = this.now();
|
|
this.pruneStuckLaunches(now);
|
|
this.activeLaunches.set(normalized, {
|
|
teamName: normalized,
|
|
source,
|
|
startedAt: now,
|
|
updatedAt: now,
|
|
});
|
|
this.markDirty('teams:list');
|
|
this.scheduleDirtyRefreshes(false);
|
|
}
|
|
|
|
noteProvisioningProgress(progress: TeamProvisioningProgress): void {
|
|
const teamName = this.normalizeTeamName(progress.teamName);
|
|
if (!teamName) {
|
|
return;
|
|
}
|
|
const now = this.now();
|
|
this.pruneStuckLaunches(now);
|
|
this.markDirty('teams:list');
|
|
|
|
if (TERMINAL_PROVISIONING_STATES.has(String(progress.state))) {
|
|
this.activeLaunches.delete(teamName);
|
|
this.quietUntil = Math.max(this.quietUntil, now + this.quietWindowMs);
|
|
this.scheduleDirtyRefreshes(true);
|
|
return;
|
|
}
|
|
|
|
const existing = this.activeLaunches.get(teamName);
|
|
this.activeLaunches.set(teamName, {
|
|
teamName,
|
|
source: existing?.source ?? 'progress',
|
|
startedAt: existing?.startedAt ?? now,
|
|
updatedAt: now,
|
|
});
|
|
this.scheduleDirtyRefreshes(false);
|
|
}
|
|
|
|
noteTeamChange(event: TeamChangeEvent): void {
|
|
if (event.type === 'config') {
|
|
this.markDirty('teams:list');
|
|
this.markDirty('teams:getAllTasks');
|
|
} else if (event.type === 'task') {
|
|
this.markDirty('teams:getAllTasks');
|
|
}
|
|
if (this.hasLaunchPressure(this.now())) {
|
|
this.scheduleDirtyRefreshes(false);
|
|
}
|
|
}
|
|
|
|
async runSummaryOperation<T extends GovernedPayload>(
|
|
key: LaunchIoGovernorOperationKey,
|
|
loadFresh: () => Promise<T>,
|
|
options: { clone: CloneFn<T> }
|
|
): Promise<T> {
|
|
const state = this.getOperationState<T>(key);
|
|
state.loadFresh = loadFresh;
|
|
state.clone = options.clone;
|
|
|
|
if (this.canServeStale(state)) {
|
|
if (state.dirty) {
|
|
this.scheduleDeferredRefresh(key, state, false);
|
|
}
|
|
return options.clone(state.cache!.value);
|
|
}
|
|
|
|
return this.runFresh(key, state, false);
|
|
}
|
|
|
|
clearForTests(): void {
|
|
for (const state of this.operations.values()) {
|
|
if (state.scheduledRefresh) {
|
|
clearTimeout(state.scheduledRefresh);
|
|
}
|
|
}
|
|
this.activeLaunches.clear();
|
|
this.quietUntil = 0;
|
|
this.operations.clear();
|
|
this.operations.set('teams:list', this.createOperationState('teams:list'));
|
|
this.operations.set('teams:getAllTasks', this.createOperationState('teams:getAllTasks'));
|
|
}
|
|
|
|
hasLaunchPressureForTests(): boolean {
|
|
return this.hasLaunchPressure(this.now());
|
|
}
|
|
|
|
private createOperationState<T extends GovernedPayload>(
|
|
key: LaunchIoGovernorOperationKey
|
|
): OperationState<T> {
|
|
return {
|
|
key,
|
|
cache: null,
|
|
dirty: false,
|
|
generation: 0,
|
|
inFlight: null,
|
|
loadFresh: null,
|
|
clone: null,
|
|
scheduledRefresh: null,
|
|
lastWarningAt: Number.NEGATIVE_INFINITY,
|
|
};
|
|
}
|
|
|
|
private getOperationState<T extends GovernedPayload>(
|
|
key: LaunchIoGovernorOperationKey
|
|
): OperationState<T> {
|
|
const state = this.operations.get(key);
|
|
if (!state) {
|
|
throw new Error(`Unknown launch IO governor operation: ${key}`);
|
|
}
|
|
return state as unknown as OperationState<T>;
|
|
}
|
|
|
|
private canServeStale<T extends GovernedPayload>(state: OperationState<T>): boolean {
|
|
const now = this.now();
|
|
if (!this.hasLaunchPressure(now) || !state.cache) {
|
|
return false;
|
|
}
|
|
return now - state.cache.cachedAt <= this.maxStaleAgeMs;
|
|
}
|
|
|
|
private async runFresh<T extends GovernedPayload>(
|
|
key: LaunchIoGovernorOperationKey,
|
|
state: OperationState<T>,
|
|
background: boolean
|
|
): Promise<T> {
|
|
if (!state.loadFresh || !state.clone) {
|
|
throw new Error(`Launch IO governor operation ${key} has no loader`);
|
|
}
|
|
|
|
if (state.inFlight) {
|
|
try {
|
|
const joined = await state.inFlight;
|
|
return state.clone(joined);
|
|
} catch (error) {
|
|
if (background) {
|
|
this.warnRefreshFailure(key, state, error);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const generationAtStart = state.generation;
|
|
const loadFresh = state.loadFresh;
|
|
const clone = state.clone;
|
|
const promise = loadFresh();
|
|
state.inFlight = promise;
|
|
|
|
try {
|
|
const fresh = await promise;
|
|
if (state.generation === generationAtStart) {
|
|
state.cache = {
|
|
value: clone(fresh),
|
|
cachedAt: this.now(),
|
|
};
|
|
state.dirty = false;
|
|
}
|
|
return clone(fresh);
|
|
} catch (error) {
|
|
if (background) {
|
|
this.warnRefreshFailure(key, state, error);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (state.inFlight === promise) {
|
|
state.inFlight = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private markDirty(key: LaunchIoGovernorOperationKey): void {
|
|
const state = this.getOperationState(key);
|
|
state.dirty = true;
|
|
state.generation += 1;
|
|
}
|
|
|
|
private scheduleDirtyRefreshes(force: boolean): void {
|
|
for (const [key, state] of this.operations) {
|
|
if (state.dirty) {
|
|
this.scheduleDeferredRefresh(key, state, force);
|
|
}
|
|
}
|
|
}
|
|
|
|
private scheduleDeferredRefresh<T extends GovernedPayload>(
|
|
key: LaunchIoGovernorOperationKey,
|
|
state: OperationState<T>,
|
|
force: boolean
|
|
): void {
|
|
if (!state.loadFresh || !state.clone) {
|
|
return;
|
|
}
|
|
if (state.scheduledRefresh) {
|
|
if (!force) {
|
|
return;
|
|
}
|
|
clearTimeout(state.scheduledRefresh);
|
|
state.scheduledRefresh = null;
|
|
}
|
|
|
|
const delayMs = this.getDelayUntilFreshAllowed(this.now());
|
|
state.scheduledRefresh = setTimeout(() => {
|
|
state.scheduledRefresh = null;
|
|
void this.flushOperation(key);
|
|
}, delayMs);
|
|
state.scheduledRefresh.unref?.();
|
|
}
|
|
|
|
private async flushOperation(key: LaunchIoGovernorOperationKey): Promise<void> {
|
|
const state = this.getOperationState(key);
|
|
const now = this.now();
|
|
if (this.hasLaunchPressure(now)) {
|
|
this.scheduleDeferredRefresh(key, state, true);
|
|
return;
|
|
}
|
|
if (!state.dirty || !state.loadFresh || !state.clone) {
|
|
return;
|
|
}
|
|
try {
|
|
await this.runFresh(key, state, true);
|
|
} catch {
|
|
// runFresh already emitted a bounded warning. Keep dirty=true so the next
|
|
// request or quiet-window timer can retry without losing the last-good cache.
|
|
}
|
|
}
|
|
|
|
private getDelayUntilFreshAllowed(now: number): number {
|
|
this.pruneStuckLaunches(now);
|
|
if (this.activeLaunches.size > 0) {
|
|
return this.quietWindowMs;
|
|
}
|
|
return Math.max(0, this.quietUntil - now);
|
|
}
|
|
|
|
private hasLaunchPressure(now: number): boolean {
|
|
this.pruneStuckLaunches(now);
|
|
return this.activeLaunches.size > 0 || now < this.quietUntil;
|
|
}
|
|
|
|
private pruneStuckLaunches(now: number): void {
|
|
for (const [teamName, launch] of this.activeLaunches) {
|
|
if (now - launch.updatedAt > this.stuckLaunchPressureMs) {
|
|
this.activeLaunches.delete(teamName);
|
|
this.logger.warn?.(
|
|
`[LaunchIoGovernor] launch pressure expired team=${teamName} source=${launch.source} ageMs=${now - launch.startedAt}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private warnRefreshFailure<T extends GovernedPayload>(
|
|
key: LaunchIoGovernorOperationKey,
|
|
state: OperationState<T>,
|
|
error: unknown
|
|
): void {
|
|
const now = this.now();
|
|
if (now - state.lastWarningAt < this.warningCooldownMs) {
|
|
return;
|
|
}
|
|
state.lastWarningAt = now;
|
|
const ageMs = state.cache ? now - state.cache.cachedAt : null;
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
this.logger.warn?.(
|
|
`[LaunchIoGovernor] deferred refresh failed op=${key} ageMs=${ageMs ?? 'none'} dirty=${state.dirty} activeLaunchCount=${this.activeLaunches.size} error=${errorMessage}`
|
|
);
|
|
}
|
|
|
|
private normalizeTeamName(teamName: string | undefined | null): string | null {
|
|
const normalized = teamName?.trim();
|
|
return normalized && normalized.length > 0 ? normalized : null;
|
|
}
|
|
}
|