feat: right-click menus everywhere, workspace/session cost, long-term DB safety
Right-click menus: one portal shell (components/ContextMenu.tsx) for every item with actions - sidebar/Recents/archived session rows (SessionMenu), console tabs, pane headers, the Changes panel's files/tree/commits, the workspace home, sessions outside a workspace (SessionRowMenu: Sessions table, Dashboard, Activity, Workflow runs), prompt images, every Settings list and every Claude Code/Codex config list (FileItemMenu). Buttons stay; a delete keeps its button's confirm; the shell stops portal clicks from reaching the card that opened it. Status bar: Workspace $ and Session $ on the Workspace page, from ?lane= on GET /api/pricing/cost and the focused/replayed session's cost. Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB, hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created (replacing the redundant idx_events_session), watchdog reads sessions.transcript_path, transcript parsed before the hook's write transaction, GET /api/sessions/:id?events=0 for the web page (reloads only on its own session, debounced), streamed export, transactional clear-data and chunked cleanup, subagent import reads tool_use_ids once, runtime logs rotated past 5 MB.
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const { rotateIfLarge } = require("./log-rotate");
|
||||
const path = require("path");
|
||||
const { execFileSync, spawnSync } = require("child_process");
|
||||
|
||||
@@ -42,6 +43,7 @@ const state = { status: "running", started_at: new Date().toISOString(), steps:
|
||||
|
||||
function log(line) {
|
||||
try {
|
||||
rotateIfLarge(LOG_FILE);
|
||||
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
|
||||
} catch {
|
||||
/* best effort */
|
||||
|
||||
@@ -1029,16 +1029,27 @@ function importSubagentFromJsonl(dbModule, sessionId, mainAgentId, subData) {
|
||||
// Per-tool-call events under the subagent's own agent_id so the UI can attribute
|
||||
// them to the subagent. Idempotent by (agent_id, event_type, tool_use_id).
|
||||
if (Array.isArray(subData.toolEvents) && subData.toolEvents.length > 0) {
|
||||
const eventExists = db.prepare(
|
||||
"SELECT 1 FROM events WHERE agent_id = ? AND event_type = ? AND data LIKE ? LIMIT 1"
|
||||
// Every tool_use_id this agent already has, read ONCE: a `data LIKE` per
|
||||
// tool event re-scanned the agent's payloads for each one, and this runs
|
||||
// on every SubagentStop — quadratic in a long subagent's tool calls.
|
||||
const seen = new Set(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT event_type, CASE WHEN json_valid(data) THEN json_extract(data, '$.tool_use_id') END AS id
|
||||
FROM events WHERE agent_id = ? AND event_type IN ('PreToolUse', 'PostToolUse')`
|
||||
)
|
||||
.all(targetAgentId)
|
||||
.filter((r) => r.id)
|
||||
.map((r) => `${r.event_type}\u0001${r.id}`)
|
||||
);
|
||||
const has = (type, id) => seen.has(`${type}\u0001${id}`);
|
||||
for (const tev of subData.toolEvents) {
|
||||
if (!tev.tool_use_id) continue;
|
||||
const useIdMarker = `%"tool_use_id":${JSON.stringify(tev.tool_use_id)}%`;
|
||||
const ts = tev.pre_timestamp || subData.startedAt;
|
||||
const truncatedInput = truncateForEvent(tev.tool_input);
|
||||
|
||||
if (!eventExists.get(targetAgentId, "PreToolUse", useIdMarker)) {
|
||||
if (!has("PreToolUse", tev.tool_use_id)) {
|
||||
seen.add(`PreToolUse\u0001${tev.tool_use_id}`);
|
||||
insertEvent.run(
|
||||
sessionId,
|
||||
targetAgentId,
|
||||
@@ -1057,7 +1068,8 @@ function importSubagentFromJsonl(dbModule, sessionId, mainAgentId, subData) {
|
||||
created++;
|
||||
}
|
||||
|
||||
if (tev.post_timestamp && !eventExists.get(targetAgentId, "PostToolUse", useIdMarker)) {
|
||||
if (tev.post_timestamp && !has("PostToolUse", tev.tool_use_id)) {
|
||||
seen.add(`PostToolUse\u0001${tev.tool_use_id}`);
|
||||
insertEvent.run(
|
||||
sessionId,
|
||||
targetAgentId,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @file One-step rotation for AgentHub's append-only runtime logs (server.log,
|
||||
* bootstrap.log, client-build.log, plugin-update.log, cc-update-all.log): a
|
||||
* file past `max` bytes becomes `<file>.1`, replacing the previous one, so
|
||||
* each log costs at most twice `max` however long the install lives. Pure fs,
|
||||
* never throws — a log must never stop what it records.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
const DEFAULT_MAX = 5 * 1024 * 1024;
|
||||
|
||||
function rotateIfLarge(file, max = DEFAULT_MAX) {
|
||||
try {
|
||||
if (fs.statSync(file).size > max) fs.renameSync(file, `${file}.1`);
|
||||
} catch {
|
||||
/* missing file, or not ours to move: keep appending */
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { rotateIfLarge, DEFAULT_MAX };
|
||||
|
||||
if (require.main === module) {
|
||||
// Self-check: node scripts/log-rotate.js
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const assert = require("assert");
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "log-rotate-"));
|
||||
const f = path.join(dir, "x.log");
|
||||
fs.writeFileSync(f, "a".repeat(20));
|
||||
rotateIfLarge(f, 10);
|
||||
assert.ok(!fs.existsSync(f) && fs.statSync(`${f}.1`).size === 20);
|
||||
fs.writeFileSync(f, "b".repeat(5));
|
||||
rotateIfLarge(f, 10);
|
||||
assert.equal(fs.readFileSync(f, "utf8"), "bbbbb");
|
||||
rotateIfLarge(path.join(dir, "missing.log"), 10);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
console.log("log-rotate ok");
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const { rotateIfLarge } = require("./log-rotate");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
@@ -469,6 +470,7 @@ function waitForPortsFree(timeoutMs = 10000) {
|
||||
}
|
||||
|
||||
function startDashboard(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
||||
rotateIfLarge(path.join(rt, "server.log"));
|
||||
const logFile = fs.openSync(path.join(rt, "server.log"), "a");
|
||||
const child = spawn(process.execPath, [path.join(root, "server", "index.js")], {
|
||||
cwd: root,
|
||||
@@ -490,6 +492,7 @@ function startDashboard(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
||||
function log(rt, line) {
|
||||
const stamped = `[${new Date().toISOString()}] ${line}\n`;
|
||||
try {
|
||||
rotateIfLarge(path.join(rt, "bootstrap.log"));
|
||||
fs.appendFileSync(path.join(rt, "bootstrap.log"), stamped);
|
||||
} catch {
|
||||
/* best effort */
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const { rotateIfLarge } = require("./log-rotate");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
@@ -55,6 +56,7 @@ function buildClient({
|
||||
const dist = path.join(rt, "client-dist");
|
||||
if (!force && fs.existsSync(path.join(dist, "index.html"))) return "ready";
|
||||
|
||||
if (logPath) rotateIfLarge(logPath);
|
||||
const logFd = logPath ? fs.openSync(logPath, "a") : null;
|
||||
const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd];
|
||||
try {
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const { rotateIfLarge } = require("./log-rotate");
|
||||
const path = require("path");
|
||||
const { execFileSync, spawnSync } = require("child_process");
|
||||
|
||||
@@ -45,6 +46,7 @@ const SAFE_NAME = /^[A-Za-z0-9][\w.-]*$/;
|
||||
|
||||
function log(line) {
|
||||
try {
|
||||
rotateIfLarge(LOG_FILE);
|
||||
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
|
||||
} catch {
|
||||
/* best effort */
|
||||
|
||||
Reference in New Issue
Block a user