fix: event-loop freezes, plugin-install boot crash, tmux attach/scrollbar, Codex transcripts
- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js, workflow-stats.js); idx_events_session restored, idx_events_tool added - compat-sqlite gains function/pluck/readonly so a plugin install without a better-sqlite3 build boots again; antigravity-usage uses db.js's Database - install-hooks adds no checkout hooks while the agenthub plugin is enabled (events were doubled) - terminal attach runs the live tmux server's own binary; the pane scrollbar gets a visible 2-column style, and claude panes start with CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll - Codex sessions' conversation in GET /api/sessions/:id/transcript - Codex/Antigravity sessions claimed by their workspace without hooks - UI-audit follow-ups, docs and OpenAPI
This commit is contained in:
+52
-29
@@ -30,26 +30,29 @@ const DROP_RESPONSE = {
|
||||
};
|
||||
const DROP_RESPONSE_FILE = { Read: ["content", "base64"] };
|
||||
|
||||
function capString(s) {
|
||||
function capString(s, hit) {
|
||||
if (s.length <= STR_CAP) return s;
|
||||
hit.n++;
|
||||
return `${s.slice(0, HEAD)}\n…[${s.length - HEAD - TAIL} chars trimmed]…\n${s.slice(-TAIL)}`;
|
||||
}
|
||||
|
||||
// Caps every string at any depth; `structuredPatch` is left whole.
|
||||
function capDeep(v, key) {
|
||||
if (typeof v === "string") return capString(v);
|
||||
function capDeep(v, key, hit) {
|
||||
if (typeof v === "string") return capString(v, hit);
|
||||
if (key === "structuredPatch" || v === null || typeof v !== "object") return v;
|
||||
if (Array.isArray(v)) return v.map((x) => capDeep(x));
|
||||
if (Array.isArray(v)) return v.map((x) => capDeep(x, undefined, hit));
|
||||
const out = {};
|
||||
for (const [k, x] of Object.entries(v)) out[k] = capDeep(x, k);
|
||||
for (const [k, x] of Object.entries(v)) out[k] = capDeep(x, k, hit);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} data A parsed hook payload.
|
||||
* @param {{n: number}} [hit] Counts every drop and cap made, so a caller can tell
|
||||
* "nothing to trim" apart.
|
||||
* @returns {object} A trimmed copy (the input is not mutated).
|
||||
*/
|
||||
function trimHookPayload(data) {
|
||||
function trimHookPayload(data, hit = { n: 0 }) {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return data;
|
||||
const out = { ...data };
|
||||
const dropped = new Set(Array.isArray(data._trimmed) ? data._trimmed : []);
|
||||
@@ -59,6 +62,7 @@ function trimHookPayload(data) {
|
||||
for (const k of DROP_RESPONSE[data.tool_name] || []) {
|
||||
if (typeof r[k] === "string" && r[k].length > 0) {
|
||||
delete r[k];
|
||||
hit.n++;
|
||||
dropped.add(`tool_response.${k}`);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +72,7 @@ function trimHookPayload(data) {
|
||||
for (const k of fileDrop) {
|
||||
if (typeof f[k] === "string" && f[k].length > 0) {
|
||||
delete f[k];
|
||||
hit.n++;
|
||||
dropped.add(`tool_response.file.${k}`);
|
||||
}
|
||||
}
|
||||
@@ -75,8 +80,8 @@ function trimHookPayload(data) {
|
||||
}
|
||||
out.tool_response = r;
|
||||
}
|
||||
if ("tool_response" in out) out.tool_response = capDeep(out.tool_response);
|
||||
if ("tool_input" in out) out.tool_input = capDeep(out.tool_input);
|
||||
if ("tool_response" in out) out.tool_response = capDeep(out.tool_response, undefined, hit);
|
||||
if ("tool_input" in out) out.tool_input = capDeep(out.tool_input, undefined, hit);
|
||||
if (dropped.size > 0) out._trimmed = [...dropped];
|
||||
return out;
|
||||
}
|
||||
@@ -84,7 +89,8 @@ function trimHookPayload(data) {
|
||||
/**
|
||||
* The SQL-side entry point (`trim_event_data(?)`, registered in `server/db.js`):
|
||||
* a JSON string in, a JSON string out. Short, null or unparseable input is
|
||||
* returned unchanged, and trimming is idempotent.
|
||||
* returned unchanged, and so is a payload with nothing to trim — re-serializing
|
||||
* it would round integers past 2^53. Trimming is idempotent.
|
||||
* @param {string|null} json
|
||||
* @returns {string|null}
|
||||
*/
|
||||
@@ -97,40 +103,57 @@ function trimEventData(json) {
|
||||
return json;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return json;
|
||||
return JSON.stringify(trimHookPayload(parsed));
|
||||
const hit = { n: 0 };
|
||||
const out = trimHookPayload(parsed, hit);
|
||||
// ponytail: a payload that IS trimmed is re-serialized whole, so a huge integer
|
||||
// elsewhere in it still rounds; splice the JSON text instead if one ever matters.
|
||||
return hit.n ? JSON.stringify(out) : json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims the rows stored before trimming existed, a chunk per write transaction
|
||||
* so a hook never waits long for the lock. Idempotent: a crash just resumes from
|
||||
* the start on the next run.
|
||||
* Trims the rows stored before trimming existed, a few rows per write
|
||||
* transaction so a hook never waits long for the lock: ids are picked first and
|
||||
* each row's payload is read alone, and a transaction ends after `chunk` rows or
|
||||
* once the payloads it read pass `maxBytes` — a Read of an image can hold MBs of
|
||||
* base64, and 200 of those in one go stalled the event loop. Idempotent: a crash
|
||||
* just resumes from the start on the next run.
|
||||
* @param {import("better-sqlite3").Database} db
|
||||
* @param {{chunk?: number}} [opts]
|
||||
* @param {{chunk?: number, maxBytes?: number}} [opts]
|
||||
* @returns {Promise<{rows: number, bytesSaved: number}>}
|
||||
*/
|
||||
function sweepEventPayloads(db, { chunk = 200 } = {}) {
|
||||
const select = db.prepare(
|
||||
"SELECT id, data FROM events WHERE id > ? AND length(data) > ? ORDER BY id LIMIT ?"
|
||||
);
|
||||
function sweepEventPayloads(db, { chunk = 200, maxBytes = 8 * 1024 * 1024 } = {}) {
|
||||
const pick = db
|
||||
.prepare("SELECT id FROM events WHERE id > ? AND length(data) > ? ORDER BY id LIMIT ?")
|
||||
.pluck();
|
||||
const read = db.prepare("SELECT data FROM events WHERE id = ?").pluck();
|
||||
const update = db.prepare("UPDATE events SET data = ? WHERE id = ?");
|
||||
const out = { rows: 0, bytesSaved: 0 };
|
||||
const trimChunk = db.transaction((rows) => {
|
||||
for (const r of rows) {
|
||||
const t = trimEventData(r.data);
|
||||
if (t === r.data) continue;
|
||||
update.run(t, r.id);
|
||||
out.rows++;
|
||||
out.bytesSaved += r.data.length - t.length;
|
||||
// Returns the last id it handled; the next pick starts after it.
|
||||
const trimChunk = db.transaction((ids) => {
|
||||
let bytes = 0;
|
||||
let last = ids[0];
|
||||
for (const id of ids) {
|
||||
last = id;
|
||||
const data = read.get(id);
|
||||
if (typeof data !== "string") continue;
|
||||
bytes += data.length;
|
||||
const t = trimEventData(data);
|
||||
if (t !== data) {
|
||||
update.run(t, id);
|
||||
out.rows++;
|
||||
out.bytesSaved += data.length - t.length;
|
||||
}
|
||||
if (bytes >= maxBytes) break;
|
||||
}
|
||||
return last;
|
||||
});
|
||||
let lastId = 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
const step = () => {
|
||||
try {
|
||||
const rows = select.all(lastId, SKIP_UNDER, chunk);
|
||||
if (rows.length === 0) return resolve(out);
|
||||
trimChunk(rows);
|
||||
lastId = rows[rows.length - 1].id;
|
||||
const ids = pick.all(lastId, SKIP_UNDER, chunk);
|
||||
if (ids.length === 0) return resolve(out);
|
||||
lastId = trimChunk(ids);
|
||||
setImmediate(step);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
|
||||
Reference in New Issue
Block a user