141 lines
5.1 KiB
TypeScript
141 lines
5.1 KiB
TypeScript
/**
|
|
* session-sm.ts — Two-SM architecture for Hermes sessions
|
|
*
|
|
* CHANNEL SM (shared, per session key — all users see same state)
|
|
* FRESH → session exists, 0 messages
|
|
* READY → has messages, idle, user can type
|
|
* AGENT_RUNNING → agent processing a user message
|
|
* HANDOVER_PENDING → handover write in progress
|
|
* HANDOVER_DONE → handover written, waiting for action
|
|
* RESETTING → deliberate new session in progress
|
|
* NO_SESSION → no session file found
|
|
*
|
|
* CONNECTION SM (per WebSocket — only this user sees it)
|
|
* CONNECTING → WS open, waiting for auth
|
|
* LOADING_HISTORY → receiving history replay
|
|
* SYNCED → connected to channel, seeing live updates
|
|
* SWITCHING → leaving one channel, joining another
|
|
*/
|
|
|
|
// ── Channel SM ──────────────────────────────────────────────
|
|
|
|
export type ChannelState =
|
|
| 'FRESH'
|
|
| 'READY'
|
|
| 'AGENT_RUNNING'
|
|
| 'HANDOVER_PENDING'
|
|
| 'HANDOVER_DONE'
|
|
| 'RESETTING'
|
|
| 'NO_SESSION';
|
|
|
|
const CHANNEL_TRANSITIONS: Record<ChannelState, ChannelState[]> = {
|
|
FRESH: ['AGENT_RUNNING', 'RESETTING', 'NO_SESSION'],
|
|
READY: ['AGENT_RUNNING', 'HANDOVER_PENDING', 'RESETTING', 'NO_SESSION'],
|
|
AGENT_RUNNING: ['READY', 'RESETTING'],
|
|
HANDOVER_PENDING: ['HANDOVER_DONE', 'READY'],
|
|
HANDOVER_DONE: ['RESETTING', 'READY'],
|
|
RESETTING: ['AGENT_RUNNING', 'FRESH', 'NO_SESSION'],
|
|
NO_SESSION: ['RESETTING', 'FRESH', 'READY'],
|
|
};
|
|
|
|
const CHANNEL_TIMEOUTS_MS: Partial<Record<ChannelState, number>> = {
|
|
AGENT_RUNNING: 300_000,
|
|
HANDOVER_PENDING: 120_000,
|
|
RESETTING: 30_000,
|
|
};
|
|
|
|
// ── Connection SM ───────────────────────────────────────────
|
|
|
|
export type ConnectionState =
|
|
| 'CONNECTING'
|
|
| 'LOADING_HISTORY'
|
|
| 'SYNCED'
|
|
| 'SWITCHING';
|
|
|
|
const CONNECTION_TRANSITIONS: Record<ConnectionState, ConnectionState[]> = {
|
|
CONNECTING: ['LOADING_HISTORY', 'SYNCED'],
|
|
LOADING_HISTORY: ['SYNCED'],
|
|
SYNCED: ['SWITCHING'],
|
|
SWITCHING: ['LOADING_HISTORY', 'SYNCED'],
|
|
};
|
|
|
|
const CONNECTION_TIMEOUTS_MS: Partial<Record<ConnectionState, number>> = {
|
|
SWITCHING: 10_000,
|
|
};
|
|
|
|
// ── Generic SM factory ──────────────────────────────────────
|
|
|
|
export interface StateMachine<S extends string> {
|
|
transition(next: S, payload?: Record<string, unknown>): boolean;
|
|
get(): S;
|
|
destroy(): void;
|
|
}
|
|
|
|
function createSM<S extends string>(
|
|
id: string,
|
|
label: string,
|
|
initial: S,
|
|
transitions: Record<S, S[]>,
|
|
timeouts: Partial<Record<S, number>>,
|
|
onStateChange: (event: Record<string, unknown>) => void,
|
|
timeoutTarget?: S,
|
|
): StateMachine<S> {
|
|
let state: S = initial;
|
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function clearPendingTimeout() {
|
|
if (timeoutHandle !== null) { clearTimeout(timeoutHandle); timeoutHandle = null; }
|
|
}
|
|
|
|
function transition(next: S, payload: Record<string, unknown> = {}): boolean {
|
|
const allowed = transitions[state];
|
|
if (!allowed?.includes(next)) {
|
|
console.warn(`[${label}:${id}] invalid transition: ${state} -> ${next} (ignored)`);
|
|
return false;
|
|
}
|
|
clearPendingTimeout();
|
|
const prev = state;
|
|
state = next;
|
|
console.log(`[${label}:${id}] ${prev} -> ${state}${payload.reason ? ' (' + payload.reason + ')' : ''}`);
|
|
|
|
const ms = timeouts[state];
|
|
if (ms && timeoutTarget) {
|
|
timeoutHandle = setTimeout(() => {
|
|
console.warn(`[${label}:${id}] timeout in ${state}, forcing -> ${timeoutTarget}`);
|
|
transition(timeoutTarget, { reason: 'timeout' });
|
|
}, ms);
|
|
}
|
|
|
|
onStateChange({ type: `${label}_state`, state, prev, ...payload });
|
|
return true;
|
|
}
|
|
|
|
return {
|
|
transition,
|
|
get: () => state,
|
|
destroy: clearPendingTimeout,
|
|
};
|
|
}
|
|
|
|
// ── Factory functions ───────────────────────────────────────
|
|
|
|
export function createChannelSM(
|
|
sessionKey: string,
|
|
onStateChange: (event: Record<string, unknown>) => void,
|
|
initial: ChannelState = 'NO_SESSION',
|
|
): StateMachine<ChannelState> {
|
|
return createSM(sessionKey, 'channel', initial, CHANNEL_TRANSITIONS, CHANNEL_TIMEOUTS_MS, onStateChange, 'READY');
|
|
}
|
|
|
|
export function createConnectionSM(
|
|
clientId: string,
|
|
onStateChange: (event: Record<string, unknown>) => void,
|
|
): StateMachine<ConnectionState> {
|
|
return createSM(clientId, 'connection', 'CONNECTING', CONNECTION_TRANSITIONS, CONNECTION_TIMEOUTS_MS, onStateChange, 'SYNCED');
|
|
}
|
|
|
|
// ── Backward compat (temporary, remove after migration) ─────
|
|
|
|
export type SmState = ChannelState | ConnectionState;
|
|
export type SessionSM = StateMachine<ChannelState>;
|