This branch snapshots the in-progress desktop workspace so GitHub-hosted Windows and Linux runners can package the same source tree that already passes local verification on macOS. Constraint: Local Apple Silicon Docker/Wine packaging crashes before Windows artifacts are produced Rejected: Publish from the incomplete base repo state | remote typecheck failed because current source additions were missing Confidence: medium Scope-risk: broad Directive: Treat this branch as a build snapshot only; do not merge it without reviewing the unrelated workspace changes it contains Tested: local typecheck, lint, workspace typecheck, unit tests, integration tests, e2e smoke, macOS signed release upload Not-tested: Native Windows/Linux artifacts before this CI run
185 lines
7.5 KiB
TypeScript
185 lines
7.5 KiB
TypeScript
import { existsSync, lstatSync, realpathSync, unlinkSync } from "node:fs";
|
|
import { extname, resolve, sep } from "node:path";
|
|
import type { DatabaseSync } from "node:sqlite";
|
|
import type { ReadAll } from "./helpers";
|
|
import { nowIso } from "./helpers";
|
|
|
|
|
|
function isWithinOrEqual(targetPath: string, rootPath: string) {
|
|
const resolvedTarget = resolve(targetPath);
|
|
const resolvedRoot = resolve(rootPath);
|
|
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${sep}`);
|
|
}
|
|
|
|
function safeRealpath(targetPath: string) {
|
|
try {
|
|
return realpathSync(targetPath);
|
|
} catch {
|
|
return resolve(targetPath);
|
|
}
|
|
}
|
|
|
|
export function buildSettingsMethods(
|
|
getDb: () => DatabaseSync,
|
|
readAll: ReadAll,
|
|
deps: {
|
|
profileDir: string;
|
|
logsDir: string;
|
|
artifactsDir: string;
|
|
backupsDir: string;
|
|
},
|
|
) {
|
|
function getSetting(key: string): string | null {
|
|
const row = getDb().prepare("select value from settings where key = ?").get(key) as { value: string } | undefined;
|
|
return row?.value ?? null;
|
|
}
|
|
|
|
function setSetting(key: string, value: string) {
|
|
getDb()
|
|
.prepare("insert into settings (key, value) values (?, ?) on conflict(key) do update set value = excluded.value")
|
|
.run(key, value);
|
|
}
|
|
|
|
return {
|
|
getSetting,
|
|
setSetting,
|
|
|
|
getCurrentCompanyId() {
|
|
return getSetting("current_company_id");
|
|
},
|
|
|
|
setCurrentCompany(companyId: string) {
|
|
setSetting("current_company_id", companyId);
|
|
},
|
|
|
|
updateSettings(input: { theme?: "system" | "light" | "dark"; locale?: "en" | "zh"; updatesEnabled?: boolean; logRetentionDays?: number }) {
|
|
if (input.theme) {
|
|
setSetting("theme", input.theme);
|
|
}
|
|
if (input.locale) {
|
|
setSetting("locale", input.locale);
|
|
}
|
|
if (typeof input.updatesEnabled === "boolean") {
|
|
setSetting("updates_enabled", input.updatesEnabled ? "true" : "false");
|
|
}
|
|
if (typeof input.logRetentionDays === "number") {
|
|
setSetting("log_retention_days", String(input.logRetentionDays));
|
|
}
|
|
},
|
|
|
|
getLogRetentionDays(): number {
|
|
const raw = getSetting("log_retention_days");
|
|
const parsed = raw ? parseInt(raw, 10) : NaN;
|
|
return Number.isFinite(parsed) && parsed >= 1 ? parsed : 90;
|
|
},
|
|
|
|
getDisabledModels(connectorId: string): Array<{ id: string; label: string }> {
|
|
const raw = getSetting(`disabled_models:${connectorId}`);
|
|
if (!raw) return [];
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return raw.split(",").filter(Boolean).map((id) => ({ id, label: id }));
|
|
}
|
|
},
|
|
|
|
setDisabledModels(connectorId: string, models: Array<{ id: string; label: string }>) {
|
|
if (models.length === 0) {
|
|
getDb().prepare("delete from settings where key = ?").run(`disabled_models:${connectorId}`);
|
|
} else {
|
|
setSetting(`disabled_models:${connectorId}`, JSON.stringify(models));
|
|
}
|
|
},
|
|
|
|
getLastBudgetResetMonth(companyId: string): string | null {
|
|
return getSetting(`budget_reset_month_${companyId}`);
|
|
},
|
|
|
|
setLastBudgetResetMonth(companyId: string, month: string) {
|
|
setSetting(`budget_reset_month_${companyId}`, month);
|
|
},
|
|
|
|
purgeExpiredRunLogs(): number {
|
|
const raw = getSetting("log_retention_days");
|
|
const parsed = raw ? parseInt(raw, 10) : NaN;
|
|
const retentionDays = Number.isFinite(parsed) && parsed >= 1 ? parsed : 90;
|
|
const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
|
|
const expiredRuns = readAll<{ id: string; logPath: string | null }>(
|
|
"select id, log_path as logPath from runs where finished_at is not null and finished_at < ? and log_path is not null",
|
|
cutoff,
|
|
);
|
|
let purged = 0;
|
|
for (const run of expiredRuns) {
|
|
if (run.logPath) {
|
|
try {
|
|
unlinkSync(run.logPath);
|
|
} catch { /* Log files may already be gone */ }
|
|
}
|
|
getDb()
|
|
.prepare("update runs set log_path = null, updated_at = ? where id = ?")
|
|
.run(nowIso(), run.id);
|
|
purged++;
|
|
}
|
|
return purged;
|
|
},
|
|
|
|
canOpenPath(targetPath: string) {
|
|
if (!targetPath || !existsSync(targetPath)) {
|
|
return false;
|
|
}
|
|
const managedRoots = [deps.profileDir, deps.logsDir, deps.artifactsDir, deps.backupsDir];
|
|
const sensitiveProfileExtensions = new Set([".sqlite", ".db", ".key"]);
|
|
const forbiddenExecutableExtensions = new Set([".app", ".exe", ".bat", ".cmd", ".sh", ".command", ".desktop", ".msi", ".com", ".scr"]);
|
|
const resolvedTarget = safeRealpath(targetPath);
|
|
const targetStats = lstatSync(targetPath);
|
|
const targetExtension = extname(resolvedTarget).toLowerCase();
|
|
if (
|
|
managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, rootPath)) &&
|
|
sensitiveProfileExtensions.has(targetExtension)
|
|
) {
|
|
return false;
|
|
}
|
|
if (!targetStats.isDirectory() && forbiddenExecutableExtensions.has(targetExtension)) {
|
|
return false;
|
|
}
|
|
if (managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, safeRealpath(rootPath)))) {
|
|
return true;
|
|
}
|
|
const workspaces = readAll<{ localPath: string }>("select local_path as localPath from workspaces");
|
|
if (workspaces.some((workspace) => isWithinOrEqual(resolvedTarget, safeRealpath(workspace.localPath)))) {
|
|
return true;
|
|
}
|
|
const runLogs = readAll<{ logPath: string | null }>("select log_path as logPath from runs where log_path is not null");
|
|
return runLogs.some((run) => run.logPath ? safeRealpath(run.logPath) === resolvedTarget : false);
|
|
},
|
|
|
|
hasCompany(companyId: string) {
|
|
const row = getDb().prepare("select id from companies where id = ?").get(companyId) as { id: string } | undefined;
|
|
return Boolean(row);
|
|
},
|
|
|
|
belongsToCompany(table: "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets" | "meetings" | "documents" | "knowledge_base" | "sprints" | "automation_rules" | "workflow_pipelines", id: string, companyId: string) {
|
|
const queries: Record<string, string> = {
|
|
workspaces: "select 1 as present from workspaces where id = ? and company_id = ?",
|
|
agents: "select 1 as present from agents where id = ? and company_id = ?",
|
|
goals: "select 1 as present from goals where id = ? and company_id = ?",
|
|
projects: "select 1 as present from projects where id = ? and company_id = ?",
|
|
tasks: "select 1 as present from tasks where id = ? and company_id = ?",
|
|
approvals: "select 1 as present from approvals where id = ? and company_id = ?",
|
|
runs: "select 1 as present from runs where id = ? and company_id = ?",
|
|
secrets: "select 1 as present from secrets where id = ? and company_id = ?",
|
|
meetings: "select 1 as present from meetings where id = ? and company_id = ?",
|
|
documents: "select 1 as present from documents where id = ? and company_id = ?",
|
|
knowledge_base: "select 1 as present from knowledge_base where id = ? and company_id = ?",
|
|
sprints: "select 1 as present from sprints where id = ? and company_id = ?",
|
|
automation_rules: "select 1 as present from automation_rules where id = ? and company_id = ?",
|
|
workflow_pipelines: "select 1 as present from workflow_pipelines where id = ? and company_id = ?",
|
|
};
|
|
const sql = queries[table];
|
|
if (!sql) throw new Error(`Unknown table: ${table}`);
|
|
const row = getDb().prepare(sql).get(id, companyId) as { present: number } | undefined;
|
|
return Boolean(row?.present);
|
|
},
|
|
};
|
|
}
|