feat(dashboard): add session import flow

This commit is contained in:
Shannon Sands 2026-06-26 08:42:59 +10:00 committed by kshitij
parent f813c7ddad
commit b51d365ef0
9 changed files with 622 additions and 22 deletions

View file

@ -433,7 +433,7 @@ TIPS = [
"hermes chat --source tool tags programmatic chats so they don't clutter hermes sessions list.",
'hermes dump --show-keys includes redacted API key fingerprints for deeper support debugging.',
'hermes sessions rename <ID> "new title" renames any past session; hermes sessions delete <ID> removes one.',
'hermes import restores a session export or profile archive produced by sessions export or profile export.',
'hermes import restores a full Hermes backup zip; session JSON/JSONL exports import from the dashboard Sessions page.',
'hermes fallback manages the fallback_model chain interactively — no hand-editing config.yaml.',
'hermes pairing rotates the DM pairing token — the first messager after rotation claims access to the bot.',
'hermes setup walks first-time users through provider, keys, and platform wiring in one interactive flow.',

View file

@ -9569,6 +9569,11 @@ class BulkDeleteSessions(BaseModel):
profile: Optional[str] = None
class SessionImport(BaseModel):
sessions: List[Dict[str, Any]]
profile: Optional[str] = None
@app.post("/api/sessions/bulk-delete")
async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
"""Delete every session in ``body.ids`` in a single DB transaction.
@ -9619,6 +9624,27 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
db.close()
@app.post("/api/sessions/import")
async def import_sessions_endpoint(body: SessionImport):
"""Import one or more sessions exported from the dashboard or CLI.
This is intentionally separate from ``/api/ops/import``: that endpoint
restores a whole Hermes backup archive, while this endpoint is scoped to
session rows/messages and is safe to use from the Sessions page.
"""
db = _open_session_db_for_profile(body.profile)
try:
result = db.import_sessions(body.sessions)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
finally:
db.close()
if not result.get("ok", False):
raise HTTPException(status_code=400, detail=result)
return result
@app.get("/api/sessions/empty/count")
async def count_empty_sessions_endpoint(profile: Optional[str] = None):
"""Return the number of empty, ended, non-archived sessions.

View file

@ -5352,6 +5352,241 @@ class SessionDB:
results.append({**session, "messages": messages})
return results
@staticmethod
def _json_text_or_none(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, str):
return value
try:
return json.dumps(value)
except (TypeError, ValueError):
return str(value)
@staticmethod
def _float_or_none(value: Any) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@staticmethod
def _int_or_default(value: Any, default: int = 0) -> int:
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
@staticmethod
def _reasoning_json_value(value: Any) -> Any:
if not isinstance(value, str):
return value
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
def import_sessions(self, sessions: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Import sessions exported by :meth:`export_session` or ``export_all``.
Existing session IDs are skipped. Imported child sessions keep their
parent only when that parent already exists or is included in the same
import payload; otherwise the child is detached so partial imports don't
fail foreign-key validation. Gateway routing, handoff, rewind, and other
live runtime state are intentionally reset: this restores conversation
history, not ownership of a live channel or process.
"""
if not isinstance(sessions, list):
raise ValueError("sessions must be a list")
if len(sessions) > 500:
raise ValueError("sessions must contain at most 500 entries")
normalized: List[Dict[str, Any]] = []
errors: List[Dict[str, Any]] = []
seen_ids: set[str] = set()
for index, raw in enumerate(sessions):
if not isinstance(raw, dict):
errors.append({"index": index, "error": "session must be an object"})
continue
session_id = str(raw.get("id") or "").strip()
if not session_id:
errors.append({"index": index, "error": "session id is required"})
continue
if session_id in seen_ids:
errors.append(
{"index": index, "session_id": session_id, "error": "duplicate session id"}
)
continue
messages = raw.get("messages") or []
if not isinstance(messages, list):
errors.append(
{"index": index, "session_id": session_id, "error": "messages must be a list"}
)
continue
if any(not isinstance(msg, dict) for msg in messages):
errors.append(
{
"index": index,
"session_id": session_id,
"error": "messages must contain only objects",
}
)
continue
seen_ids.add(session_id)
normalized.append({"index": index, "session": raw, "messages": messages})
if errors:
return {
"ok": False,
"imported": 0,
"skipped": 0,
"detached": 0,
"errors": errors,
}
def _do(conn):
imported_ids: List[str] = []
skipped_ids: List[str] = []
parent_updates: List[tuple[str, str]] = []
detached = 0
for item in normalized:
raw = item["session"]
messages = item["messages"]
session_id = str(raw.get("id") or "").strip()
exists = conn.execute(
"SELECT 1 FROM sessions WHERE id = ? LIMIT 1",
(session_id,),
).fetchone()
if exists:
skipped_ids.append(session_id)
continue
started_at = self._float_or_none(raw.get("started_at"))
if started_at is None:
started_at = time.time()
archived = 1 if raw.get("archived") else 0
conn.execute(
"""INSERT INTO sessions (
id, source, user_id, model, model_config, system_prompt,
parent_session_id, started_at, ended_at, end_reason,
message_count, tool_call_count, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens, reasoning_tokens,
cwd, git_branch, git_repo_root,
billing_provider, billing_base_url, billing_mode,
estimated_cost_usd, actual_cost_usd, cost_status, cost_source,
pricing_version, title, api_call_count, archived
)
VALUES (
:id, :source, :user_id, :model, :model_config,
:system_prompt, NULL, :started_at, :ended_at,
:end_reason, 0, 0, :input_tokens, :output_tokens,
:cache_read_tokens, :cache_write_tokens,
:reasoning_tokens, :cwd, :git_branch, :git_repo_root,
:billing_provider, :billing_base_url, :billing_mode,
:estimated_cost_usd, :actual_cost_usd, :cost_status,
:cost_source, :pricing_version, :title,
:api_call_count, :archived
)""",
{
"id": session_id,
"source": str(raw.get("source") or "import"),
"user_id": raw.get("user_id"),
"model": raw.get("model"),
"model_config": self._json_text_or_none(raw.get("model_config")),
"system_prompt": raw.get("system_prompt"),
"started_at": started_at,
"ended_at": self._float_or_none(raw.get("ended_at")),
"end_reason": raw.get("end_reason"),
"input_tokens": self._int_or_default(raw.get("input_tokens")),
"output_tokens": self._int_or_default(raw.get("output_tokens")),
"cache_read_tokens": self._int_or_default(
raw.get("cache_read_tokens")
),
"cache_write_tokens": self._int_or_default(
raw.get("cache_write_tokens")
),
"reasoning_tokens": self._int_or_default(
raw.get("reasoning_tokens")
),
"cwd": raw.get("cwd"),
"git_branch": raw.get("git_branch"),
"git_repo_root": raw.get("git_repo_root"),
"billing_provider": raw.get("billing_provider"),
"billing_base_url": raw.get("billing_base_url"),
"billing_mode": raw.get("billing_mode"),
"estimated_cost_usd": self._float_or_none(
raw.get("estimated_cost_usd")
),
"actual_cost_usd": self._float_or_none(
raw.get("actual_cost_usd")
),
"cost_status": raw.get("cost_status"),
"cost_source": raw.get("cost_source"),
"pricing_version": raw.get("pricing_version"),
"title": raw.get("title"),
"api_call_count": self._int_or_default(raw.get("api_call_count")),
"archived": archived,
},
)
sanitized_messages: List[Dict[str, Any]] = []
for msg in messages:
clean = dict(msg)
for key in (
"reasoning_details",
"codex_reasoning_items",
"codex_message_items",
):
clean[key] = self._reasoning_json_value(clean.get(key))
sanitized_messages.append(clean)
total_messages, total_tool_calls = self._insert_message_rows(
conn,
session_id,
sanitized_messages,
)
conn.execute(
"UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
(total_messages, total_tool_calls, session_id),
)
parent_id = str(raw.get("parent_session_id") or "").strip()
if parent_id and parent_id != session_id:
parent_updates.append((session_id, parent_id))
imported_ids.append(session_id)
for session_id, parent_id in parent_updates:
parent_exists = conn.execute(
"SELECT 1 FROM sessions WHERE id = ? LIMIT 1",
(parent_id,),
).fetchone()
if parent_exists:
conn.execute(
"UPDATE sessions SET parent_session_id = ? WHERE id = ?",
(parent_id, session_id),
)
else:
detached += 1
return {
"ok": True,
"imported": len(imported_ids),
"skipped": len(skipped_ids),
"detached": detached,
"imported_ids": imported_ids,
"skipped_ids": skipped_ids,
"errors": [],
}
return self._execute_write(_do)
def clear_messages(self, session_id: str) -> None:
"""Delete all messages for a session and reset its counters."""
def _do(conn):

View file

@ -1189,6 +1189,53 @@ class TestWebServerEndpoints:
resp = self.client.patch("/api/sessions/does-not-exist", json={"title": "x"})
assert resp.status_code == 404
def test_import_sessions_endpoint_imports_exported_json(self):
from hermes_state import SessionDB
payload = {
"id": "imported-web-session",
"source": "cli",
"title": "Imported from dashboard",
"started_at": 100.0,
"ended_at": 110.0,
"end_reason": "complete",
"messages": [
{"role": "user", "content": "hello", "timestamp": 101.0},
{"role": "assistant", "content": "hi", "timestamp": 102.0},
],
}
resp = self.client.post("/api/sessions/import", json={"sessions": [payload]})
assert resp.status_code == 200
data = resp.json()
assert data["imported"] == 1
assert data["skipped"] == 0
db = SessionDB()
try:
session = db.get_session("imported-web-session")
assert session["title"] == "Imported from dashboard"
assert session["message_count"] == 2
assert [m["content"] for m in db.get_messages("imported-web-session")] == [
"hello",
"hi",
]
finally:
db.close()
duplicate = self.client.post("/api/sessions/import", json={"sessions": [payload]})
assert duplicate.status_code == 200
assert duplicate.json()["skipped_ids"] == ["imported-web-session"]
invalid = self.client.post(
"/api/sessions/import",
json={"sessions": [{"source": "cli", "messages": []}]},
)
assert invalid.status_code == 400
assert invalid.json()["detail"]["errors"] == [
{"index": 0, "error": "session id is required"}
]
def test_archive_session_via_patch(self):
"""PATCH archived=true soft-hides a session; archived=false restores it."""
from hermes_state import SessionDB

View file

@ -2168,6 +2168,111 @@ class TestDeleteAndExport:
assert len(exports) == 1
assert exports[0]["source"] == "cli"
def test_import_exported_session_round_trips(self, db, tmp_path):
db.create_session(
session_id="s1",
source="cli",
model="test-model",
model_config={"temperature": 0.2},
user_id="user-1",
cwd="/workspace",
)
db.set_session_title("s1", "Imported session")
db.update_session_cwd(
"s1",
"/workspace/project",
git_branch="feature/import",
git_repo_root="/workspace/project",
)
db.append_message("s1", role="user", content="Hello", timestamp=10)
db.append_message(
"s1",
role="assistant",
content="Hi",
timestamp=11,
tool_calls=[{"id": "call-1", "function": {"name": "noop"}}],
reasoning_details=[{"type": "summary", "text": "short"}],
)
db.end_session("s1", "complete")
exported = db.export_session("s1")
exported["handoff_state"] = "active"
exported["handoff_platform"] = "telegram"
exported["handoff_error"] = "stale runtime state"
exported["rewind_count"] = 3
target = SessionDB(db_path=tmp_path / "target_state.db")
try:
result = target.import_sessions([exported])
assert result["ok"] is True
assert result["imported"] == 1
assert result["skipped"] == 0
imported = target.get_session("s1")
assert imported["title"] == "Imported session"
assert imported["source"] == "cli"
assert imported["model"] == "test-model"
assert imported["cwd"] == "/workspace/project"
assert imported["git_branch"] == "feature/import"
assert imported["git_repo_root"] == "/workspace/project"
assert imported["message_count"] == 2
assert imported["tool_call_count"] == 1
assert imported["handoff_state"] is None
assert imported["handoff_platform"] is None
assert imported["handoff_error"] is None
assert imported["rewind_count"] == 0
messages = target.get_messages("s1")
assert [m["role"] for m in messages] == ["user", "assistant"]
assert messages[0]["content"] == "Hello"
assert messages[1]["tool_calls"][0]["id"] == "call-1"
duplicate = target.import_sessions([exported])
assert duplicate["imported"] == 0
assert duplicate["skipped"] == 1
assert duplicate["skipped_ids"] == ["s1"]
finally:
target.close()
def test_import_sessions_restores_valid_parents_and_detaches_missing(self, db):
result = db.import_sessions(
[
{
"id": "child",
"source": "cli",
"parent_session_id": "parent",
"messages": [],
},
{"id": "parent", "source": "cli", "messages": []},
{
"id": "orphan",
"source": "cli",
"parent_session_id": "missing",
"messages": [],
},
]
)
assert result["ok"] is True
assert result["imported"] == 3
assert result["detached"] == 1
assert db.get_session("child")["parent_session_id"] == "parent"
assert db.get_session("orphan")["parent_session_id"] is None
def test_import_sessions_rejects_invalid_batch_atomically(self, db):
result = db.import_sessions(
[
{"id": "valid", "source": "cli", "messages": []},
{"source": "cli", "messages": []},
]
)
assert result["ok"] is False
assert result["imported"] == 0
assert result["errors"] == [
{"index": 1, "error": "session id is required"}
]
assert db.get_session("valid") is None
# =========================================================================
# Prune

View file

@ -403,6 +403,15 @@ export const api = {
fetchJSON<SessionStoreStats>(appendProfileParam("/api/sessions/stats", profile)),
exportSessionUrl: (id: string, profile = getManagementProfile()) =>
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/export`, profile),
importSessions: (
sessions: Array<Record<string, unknown>>,
profile = getManagementProfile(),
) =>
fetchJSON<SessionImportResponse>("/api/sessions/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessions, profile: profile || undefined }),
}),
pruneSessions: (
older_than_days: number,
source?: string,
@ -1298,6 +1307,16 @@ export interface SessionStoreStats {
by_source: Record<string, number>;
}
export interface SessionImportResponse {
ok: boolean;
imported: number;
skipped: number;
detached: number;
imported_ids: string[];
skipped_ids: string[];
errors: Array<Record<string, unknown>>;
}
export interface SkillHubResult {
name: string;
description: string;

View file

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { importSummary, parseImportSessions } from "./session-import";
describe("parseImportSessions", () => {
it("accepts a single exported session", () => {
expect(parseImportSessions('{"id":"session-1","messages":[]}')).toEqual([
{ id: "session-1", messages: [] },
]);
});
it("accepts arrays and wrapped session exports", () => {
const sessions = [{ id: "one" }, { id: "two" }];
expect(parseImportSessions(JSON.stringify(sessions))).toEqual(sessions);
expect(parseImportSessions(JSON.stringify({ sessions }))).toEqual(sessions);
});
it("accepts JSONL session exports", () => {
expect(parseImportSessions('{"id":"one"}\n\n{"id":"two"}\n')).toEqual([
{ id: "one" },
{ id: "two" },
]);
});
it("rejects empty files and non-object entries", () => {
expect(() => parseImportSessions(" \n")).toThrow("File is empty");
expect(() => parseImportSessions('[{"id":"one"},42]')).toThrow(
"Expected exported session JSON or JSONL",
);
});
});
describe("importSummary", () => {
it("includes skipped and detached counts only when present", () => {
expect(
importSummary({
ok: true,
imported: 2,
skipped: 1,
detached: 1,
imported_ids: ["one", "two"],
skipped_ids: ["existing"],
errors: [],
}),
).toBe("2 imported; 1 skipped; 1 detached from missing parents");
});
});

View file

@ -0,0 +1,46 @@
import type { SessionImportResponse } from "@/lib/api";
export type ImportableSession = Record<string, unknown>;
function normalizeImportSessions(value: unknown): ImportableSession[] {
const candidate =
value &&
typeof value === "object" &&
!Array.isArray(value) &&
Array.isArray((value as { sessions?: unknown }).sessions)
? (value as { sessions: unknown[] }).sessions
: Array.isArray(value)
? value
: [value];
const sessions = candidate.filter(
(item): item is ImportableSession =>
!!item && typeof item === "object" && !Array.isArray(item),
);
if (sessions.length !== candidate.length) {
throw new Error("Expected exported session JSON or JSONL");
}
return sessions;
}
export function parseImportSessions(text: string): ImportableSession[] {
const trimmed = text.trim();
if (!trimmed) throw new Error("File is empty");
try {
return normalizeImportSessions(JSON.parse(trimmed));
} catch (jsonError) {
const lines = trimmed.split(/\r?\n/).filter((line) => line.trim());
if (lines.length <= 1) throw jsonError;
return normalizeImportSessions(lines.map((line) => JSON.parse(line)));
}
}
export function importSummary(result: SessionImportResponse): string {
const parts = [`${result.imported} imported`];
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
if (result.detached > 0) {
parts.push(`${result.detached} detached from missing parents`);
}
return parts.join("; ");
}

View file

@ -25,12 +25,17 @@ import {
Play,
Eraser,
Download,
Upload,
Pencil,
Check,
Archive,
} from "lucide-react";
import { api } from "@/lib/api";
import { shouldRefreshSessions } from "@/lib/session-refresh";
import {
importSummary,
parseImportSessions,
} from "@/lib/session-import";
import type {
SessionInfo,
SessionMessage,
@ -387,7 +392,6 @@ function SessionRow({
resumeInChatEnabled,
}: SessionRowProps) {
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(session.title ?? "");
@ -396,15 +400,20 @@ function SessionRow({
const navigate = useNavigate();
useEffect(() => {
if (isExpanded && messages === null && !loading) {
setLoading(true);
api
.getSessionMessages(session.id)
.then((resp) => setMessages(resp.messages))
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}
}, [isExpanded, session.id, messages, loading]);
if (!isExpanded || messages !== null) return;
let cancelled = false;
api
.getSessionMessages(session.id)
.then((resp) => {
if (!cancelled) setMessages(resp.messages);
})
.catch((err) => {
if (!cancelled) setError(String(err));
});
return () => {
cancelled = true;
};
}, [isExpanded, session.id, messages]);
const sourceInfo = (session.source
? SOURCE_CONFIG[session.source]
@ -639,7 +648,7 @@ function SessionRow({
{isExpanded && (
<div className="min-w-0 border-t border-border bg-background/50 p-4">
{loading && (
{messages === null && !error && (
<div className="flex items-center justify-center py-8">
<Spinner className="text-xl text-primary" />
</div>
@ -725,6 +734,7 @@ export default function SessionsPage() {
>(null);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const importInputRef = useRef<HTMLInputElement | null>(null);
const logScrollRef = useRef<HTMLPreElement | null>(null);
const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
@ -757,6 +767,7 @@ export default function SessionsPage() {
const [pruneOpen, setPruneOpen] = useState(false);
const [pruneDays, setPruneDays] = useState("90");
const [pruning, setPruning] = useState(false);
const [importingSessions, setImportingSessions] = useState(false);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
@ -831,6 +842,37 @@ export default function SessionsPage() {
.catch(() => {});
}, []);
const handleImportSessions = useCallback(
async (files: FileList | null) => {
const file = files?.[0];
if (!file) return;
setImportingSessions(true);
try {
const text = await file.text();
const importedSessions = parseImportSessions(text);
const result = await api.importSessions(importedSessions);
showToast(`Import complete: ${importSummary(result)}`, "success");
clearSelection();
loadSessions(page, true);
loadStats();
refreshEmptyCount();
} catch (error) {
showToast(`Import failed: ${error}`, "error");
} finally {
setImportingSessions(false);
if (importInputRef.current) importInputRef.current.value = "";
}
},
[
clearSelection,
loadSessions,
loadStats,
page,
refreshEmptyCount,
showToast,
],
);
useEffect(() => {
loadStats();
}, [loadStats]);
@ -842,11 +884,21 @@ export default function SessionsPage() {
// baseline without triggering a redundant reload (mount already loads).
const newestSeenRef = useRef<string | null>(null);
const pageRef = useRef(page);
pageRef.current = page;
useEffect(() => {
loadSessions(page);
refreshEmptyCount();
pageRef.current = page;
}, [page]);
useEffect(() => {
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
loadSessions(page);
refreshEmptyCount();
});
return () => {
cancelled = true;
};
}, [loadSessions, page, refreshEmptyCount]);
useEffect(() => {
@ -902,6 +954,7 @@ export default function SessionsPage() {
const updateSearch = useCallback(
(value: string) => {
setSearch(value);
if (value.trim()) setView("list");
clearSelection();
},
[clearSelection],
@ -919,13 +972,15 @@ export default function SessionsPage() {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!search.trim()) {
setSearchResults(null);
setSearching(false);
debounceRef.current = setTimeout(() => {
setSearchResults(null);
setSearching(false);
}, 0);
return;
}
setSearching(true);
debounceRef.current = setTimeout(() => {
setSearching(true);
api
.searchSessions(search.trim())
.then((resp) => setSearchResults(resp.results))
@ -1210,10 +1265,6 @@ export default function SessionsPage() {
const showList = view === "list" || isSearching || !showOverviewTab;
const showPagination = showList && !searchResults && total > PAGE_SIZE;
useEffect(() => {
if (isSearching) setView("list");
}, [isSearching]);
const alerts: { message: string; detail?: string }[] = [];
if (status) {
if (status.gateway_state === "startup_failed") {
@ -1249,6 +1300,13 @@ export default function SessionsPage() {
<div className="flex min-w-0 w-full max-w-full flex-col gap-4">
<PluginSlot name="sessions:top" />
<Toast toast={toast} />
<input
ref={importInputRef}
type="file"
accept=".json,.jsonl,application/json,application/x-ndjson"
className="hidden"
onChange={(event) => void handleImportSessions(event.currentTarget.files)}
/>
<DeleteConfirmDialog
open={sessionDelete.isOpen}
@ -1527,6 +1585,23 @@ export default function SessionsPage() {
</span>
</Button>
)}
{!isSearching && (
<Button
outlined
size="sm"
className="shrink-0"
disabled={importingSessions}
onClick={() => importInputRef.current?.click()}
aria-label="Import exported sessions"
title="Import exported session JSON or JSONL"
prefix={importingSessions ? <Spinner /> : <Upload />}
>
<span className="font-mondwest normal-case text-xs">
Import sessions
</span>
</Button>
)}
</div>
{showPagination && (