Files
AgentCompany/src/main/database/run-logs.ts
T
ZacharyZhang-NY dbdec86cdd refactor(database): split 5 god modules into domain-scoped files
Five files under `src/main/database/` exceeded 400 LOC by combining
multiple responsibilities. Split along natural fault lines. Zero
behavior change — each original `build*Methods` factory still returns
the same method surface, now via spread of smaller builders.

| Original                          | Split into                          | LOC each       |
|-----------------------------------|-------------------------------------|----------------|
| tasks-crud.ts (640)               | tasks-crud / task-sessions-crud /   | 250 / 252 /    |
|                                   | task-queries                        | 177            |
| agents-hierarchy.ts (487)         | agents-hierarchy / agent-permissions| 350 / 151      |
| company-settings.ts (476)         | company-settings / company-metrics  | 122 / 370      |
| documents.ts (426)                | documents / config-revisions        | 318 / 126      |
| runs.ts (413)                     | runs / run-logs / run-events /      | 245 / 96 /     |
|                                   | cost-entries                        | 82 / 52        |

AppDatabase call sites unchanged:
- db.tasks.saveTask, db.tasks.ensureAgentSession (task-sessions-crud),
  db.tasks.listAgentTasks (task-queries)
- db.agents.getChainOfCommand, db.agents.hasAgentPermission
- db.company.getCompanySettings, db.company.getStandupReport
  (company-metrics)
- db.documentsMod.saveDocument, db.documentsMod.recordConfigRevision
- db.runs.createRun, db.runs.appendRunLog (run-logs),
  db.runs.appendRunEvent (run-events), db.runs.recordCostEntry
  (cost-entries)

`database-base.ts` (449) kept as-is — thin facade where every
pass-through is one line; splitting adds mechanical files without
behavior benefit. Will revisit if it grows further.

Aggregator files (`tasks.ts`, `agents.ts`, `company.ts`) each import
and spread the new sub-builders. No renamed exports.
2026-04-17 16:35:19 -04:00

97 lines
3.8 KiB
TypeScript

import { writeFileSync, readFileSync, existsSync, statSync, openSync, readSync, closeSync } from "node:fs";
import { appendFile } from "node:fs/promises";
import type { DatabaseSync } from "node:sqlite";
import log from "electron-log/main.js";
import type { RunLogChunk } from "@shared/types";
import type { ReadAll, Transaction } from "./helpers";
/**
* Find the last complete UTF-8 character boundary in a buffer.
* Returns the number of bytes up to (and including) the last complete character.
* This prevents corrupting multi-byte CJK characters when slicing at chunk edges.
*/
export function findUtf8Boundary(buffer: Buffer, bytesRead: number): number {
if (bytesRead === 0) return 0;
let pos = bytesRead - 1;
while (pos >= 0 && (buffer[pos] & 0xc0) === 0x80) {
pos--;
}
if (pos < 0) return bytesRead;
const lead = buffer[pos];
let charLen: number;
if ((lead & 0x80) === 0) charLen = 1;
else if ((lead & 0xe0) === 0xc0) charLen = 2;
else if ((lead & 0xf0) === 0xe0) charLen = 3;
else if ((lead & 0xf8) === 0xf0) charLen = 4;
else return bytesRead;
if (pos + charLen <= bytesRead) return bytesRead;
return pos;
}
/**
* Run log file-IO helpers extracted from runs.ts. Every method is spread
* into the final `buildRunMethods` result so AppDatabase call-sites remain
* unchanged. `writeFileSync` is re-exported indirectly through createRun
* (still in runs.ts) via the shared logs directory.
*/
export function buildRunLogMethods(
getDb: () => DatabaseSync,
_readAll: ReadAll,
_transaction: Transaction,
) {
return {
appendRunLog(runId: string, stream: "stdout" | "stderr", chunk: string) {
const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
if (!run?.logPath) return;
return appendFile(run.logPath, `[${new Date().toISOString()}][${stream}] ${chunk}`, "utf8");
},
readRunLog(runId: string) {
const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
if (!run?.logPath) return "";
try {
return readFileSync(run.logPath, "utf8");
} catch (err) {
log.warn(`[database/runs] readFileSync failed for run ${runId} log ${run.logPath}:`, err instanceof Error ? err.message : String(err));
return "";
}
},
readRunLogChunk(runId: string, offset = 0, limit = 65_536): RunLogChunk {
const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
if (!run?.logPath || !existsSync(run.logPath)) {
return { content: "", offset: 0, nextOffset: 0, totalBytes: 0, eof: true };
}
const totalBytes = statSync(run.logPath).size;
const safeOffset = Math.max(0, Math.min(offset, totalBytes));
const safeLimit = Math.max(1_024, Math.min(limit, 262_144));
const bytesToRead = Math.max(0, Math.min(safeLimit, totalBytes - safeOffset));
if (bytesToRead === 0) {
return { content: "", offset: safeOffset, nextOffset: safeOffset, totalBytes, eof: true };
}
const fileDescriptor = openSync(run.logPath, "r");
try {
const buffer = Buffer.allocUnsafe(bytesToRead);
const bytesRead = readSync(fileDescriptor, buffer, 0, bytesToRead, safeOffset);
const safeBytesRead = findUtf8Boundary(buffer, bytesRead);
const nextOffset = safeOffset + safeBytesRead;
return {
content: buffer.subarray(0, safeBytesRead).toString("utf8"),
offset: safeOffset,
nextOffset,
totalBytes,
eof: nextOffset >= totalBytes,
};
} finally {
closeSync(fileDescriptor);
}
},
};
}
// Re-exported so runs.ts can still use writeFileSync on run creation.
export { writeFileSync };