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:
@@ -26,6 +26,7 @@ const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude
|
||||
const { getCodexHome, setCodexHome } = require("../lib/codex-home");
|
||||
const { envOwned } = require("../lib/settings-env");
|
||||
const pricingSync = require("../lib/pricing-sync");
|
||||
const { EXPORT_FORMAT, EXPORT_VERSION, importExportBundle } = require("../lib/data-transfer");
|
||||
|
||||
function getDbSize() {
|
||||
try {
|
||||
@@ -64,6 +65,17 @@ function getHookStatus() {
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
];
|
||||
// The enabled plugin runs the hooks from its own plugin.json, so
|
||||
// settings.json rightly holds none of ours (installHooks removes them).
|
||||
const { pluginProvidesHooks } = require("../../scripts/install-hooks");
|
||||
if (pluginProvidesHooks(settings)) {
|
||||
return {
|
||||
installed: true,
|
||||
source: "plugin",
|
||||
path: getSettingsPath(),
|
||||
hooks: Object.fromEntries(hookTypes.map((ht) => [ht, true])),
|
||||
};
|
||||
}
|
||||
const hooks = {};
|
||||
for (const ht of hookTypes) {
|
||||
const entries = settings.hooks?.[ht] || [];
|
||||
@@ -288,7 +300,10 @@ router.get("/export", async (req, res) => {
|
||||
};
|
||||
const field = (name, value) => `,${JSON.stringify(name)}:${JSON.stringify(value)}`;
|
||||
try {
|
||||
await write(`{"exported_at":${JSON.stringify(new Date().toISOString())}`);
|
||||
await write(
|
||||
`{"format":${JSON.stringify(EXPORT_FORMAT)},"version":${EXPORT_VERSION}` +
|
||||
field("exported_at", new Date().toISOString())
|
||||
);
|
||||
await write(
|
||||
field("sessions", db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all())
|
||||
);
|
||||
@@ -311,6 +326,16 @@ router.get("/export", async (req, res) => {
|
||||
}
|
||||
await write("]");
|
||||
await write(field("token_usage", db.prepare("SELECT * FROM token_usage").all()));
|
||||
await write(field("workflows", db.prepare("SELECT * FROM workflows").all()));
|
||||
await write(
|
||||
field(
|
||||
"dashboard_runs",
|
||||
db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all()
|
||||
)
|
||||
);
|
||||
await write(
|
||||
field("alert_rules", db.prepare("SELECT * FROM alert_rules ORDER BY created_at ASC").all())
|
||||
);
|
||||
await write(field("model_pricing", stmts.listPricing.all()) + "}");
|
||||
res.end();
|
||||
} catch (err) {
|
||||
@@ -319,6 +344,62 @@ router.get("/export", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/settings/import — restore a file written by GET /export, uploaded
|
||||
// as multipart `file`. Non-destructive (data-transfer.js: a session already
|
||||
// here is skipped whole, nothing is overwritten). The whole file is parsed in
|
||||
// memory, hence the size cap; a bad file is answered without echoing its text.
|
||||
// ponytail: one JSON.parse of the whole bundle; stream-parse if backups outgrow 512 MB.
|
||||
const IMPORT_MAX_BYTES = 512 * 1024 * 1024;
|
||||
router.post("/import", sameOriginGuard, (req, res) => {
|
||||
const multer = require("multer");
|
||||
const upload = multer({
|
||||
dest: os.tmpdir(),
|
||||
limits: { files: 1, fileSize: IMPORT_MAX_BYTES, fields: 8 },
|
||||
}).single("file");
|
||||
upload(req, res, (uploadErr) => {
|
||||
const file = req.file;
|
||||
const done = (status, body) => {
|
||||
if (file) fs.rm(file.path, { force: true }, () => {});
|
||||
res.status(status).json(body);
|
||||
};
|
||||
if (uploadErr) {
|
||||
const tooBig = uploadErr.code === "LIMIT_FILE_SIZE";
|
||||
return done(tooBig ? 413 : 400, {
|
||||
error: {
|
||||
code: tooBig ? "FILE_TOO_LARGE" : "BAD_UPLOAD",
|
||||
message: tooBig ? "the backup is larger than 512 MB" : uploadErr.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!file) {
|
||||
return done(400, { error: { code: "NO_FILE", message: "attach the export as `file`" } });
|
||||
}
|
||||
let bundle;
|
||||
try {
|
||||
bundle = JSON.parse(fs.readFileSync(file.path, "utf8"));
|
||||
} catch {
|
||||
return done(400, {
|
||||
error: { code: "INVALID_FORMAT", message: "Not a valid export file (not JSON)." },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const counts = importExportBundle(db, bundle);
|
||||
done(200, {
|
||||
ok: true,
|
||||
source: file.originalname,
|
||||
format: bundle.format ?? null,
|
||||
...counts,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === "INVALID_FORMAT") {
|
||||
return done(400, { error: { code: err.code, message: err.message } });
|
||||
}
|
||||
console.warn("import failed:", err.message);
|
||||
done(500, { error: { code: "IMPORT_FAILED", message: err.message } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The two editable home directories share one shape: GET answers the value and
|
||||
// whether the real environment set it (`env_owned` — then a saved value is
|
||||
// overridden again at the next start), PUT validates, applies and persists
|
||||
|
||||
Reference in New Issue
Block a user