Merge branch 'perf/local-opt': faster local install and updates, lighter polling and ingest

This commit is contained in:
2026-10-16 10:27:26 +07:00
79 changed files with 5395 additions and 1796 deletions
@@ -0,0 +1,34 @@
/**
* @file sessionChange.test.ts
* @description Unit tests for `sessionChangeMatcher`: fires on a new session
* and on a change of the keyed columns, never on a re-broadcast that moved none.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { sessionChangeMatcher } from "../sessionChange";
import type { WSMessage } from "../types";
const msg = (type: string, data: unknown) => ({ type, data, timestamp: "" }) as WSMessage;
describe("sessionChangeMatcher", () => {
it("fires on a new session and on a state change, not on a same-state re-broadcast", () => {
const m = sessionChangeMatcher();
expect(m(msg("session_created", { id: "a", status: "active" }))).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "active", name: "x" }))).toBe(false);
expect(
m(msg("session_updated", { id: "a", status: "active", awaiting_input_since: "t" }))
).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "completed" }))).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "completed", archived_at: "t" }))).toBe(
true
);
});
it("takes an unseen or unkeyable session as a change, and ignores other types", () => {
const m = sessionChangeMatcher();
expect(m(msg("session_updated", { id: "b", status: "active" }))).toBe(true);
expect(m(msg("session_updated", null))).toBe(true);
expect(m(msg("new_event", { session_id: "b" }))).toBe(false);
});
});
+47
View File
@@ -1649,6 +1649,23 @@ export const api = {
/** POST /api/settings/backup/run — starts one; poll `get` while `running` */
run: () => request<BackupState & { ok: boolean }>("/settings/backup/run", { method: "POST" }),
},
/** Opt-in automatic retention and Compact (`server/lib/db-retention.js`). */
retention: {
/** GET /api/settings/retention */
get: () => request<RetentionState>("/settings/retention"),
/** PUT /api/settings/retention — `days` 0 turns it off */
save: (days: number) =>
request<RetentionState & { ok: boolean }>("/settings/retention", {
method: "PUT",
body: JSON.stringify({ days }),
}),
/** POST /api/settings/compact — a full VACUUM; poll `get` while `compacting` */
compact: () =>
request<RetentionState & { ok: boolean }>("/settings/compact", {
method: "POST",
body: JSON.stringify({ confirm: true }),
}),
},
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
* stored: `rotate` returns the new token this once, `get` never does. */
pushToken: {
@@ -5461,6 +5478,36 @@ export interface BackupState {
env_owned: { dir: boolean; hour: boolean; keep: boolean };
}
/** GET /api/settings/retention. `days` 0 = automatic retention off;
* `last_run`/`last_compact` are this server process's own. */
export interface RetentionState {
days: number;
max_days: number;
env_owned: { days: boolean };
last_run: {
at: string;
retention?: { purged_sessions: number; purged_events: number; error?: string };
reclaim?: { reclaimed_pages: number; error?: string };
} | null;
space: {
auto_vacuum: "none" | "full" | "incremental";
page_size: number;
page_count: number;
freelist_pages: number;
free_bytes: number;
} | null;
db_size: number;
compacting: boolean;
last_compact: {
at: string;
ok: boolean;
size_before?: number;
size_after?: number;
ms?: number;
error?: string;
} | null;
}
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
export interface PushTokenStatus {
enabled: boolean;
+9 -5
View File
@@ -14,6 +14,9 @@
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
* input box off the screen and empties it; the chat's draft is pasted back
* without Enter), so the half-typed text is never left behind on the other side.
* Attached images' paths go after the text, each in a paste of its own: `claude`
* turns a paste that is only an image's path into `[Image #n]`, but left a
* Windows path (`C:\…\drops\…png`) inside a paste of text as a plain path.
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
* arrows and Enter, or type into its "Type something" row).
@@ -24,7 +27,8 @@
export type PaneScreen = { rows: string[]; cols: number };
interface Pane {
paste: (text: string, submit?: boolean) => void;
/** `files`: image paths pasted after `text`, one paste each. */
paste: (text: string, submit?: boolean, files?: string[]) => void;
visible: () => boolean;
key?: (data: string) => void;
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
@@ -62,19 +66,19 @@ function ofLane(runId: string, laneId: number): boolean {
/** Pastes `text` into the pane of `runId` when it is mounted and on screen;
* false otherwise (not mounted yet, or hidden). */
export function pasteToRun(runId: string, text: string): boolean {
export function pasteToRun(runId: string, text: string, files: string[] = []): boolean {
const p = panes.get(runId);
if (!p?.visible()) return false;
p.paste(text);
p.paste(text, false, files);
return true;
}
/** Sends `text` as a message to the pane of `runId`: a paste, then Enter.
* False when the pane is not mounted or not attached. */
export function submitToRun(runId: string, text: string): boolean {
export function submitToRun(runId: string, text: string, files: string[] = []): boolean {
const p = panes.get(runId);
if (!p?.visible()) return false;
p.paste(text, true);
p.paste(text, true, files);
return true;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* @file sessionChange.ts
* @description `session_created` / `session_updated` matcher for live-refresh
* callers that care about only a few session columns. A session row is
* re-broadcast on nearly every hook (last activity, name, tokens…), so
* matching the bare message types made every such frame refetch; this one
* fires on a new session, or when the chosen key of a session actually
* changed. Each call keeps its own memory of the keys it has seen.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { Session, WSMessage } from "./types";
/** The columns that move a session between the active / waiting / archived counts. */
export const sessionStateKeyOf = (s: Partial<Session>): string =>
`${s.status}:${s.awaiting_input_since ? 1 : 0}:${s.archived_at ? 1 : 0}`;
export function sessionChangeMatcher(
keyOf: (s: Partial<Session>) => string = sessionStateKeyOf
): (msg: WSMessage) => boolean {
const seen = new Map<string, string>();
return (msg) => {
if (msg.type !== "session_created" && msg.type !== "session_updated") return false;
const s = msg.data as Partial<Session> | undefined;
// A frame we cannot key is taken as a change: refetching is the safe side.
if (!s || typeof s.id !== "string") return true;
const key = keyOf(s);
if (msg.type === "session_updated" && seen.get(s.id) === key) return false;
seen.set(s.id, key);
return true;
};
}