Studio: harden profile stats against deleted and malformed history

- Deleting a forked conversation's source left the fork holding the only
  copies of those messages, but the skip still dropped them because
  forked_from_thread_id is not a foreign key. The suppression now joins
  the source row, so copies are only ignored while the originals survive.
- delete_run never clears the predecessor's resume_blocked, so removing
  a continuation stranded its source at zero steps and tokens while the
  run stayed visible. Supersession now requires a continuation that
  still exists.
- created_at is a client-supplied integer stored unchecked, so one
  out-of-range row made datetime raise and returned 500 for the whole
  panel. Those rows are now skipped for day and hour buckets.
- A future-dated row satisfied the current-streak window, reporting days
  that have not happened. The last active day must now be today or
  yesterday.
- Compact numbers rounded 999,999 to "1000K". Rounding into the next
  unit now steps up the suffix.
- Escape or a programmatic close unmounts the Profile tab without a
  blur, dropping an in-progress name edit. Drafts are committed on
  unmount; both saves no-op when unchanged.
- Stats copy no longer claims the numbers never leave the device, which
  is untrue when Studio is reached from another machine. It states what
  actually holds: nothing is collected or sent to Unsloth.
This commit is contained in:
Unsloth 2026-07-29 00:10:13 -07:00
commit 1338f09b97
9 changed files with 241 additions and 30 deletions

View file

@ -90,10 +90,18 @@ def _resolve_zone(tz_name: str, tz_offset_minutes: int):
def _local_stamp(created_at_ms: int, zone) -> Optional[datetime]:
"""Wall-clock time in the caller's timezone, not the server's."""
"""Wall-clock time in the caller's timezone, not the server's.
created_at is a client-supplied integer that SQLite stores unchecked, so a
value outside datetime's range is possible. Drop that row rather than let
one bad import take down the whole panel.
"""
if created_at_ms <= 0:
return None
return datetime.fromtimestamp(created_at_ms / 1000, tz = zone).replace(tzinfo = None)
try:
return datetime.fromtimestamp(created_at_ms / 1000, tz = zone).replace(tzinfo = None)
except (ValueError, OverflowError, OSError):
return None
def _streaks(days: set[date], today: date) -> dict[str, Any]:
@ -114,7 +122,9 @@ def _streaks(days: set[date], today: date) -> dict[str, Any]:
last = ordered[-1]
current_streak = 0
if today - last <= timedelta(days = 1):
# A future-dated row (client clock ahead) is not a live streak: the last
# active day still has to be today or yesterday.
if timedelta(0) <= today - last <= timedelta(days = 1):
current_streak = 1
cursor = last
while cursor - timedelta(days = 1) in days:
@ -182,9 +192,13 @@ def _fold_messages(conn, zone) -> _MessageFold:
"""
SELECT m.thread_id, m.role, m.metadata_json, m.attachments_json, m.created_at,
t.title, t.model_id, t.model_type,
t.created_at AS thread_created_at, t.forked_from_thread_id
t.created_at AS thread_created_at, src.id AS fork_source_id
FROM chat_messages m
LEFT JOIN chat_threads t ON t.id = m.thread_id
-- forked_from_thread_id is not a foreign key, so it outlives the source.
-- Joining to the real row means the copies are only suppressed while
-- the originals are still there to be counted.
LEFT JOIN chat_threads src ON src.id = t.forked_from_thread_id
ORDER BY m.thread_id, m.created_at
"""
)
@ -225,8 +239,9 @@ def _fold_messages(conn, zone) -> _MessageFold:
# Forking clones the whole ancestry into the new thread, keeping each
# copy's original timestamp. Counting those again would double every
# metric for the branched-from conversation, so skip anything older
# than the fork itself.
if row["forked_from_thread_id"] and created_at < _as_int(row["thread_created_at"]):
# than the fork itself, but only while the source survives to be
# counted in its place.
if row["fork_source_id"] and created_at < _as_int(row["thread_created_at"]):
continue
fold.messages += 1
@ -342,14 +357,29 @@ def _daily_series(fold: _MessageFold, today: date, days: int) -> list[dict[str,
return series
def _superseded(prefix: str) -> str:
def _superseded(prefix: str = "r.") -> str:
"""SQL for "a later run resumed from this one, so its counters live there".
``prefix`` must qualify the outer row: the EXISTS subquery selects from the
same table, so a bare column name would bind to the subquery instead.
``create_run``'s resume claim sets ``resume_blocked`` and leaves
``output_dir`` alone. Cancelling clears ``output_dir`` while setting the
same flag, so the flag alone cannot tell the two apart.
``delete_run`` never clears the flag, so the continuation has to still be
there. Otherwise deleting it would strand the source at zero while its row
and metrics stay visible in history.
"""
return f"""
{prefix}resume_blocked = 1
AND {prefix}output_dir IS NOT NULL
AND EXISTS (
SELECT 1 FROM training_runs continuation
WHERE continuation.output_dir = {prefix}output_dir
AND continuation.started_at > {prefix}started_at
)
"""
return f"{prefix}resume_blocked = 1 AND {prefix}output_dir IS NOT NULL"
def _training_stats(conn) -> dict[str, Any]:
@ -371,7 +401,7 @@ def _training_stats(conn) -> dict[str, Any]:
# resume_blocked while leaving output_dir intact, whereas cancelling clears
# output_dir, so a cancelled run keeps contributing the work it did do.
steps = conn.execute(
f"SELECT COALESCE(SUM(final_step), 0) FROM training_runs WHERE NOT ({_superseded('')})"
f"SELECT COALESCE(SUM(r.final_step), 0) FROM training_runs r WHERE NOT ({_superseded()})"
).fetchone()[0]
# num_tokens is state.num_input_tokens_seen, a running total logged at each

View file

@ -522,6 +522,122 @@ def test_unknown_timezone_falls_back_to_the_offset(stats_db):
assert stats["totals"]["totalTokens"] == 20
def test_deleting_the_source_thread_keeps_the_forks_copies(stats_db):
"""forked_from_thread_id is not a foreign key, so it outlives the source."""
now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
conn = studio_db.get_connection()
try:
_seed_thread(conn, "gone", "m", [(now - timedelta(hours = 2), _metadata(100, 50))])
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, "
"updated_at, forked_from_thread_id, forked_from_message_id) "
"VALUES ('kept', 'fork', 'base', 'm', ?, ?, 'gone', 'gone-a0')",
(_ms(now), _ms(now)),
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('kept-a0', 'kept', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2))),
)
conn.commit()
finally:
conn.close()
assert compute_profile_stats(days = 7)["totals"]["totalTokens"] == 150
conn = studio_db.get_connection()
try:
conn.execute("DELETE FROM chat_threads WHERE id = 'gone'")
conn.commit()
finally:
conn.close()
invalidate_profile_stats_cache()
stats = compute_profile_stats(days = 7)
# The fork now holds the only copy, so it must still be counted once.
assert stats["totals"]["totalTokens"] == 150
assert stats["totals"]["messages"] == 1
def test_deleting_a_continuation_restores_its_source(stats_db):
"""delete_run leaves resume_blocked set, so supersession needs a live tail."""
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, total_steps, final_step, output_dir, resume_blocked) "
"VALUES ('src', 'stopped', 'm', 'd', '{}', '2026-01-01T10:00:00', 20, 10, "
"'/runs/out', 1)",
)
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, total_steps, final_step, output_dir, resume_blocked) "
"VALUES ('cont', 'completed', 'm', 'd', '{}', '2026-01-02T10:00:00', 20, 15, "
"'/runs/out', 0)",
)
conn.commit()
finally:
conn.close()
assert compute_profile_stats(days = 7)["training"]["steps"] == 15
conn = studio_db.get_connection()
try:
conn.execute("DELETE FROM training_runs WHERE id = 'cont'")
conn.commit()
finally:
conn.close()
invalidate_profile_stats_cache()
# With the continuation gone the source is the only record of that work.
assert compute_profile_stats(days = 7)["training"]["steps"] == 10
def test_out_of_range_timestamps_do_not_break_the_panel(stats_db):
"""created_at is client supplied and SQLite stores it unchecked."""
now = datetime.now()
conn = studio_db.get_connection()
try:
_seed_thread(conn, "sane", "m", [(now, _metadata(10, 10))])
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) "
"VALUES ('bad', 'bad', 'base', 'm', 1, 1)",
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('bad-a0', 'bad', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(7, 3)), 99_999_999_999_999_999),
)
conn.commit()
finally:
conn.close()
stats = compute_profile_stats(days = 7)
# Totals still include the bad row; only its day and hour buckets are
# dropped, so the two well-formed messages are all that is placed.
assert stats["totals"]["totalTokens"] == 30
assert stats["totals"]["messages"] == 3
assert sum(stats["hourly"]) == 2
def test_future_dated_history_is_not_a_current_streak(stats_db):
"""A client clock that ran ahead must not report a streak that has not happened."""
future = datetime.now() + timedelta(days = 5)
conn = studio_db.get_connection()
try:
_seed_thread(conn, "ahead", "m", [(future, _metadata(10, 10))])
conn.commit()
finally:
conn.close()
streak = compute_profile_stats(days = 366)["streak"]
assert streak["current"] == 0
def test_days_and_hours_use_the_callers_timezone(stats_db):
"""A remote browser must not be bucketed against the server's calendar."""
# 01:30 UTC. In UTC that is one day; at UTC-4 it is 21:30 the day before.

View file

@ -138,6 +138,18 @@ export function ProfilePersonalizationPanel() {
}
};
// Escape, or any programmatic close, unmounts the tab without dispatching a
// blur, which would drop whatever was typed. Commit the drafts on the way
// out; both saves no-op on an unchanged value, so a double commit is safe.
const flushDrafts = useRef<() => void>(() => {});
useEffect(() => {
flushDrafts.current = () => {
saveName();
saveNickname();
};
});
useEffect(() => () => flushDrafts.current(), []);
const applyAvatar = (value: string | null) => {
setAvatarDataUrl(value);
const persisted = readPersistedProfile();

View file

@ -30,14 +30,27 @@ const SHAPE: Record<AvatarShape, string> = {
rounded: "rounded-[22%]",
};
export function UserAvatar({ name, imageUrl, size, className, shape }: UserAvatarProps) {
export function UserAvatar({
name,
imageUrl,
size,
className,
shape,
}: UserAvatarProps) {
const label = initialsFromName(name);
const storedShape = useUserProfileStore((s) => s.avatarShape);
const shapeClass = SHAPE[shape ?? storedShape];
if (imageUrl) {
return (
<span className={cn("relative inline-flex shrink-0 overflow-hidden bg-transparent", shapeClass, SIZE[size], className)}>
<span
className={cn(
"relative inline-flex shrink-0 overflow-hidden bg-transparent",
shapeClass,
SIZE[size],
className,
)}
>
<img src={imageUrl} alt="" className="size-full object-cover" />
</span>
);

View file

@ -238,7 +238,8 @@ export function usePersonalizationSync(enabled: boolean): void {
// default so the push detects the diff) rather than treating the
// default as an explicit remote choice. A record that actually stored
// the field reports <field>Saved=true and still wins.
const localGreeting = useUserProfileStore.getState().showGreetingSloth;
const localGreeting =
useUserProfileStore.getState().showGreetingSloth;
const remoteGreeting = remote.profile.showGreetingSloth !== false;
const keepLocalGreeting =
remote.greetingSlothSaved === false && localGreeting === false;
@ -248,7 +249,9 @@ export function usePersonalizationSync(enabled: boolean): void {
avatarDataUrl: remote.profile.avatarDataUrl ?? null,
avatarShape:
remote.profile.avatarShape === "rounded" ? "rounded" : "circle",
showGreetingSloth: keepLocalGreeting ? localGreeting : remoteGreeting,
showGreetingSloth: keepLocalGreeting
? localGreeting
: remoteGreeting,
};
const nextTheme = remote.appearance.theme;
const localPalette = latestPaletteRef.current;

View file

@ -28,7 +28,11 @@ function loadImage(file: File): Promise<HTMLImageElement> {
});
}
function canvasHasTransparency(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
function canvasHasTransparency(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
): boolean {
const { data } = ctx.getImageData(0, 0, width, height);
for (let i = 3; i < data.length; i += 4) {
if (data[i] < 255) return true;
@ -67,7 +71,12 @@ function drawScaled(
w: number,
h: number,
maxEdge: number,
): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; cw: number; ch: number } {
): {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
cw: number;
ch: number;
} {
const scale = Math.min(1, maxEdge / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
@ -96,16 +105,28 @@ export async function resizeImageFileToDataUrl(file: File): Promise<string> {
// would paint a background behind a transparent image.
for (let edge = MAX_EDGE; edge >= MIN_EDGE; edge -= EDGE_STEP) {
const { canvas } = edge === MAX_EDGE ? base : drawScaled(img, w, h, edge);
const webpDataUrl = encodeCanvasWithinLimit(canvas, "image/webp", WEBP_QUALITY_START);
const webpDataUrl = encodeCanvasWithinLimit(
canvas,
"image/webp",
WEBP_QUALITY_START,
);
if (webpDataUrl) return webpDataUrl;
const pngDataUrl = encodePngWithinLimit(canvas);
if (pngDataUrl) return pngDataUrl;
}
throw new Error("Image is still too large after compression. Try a smaller file.");
throw new Error(
"Image is still too large after compression. Try a smaller file.",
);
}
const jpegDataUrl = encodeCanvasWithinLimit(base.canvas, "image/jpeg", JPEG_QUALITY_START);
const jpegDataUrl = encodeCanvasWithinLimit(
base.canvas,
"image/jpeg",
JPEG_QUALITY_START,
);
if (jpegDataUrl) return jpegDataUrl;
throw new Error("Image is still too large after compression. Try a smaller file.");
throw new Error(
"Image is still too large after compression. Try a smaller file.",
);
}

View file

@ -21,16 +21,21 @@ export function formatCompactNumber(value: number): string {
{ limit: 1e6, suffix: "M" },
{ limit: 1e3, suffix: "K" },
];
for (const { limit, suffix } of units) {
if (abs >= limit) {
const scaled = value / limit;
// One decimal below 100 keeps "1.9B" readable; above it the decimal is noise.
const text =
Math.abs(scaled) >= 100
? Math.round(scaled).toString()
: scaled.toFixed(1);
return `${text.replace(TRAILING_ZERO_DECIMAL, "")}${suffix}`;
for (const [index, { limit, suffix }] of units.entries()) {
if (abs < limit) continue;
const scaled = value / limit;
// One decimal below 100 keeps "1.9B" readable; above it the decimal is noise.
const rounded =
Math.abs(scaled) >= 100 ? Math.round(scaled) : Number(scaled.toFixed(1));
// Rounding can push a value over the next boundary, and "1000K" is not
// compact. Step up a unit rather than print four digits.
const next = units[index - 1];
if (next && Math.abs(rounded) >= 1000) {
return `${(value / next.limit).toFixed(1).replace(TRAILING_ZERO_DECIMAL, "")}${next.suffix}`;
}
const text =
Math.abs(scaled) >= 100 ? rounded.toString() : scaled.toFixed(1);
return `${text.replace(TRAILING_ZERO_DECIMAL, "")}${suffix}`;
}
return String(Math.round(value));
}

View file

@ -411,10 +411,10 @@ export const en = {
stats: {
title: "Your stats",
subtitle:
"Everything below is counted from history on this machine. Nothing is uploaded.",
"Everything below is counted from your own history. Nothing is collected or sent to Unsloth.",
retry: "Try again",
privacyNote:
"Stats are computed locally from your chat and training history and never leave this device.",
"Stats are computed from the chat and training history held by your Unsloth install. Nothing is collected, and nothing is sent to Unsloth or any third party.",
emptyChats:
"No chats yet. Start a conversation and your stats will fill in here.",
lifetimeTokens: "Lifetime tokens",

View file

@ -25,6 +25,17 @@ test("compact numbers match the tile format", () => {
assert.equal(formatCompactNumber(Number.NaN), "0");
});
test("rounding up a unit steps to the next suffix", () => {
// Rounding 999.5K to "1000K" is four digits, which is not compact.
assert.equal(formatCompactNumber(999_999), "1M");
assert.equal(formatCompactNumber(999_500), "1M");
assert.equal(formatCompactNumber(999_999_999), "1B");
assert.equal(formatCompactNumber(999_999_999_999), "1T");
assert.equal(formatCompactNumber(-999_999), "-1M");
// Just below the rounding boundary the unit is unchanged.
assert.equal(formatCompactNumber(999_499), "999K");
});
test("durations read the way the header does", () => {
assert.equal(formatDuration(0), "0m");
assert.equal(formatDuration(45), "45s");