refactor: split gateway server helpers and tests

This commit is contained in:
Peter Steinberger
2026-01-03 17:34:52 +01:00
parent 00c3e98431
commit 6ae51ae3de
21 changed files with 5385 additions and 5427 deletions

View File

@@ -0,0 +1,300 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { lookupContextTokens } from "../agents/context.js";
import {
DEFAULT_CONTEXT_TOKENS,
DEFAULT_MODEL,
DEFAULT_PROVIDER,
} from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { type ClawdisConfig, loadConfig } from "../config/config.js";
import {
buildGroupDisplayName,
loadSessionStore,
resolveStorePath,
type SessionEntry,
} from "../config/sessions.js";
export type GatewaySessionsDefaults = {
model: string | null;
contextTokens: number | null;
};
export type GatewaySessionRow = {
key: string;
kind: "direct" | "group" | "global" | "unknown";
displayName?: string;
surface?: string;
subject?: string;
room?: string;
space?: string;
updatedAt: number | null;
sessionId?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
thinkingLevel?: string;
verboseLevel?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
model?: string;
contextTokens?: number;
};
export type SessionsListResult = {
ts: number;
path: string;
count: number;
defaults: GatewaySessionsDefaults;
sessions: GatewaySessionRow[];
};
export type SessionsPatchResult = {
ok: true;
path: string;
key: string;
entry: SessionEntry;
};
export function readSessionMessages(
sessionId: string,
storePath: string | undefined,
): unknown[] {
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath);
const filePath = candidates.find((p) => fs.existsSync(p));
if (!filePath) return [];
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
const messages: unknown[] = [];
for (const line of lines) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
if (parsed?.message) {
messages.push(parsed.message);
}
} catch {
// ignore bad lines
}
}
return messages;
}
export function resolveSessionTranscriptCandidates(
sessionId: string,
storePath: string | undefined,
): string[] {
const candidates: string[] = [];
if (storePath) {
const dir = path.dirname(storePath);
candidates.push(path.join(dir, `${sessionId}.jsonl`));
}
candidates.push(
path.join(os.homedir(), ".clawdis", "sessions", `${sessionId}.jsonl`),
);
return candidates;
}
export function archiveFileOnDisk(filePath: string, reason: string): string {
const ts = new Date().toISOString().replaceAll(":", "-");
const archived = `${filePath}.${reason}.${ts}`;
fs.renameSync(filePath, archived);
return archived;
}
function jsonUtf8Bytes(value: unknown): number {
try {
return Buffer.byteLength(JSON.stringify(value), "utf8");
} catch {
return Buffer.byteLength(String(value), "utf8");
}
}
export function capArrayByJsonBytes<T>(
items: T[],
maxBytes: number,
): { items: T[]; bytes: number } {
if (items.length === 0) return { items, bytes: 2 };
const parts = items.map((item) => jsonUtf8Bytes(item));
let bytes = 2 + parts.reduce((a, b) => a + b, 0) + (items.length - 1);
let start = 0;
while (bytes > maxBytes && start < items.length - 1) {
bytes -= parts[start] + 1;
start += 1;
}
const next = start > 0 ? items.slice(start) : items;
return { items: next, bytes };
}
export function loadSessionEntry(sessionKey: string) {
const cfg = loadConfig();
const sessionCfg = cfg.session;
const storePath = sessionCfg?.store
? resolveStorePath(sessionCfg.store)
: resolveStorePath(undefined);
const store = loadSessionStore(storePath);
const entry = store[sessionKey];
return { cfg, storePath, store, entry };
}
export function classifySessionKey(
key: string,
entry?: SessionEntry,
): GatewaySessionRow["kind"] {
if (key === "global") return "global";
if (key === "unknown") return "unknown";
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
if (
key.startsWith("group:") ||
key.includes(":group:") ||
key.includes(":channel:")
) {
return "group";
}
return "direct";
}
export function parseGroupKey(
key: string,
): { surface?: string; kind?: "group" | "channel"; id?: string } | null {
if (key.startsWith("group:")) {
const raw = key.slice("group:".length);
return raw ? { id: raw } : null;
}
const parts = key.split(":").filter(Boolean);
if (parts.length >= 3) {
const [surface, kind, ...rest] = parts;
if (kind === "group" || kind === "channel") {
const id = rest.join(":");
return { surface, kind, id };
}
}
return null;
}
export function getSessionDefaults(
cfg: ClawdisConfig,
): GatewaySessionsDefaults {
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const contextTokens =
cfg.agent?.contextTokens ??
lookupContextTokens(resolved.model) ??
DEFAULT_CONTEXT_TOKENS;
return {
model: resolved.model ?? null,
contextTokens: contextTokens ?? null,
};
}
export function resolveSessionModelRef(
cfg: ClawdisConfig,
entry?: SessionEntry,
): { provider: string; model: string } {
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
let provider = resolved.provider;
let model = resolved.model;
const storedModelOverride = entry?.modelOverride?.trim();
if (storedModelOverride) {
provider = entry?.providerOverride?.trim() || provider;
model = storedModelOverride;
}
return { provider, model };
}
export function listSessionsFromStore(params: {
cfg: ClawdisConfig;
storePath: string;
store: Record<string, SessionEntry>;
opts: import("./protocol/index.js").SessionsListParams;
}): SessionsListResult {
const { cfg, storePath, store, opts } = params;
const now = Date.now();
const includeGlobal = opts.includeGlobal === true;
const includeUnknown = opts.includeUnknown === true;
const activeMinutes =
typeof opts.activeMinutes === "number" &&
Number.isFinite(opts.activeMinutes)
? Math.max(1, Math.floor(opts.activeMinutes))
: undefined;
let sessions = Object.entries(store)
.filter(([key]) => {
if (!includeGlobal && key === "global") return false;
if (!includeUnknown && key === "unknown") return false;
return true;
})
.map(([key, entry]) => {
const updatedAt = entry?.updatedAt ?? null;
const input = entry?.inputTokens ?? 0;
const output = entry?.outputTokens ?? 0;
const total = entry?.totalTokens ?? input + output;
const parsed = parseGroupKey(key);
const surface = entry?.surface ?? parsed?.surface;
const subject = entry?.subject;
const room = entry?.room;
const space = entry?.space;
const id = parsed?.id;
const displayName =
entry?.displayName ??
(surface
? buildGroupDisplayName({
surface,
subject,
room,
space,
id,
key,
})
: undefined);
return {
key,
kind: classifySessionKey(key, entry),
displayName,
surface,
subject,
room,
space,
updatedAt,
sessionId: entry?.sessionId,
systemSent: entry?.systemSent,
abortedLastRun: entry?.abortedLastRun,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: total,
model: entry?.model,
contextTokens: entry?.contextTokens,
} satisfies GatewaySessionRow;
})
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
if (activeMinutes !== undefined) {
const cutoff = now - activeMinutes * 60_000;
sessions = sessions.filter((s) => (s.updatedAt ?? 0) >= cutoff);
}
if (typeof opts.limit === "number" && Number.isFinite(opts.limit)) {
const limit = Math.max(1, Math.floor(opts.limit));
sessions = sessions.slice(0, limit);
}
return {
ts: now,
path: storePath,
count: sessions.length,
defaults: getSessionDefaults(cfg),
sessions,
};
}