188 lines
5.9 KiB
TypeScript
188 lines
5.9 KiB
TypeScript
import { runtimeDbPath } from "./runtime-db.js";
|
|
import type { ServerConfig } from "./types.js";
|
|
import { shortId } from "./utils.js";
|
|
import { createWorkspaceKvStore, isRecord } from "./workspace-kv-store.js";
|
|
|
|
export type SessionGroupDefinition = {
|
|
id: string;
|
|
label: string;
|
|
};
|
|
|
|
export type SessionGroupState = {
|
|
groups: SessionGroupDefinition[];
|
|
assignments: Record<string, string>;
|
|
};
|
|
|
|
export type SessionGroupEventAction = "created" | "updated" | "deleted" | "assigned" | "reordered" | "imported";
|
|
|
|
export type SessionGroupEvent = {
|
|
id: string;
|
|
seq: number;
|
|
workspaceId: string;
|
|
type: "session_groups.updated";
|
|
action: SessionGroupEventAction;
|
|
groupId?: string;
|
|
sessionId?: string;
|
|
timestamp: number;
|
|
};
|
|
|
|
const EMPTY_SESSION_GROUP_STATE: SessionGroupState = { groups: [], assignments: {} };
|
|
|
|
type SessionGroupEventState = {
|
|
seq: number;
|
|
events: SessionGroupEvent[];
|
|
};
|
|
|
|
function normalizeGroupId(value: unknown): string {
|
|
if (typeof value !== "string") return "";
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return "";
|
|
return trimmed.slice(0, 128);
|
|
}
|
|
|
|
function normalizeLabel(value: unknown): string {
|
|
if (typeof value !== "string") return "";
|
|
return value.trim().slice(0, 120);
|
|
}
|
|
|
|
export function createSessionGroupId(): string {
|
|
return `grp_${Date.now().toString(36)}_${shortId()}`;
|
|
}
|
|
|
|
export function normalizeSessionGroupState(value: unknown): SessionGroupState {
|
|
if (!isRecord(value)) return EMPTY_SESSION_GROUP_STATE;
|
|
|
|
const groups: SessionGroupDefinition[] = [];
|
|
const seenGroupIds = new Set<string>();
|
|
if (Array.isArray(value.groups)) {
|
|
for (const item of value.groups) {
|
|
if (!isRecord(item)) continue;
|
|
const id = normalizeGroupId(item.id);
|
|
const label = normalizeLabel(item.label);
|
|
if (!id || !label || seenGroupIds.has(id)) continue;
|
|
groups.push({ id, label });
|
|
seenGroupIds.add(id);
|
|
}
|
|
}
|
|
|
|
const assignments: Record<string, string> = {};
|
|
if (isRecord(value.assignments)) {
|
|
for (const [sessionId, rawGroupId] of Object.entries(value.assignments)) {
|
|
const normalizedSessionId = sessionId.trim().slice(0, 256);
|
|
const groupId = normalizeGroupId(rawGroupId);
|
|
if (!normalizedSessionId || !groupId || !seenGroupIds.has(groupId)) continue;
|
|
assignments[normalizedSessionId] = groupId;
|
|
}
|
|
}
|
|
|
|
return { groups, assignments };
|
|
}
|
|
|
|
const updateQueueByWorkspace = new Map<string, Promise<void>>();
|
|
|
|
function parseSessionGroupState(stateJson: string): SessionGroupState {
|
|
try {
|
|
return normalizeSessionGroupState(JSON.parse(stateJson));
|
|
} catch {
|
|
return EMPTY_SESSION_GROUP_STATE;
|
|
}
|
|
}
|
|
|
|
const sessionGroupStateStore = createWorkspaceKvStore<SessionGroupState>({
|
|
tableName: "session_group_states",
|
|
valueColumn: "state_json",
|
|
extraColumns: { schemaVersion: { name: "schema_version", definition: "INTEGER NOT NULL DEFAULT 1", value: 1 } },
|
|
parse: parseSessionGroupState,
|
|
serialize: (value) => JSON.stringify(value),
|
|
});
|
|
|
|
export async function readSessionGroupState(
|
|
config: ServerConfig,
|
|
workspaceId: string,
|
|
): Promise<{ state: SessionGroupState; updatedAt: number | null }> {
|
|
const row = await sessionGroupStateStore.getRow(config, workspaceId);
|
|
if (!row || row.updatedAt === null) return { state: EMPTY_SESSION_GROUP_STATE, updatedAt: null };
|
|
return { state: row.value, updatedAt: row.updatedAt };
|
|
}
|
|
|
|
export async function writeSessionGroupState(
|
|
config: ServerConfig,
|
|
workspaceId: string,
|
|
state: SessionGroupState,
|
|
): Promise<{ state: SessionGroupState; updatedAt: number }> {
|
|
const next = normalizeSessionGroupState(state);
|
|
const updatedAt = Date.now();
|
|
await sessionGroupStateStore.set(config, workspaceId, next, updatedAt);
|
|
return { state: next, updatedAt };
|
|
}
|
|
|
|
export async function updateSessionGroupState(
|
|
config: ServerConfig,
|
|
workspaceId: string,
|
|
updater: (current: SessionGroupState) => SessionGroupState,
|
|
): Promise<{ state: SessionGroupState; updatedAt: number }> {
|
|
const key = `${runtimeDbPath(config)}:${workspaceId}`;
|
|
const previous = updateQueueByWorkspace.get(key) ?? Promise.resolve();
|
|
let release = () => {};
|
|
const queued = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const currentQueue = previous.then(() => queued, () => queued);
|
|
updateQueueByWorkspace.set(key, currentQueue);
|
|
|
|
await previous.catch(() => undefined);
|
|
try {
|
|
const current = await readSessionGroupState(config, workspaceId);
|
|
return await writeSessionGroupState(config, workspaceId, updater(current.state));
|
|
} finally {
|
|
release();
|
|
if (updateQueueByWorkspace.get(key) === currentQueue) {
|
|
updateQueueByWorkspace.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class SessionGroupEventStore {
|
|
private eventsByWorkspace = new Map<string, SessionGroupEventState>();
|
|
private maxSize: number;
|
|
|
|
constructor(maxSize = 500) {
|
|
this.maxSize = maxSize;
|
|
}
|
|
|
|
record(
|
|
workspaceId: string,
|
|
action: SessionGroupEventAction,
|
|
details?: { groupId?: string; sessionId?: string },
|
|
): SessionGroupEvent {
|
|
const state = this.eventsByWorkspace.get(workspaceId) ?? { seq: 0, events: [] };
|
|
const event: SessionGroupEvent = {
|
|
id: shortId(),
|
|
seq: ++state.seq,
|
|
workspaceId,
|
|
type: "session_groups.updated",
|
|
action,
|
|
...(details?.groupId ? { groupId: details.groupId } : {}),
|
|
...(details?.sessionId ? { sessionId: details.sessionId } : {}),
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
state.events.push(event);
|
|
if (state.events.length > this.maxSize) {
|
|
state.events.splice(0, state.events.length - this.maxSize);
|
|
}
|
|
this.eventsByWorkspace.set(workspaceId, state);
|
|
return event;
|
|
}
|
|
|
|
list(workspaceId: string, since?: number): SessionGroupEvent[] {
|
|
const cursor = typeof since === "number" && Number.isFinite(since) ? since : 0;
|
|
const state = this.eventsByWorkspace.get(workspaceId);
|
|
return state ? state.events.filter((event) => event.seq > cursor) : [];
|
|
}
|
|
|
|
cursor(workspaceId: string): number {
|
|
return this.eventsByWorkspace.get(workspaceId)?.seq ?? 0;
|
|
}
|
|
}
|