feat(settings): 用户级全局默认配置(per-user 落库 + 新建项目套默认)
新增 settings 表(user_id PK,data JSON,单本地用户 'local')+ store.getSettings/putSettings (内置默认=核心策略子集)+ GET/PUT /api/settings 端点;POST /api/projects 创建时以用户 默认填充未显式传的字段(body 优先,自动放行/预算经 patchProject 套用)。前端弹框另提交。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+34
-6
@@ -1,6 +1,6 @@
|
|||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import { WebSocketServer, type WebSocket } from 'ws';
|
import { WebSocketServer, type WebSocket } from 'ws';
|
||||||
import { Store, StoreError, type ActiveRun, type PatchProjectInput, type PatchTaskInput } from '../store/index.js';
|
import { Store, StoreError, type ActiveRun, type PatchProjectInput, type PatchTaskInput, type UserSettings } from '../store/index.js';
|
||||||
import type { Complexity } from '../model/complexity.js';
|
import type { Complexity } from '../model/complexity.js';
|
||||||
import { isComplexity } from '../model/complexity.js';
|
import { isComplexity } from '../model/complexity.js';
|
||||||
import type { TaskStatus } from '../model/status.js';
|
import type { TaskStatus } from '../model/status.js';
|
||||||
@@ -64,14 +64,25 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
|||||||
app.post('/api/projects', (req) => {
|
app.post('/api/projects', (req) => {
|
||||||
const b = req.body as Record<string, unknown>;
|
const b = req.body as Record<string, unknown>;
|
||||||
if (!b?.name || !b?.repoPath) throw new StoreError('name 与 repoPath 必填');
|
if (!b?.name || !b?.repoPath) throw new StoreError('name 与 repoPath 必填');
|
||||||
return projectOut(store.createProject({
|
// 用户级全局默认(核心策略子集):body 显式给则用 body,否则套默认
|
||||||
|
const s = store.getSettings();
|
||||||
|
const pick = <T>(key: string, fallback: T): T => (b[key] !== undefined ? (b[key] as T) : fallback);
|
||||||
|
const created = store.createProject({
|
||||||
name: String(b.name), repoPath: String(b.repoPath),
|
name: String(b.name), repoPath: String(b.repoPath),
|
||||||
defaultBranch: b.defaultBranch ? String(b.defaultBranch) : undefined,
|
defaultBranch: b.defaultBranch ? String(b.defaultBranch) : undefined,
|
||||||
verifyCmd: b.verifyCmd === undefined ? undefined : (b.verifyCmd === null ? null : String(b.verifyCmd)),
|
verifyCmd: b.verifyCmd === undefined ? undefined : (b.verifyCmd === null ? null : String(b.verifyCmd)),
|
||||||
autonomy: b.autonomy as never, model: b.model === undefined ? undefined : (b.model === null ? null : String(b.model)),
|
autonomy: pick('autonomy', s.autonomy) as never,
|
||||||
concurrency: b.concurrency === undefined ? undefined : Number(b.concurrency),
|
model: b.model !== undefined ? (b.model === null ? null : String(b.model)) : s.model,
|
||||||
maxRetries: b.maxRetries === undefined ? undefined : Number(b.maxRetries),
|
concurrency: Number(pick('concurrency', s.concurrency)),
|
||||||
timeoutMs: b.timeoutMs === undefined ? undefined : Number(b.timeoutMs),
|
maxRetries: Number(pick('maxRetries', s.maxRetries)),
|
||||||
|
timeoutMs: Number(pick('timeoutMs', s.timeoutMs)),
|
||||||
|
});
|
||||||
|
// createProject 不接受的默认字段(自动放行 / 预算)经 patchProject 套用
|
||||||
|
return projectOut(store.patchProject(created.id, {
|
||||||
|
autoApprovePlan: Boolean(pick('autoApprovePlan', s.autoApprovePlan)),
|
||||||
|
autoApproveExec: Boolean(pick('autoApproveExec', s.autoApproveExec)),
|
||||||
|
budgetUsd: ((): number | null => { const v = pick<number | null>('budgetUsd', s.budgetUsd); return v === null ? null : Number(v); })(),
|
||||||
|
budgetPeriod: pick('budgetPeriod', s.budgetPeriod) as 'day' | 'month',
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,6 +123,23 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
|||||||
return store.reorderProjects(b.order.map(String)).map(projectOut);
|
return store.reorderProjects(b.order.map(String)).map(projectOut);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- 用户级全局默认配置(per-user,新建项目默认值来源)----------
|
||||||
|
app.get('/api/settings', () => store.getSettings());
|
||||||
|
app.put('/api/settings', (req) => {
|
||||||
|
const b = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const patch: Partial<UserSettings> = {};
|
||||||
|
if (b.autonomy !== undefined) patch.autonomy = b.autonomy as Autonomy;
|
||||||
|
if (b.concurrency !== undefined) patch.concurrency = Number(b.concurrency);
|
||||||
|
if (b.maxRetries !== undefined) patch.maxRetries = Number(b.maxRetries);
|
||||||
|
if (b.timeoutMs !== undefined) patch.timeoutMs = Number(b.timeoutMs);
|
||||||
|
if (b.autoApprovePlan !== undefined) patch.autoApprovePlan = Boolean(b.autoApprovePlan);
|
||||||
|
if (b.autoApproveExec !== undefined) patch.autoApproveExec = Boolean(b.autoApproveExec);
|
||||||
|
if (b.budgetUsd !== undefined) patch.budgetUsd = b.budgetUsd === null || b.budgetUsd === '' ? null : Number(b.budgetUsd);
|
||||||
|
if (b.budgetPeriod !== undefined) patch.budgetPeriod = b.budgetPeriod as 'day' | 'month';
|
||||||
|
if (b.model !== undefined) patch.model = b.model === null || b.model === '' ? null : String(b.model);
|
||||||
|
return store.putSettings(patch);
|
||||||
|
});
|
||||||
|
|
||||||
// 彻底删除项目(连带 tasks/runs/approvals/events,不可恢复)
|
// 彻底删除项目(连带 tasks/runs/approvals/events,不可恢复)
|
||||||
app.delete('/api/projects/:id', (req) => {
|
app.delete('/api/projects/:id', (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
export { Store, StoreError } from './store.js';
|
export { Store, StoreError } from './store.js';
|
||||||
export type { CreateProjectInput, CreateTaskInput, PatchProjectInput, PatchTaskInput, ActiveRun } from './store.js';
|
export type { CreateProjectInput, CreateTaskInput, PatchProjectInput, PatchTaskInput, ActiveRun, UserSettings } from './store.js';
|
||||||
export type { Metrics, DurationStats, RunStats, ReviewStats, RetryStats, ModelUsage } from '../model/metrics.js';
|
export type { Metrics, DurationStats, RunStats, ReviewStats, RetryStats, ModelUsage } from '../model/metrics.js';
|
||||||
export { openDb } from './db.js';
|
export { openDb } from './db.js';
|
||||||
export type { DB } from './db.js';
|
export type { DB } from './db.js';
|
||||||
|
|||||||
@@ -94,3 +94,10 @@ CREATE TABLE IF NOT EXISTS events (
|
|||||||
at TEXT NOT NULL
|
at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_project ON events(project_id, at);
|
CREATE INDEX IF NOT EXISTS idx_events_project ON events(project_id, at);
|
||||||
|
|
||||||
|
-- 用户级配置(per-user):新建项目默认值的单一来源。单本地用户 'local',多用户前向兼容。
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL DEFAULT '{}', -- 核心策略子集 JSON(autonomy/concurrency/max_retries/timeout_ms/auto_approve_*/budget_*/model)
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ import {
|
|||||||
} from '../model/status.js';
|
} from '../model/status.js';
|
||||||
|
|
||||||
const now = (): string => new Date().toISOString();
|
const now = (): string => new Date().toISOString();
|
||||||
|
|
||||||
|
/** 用户级全局默认配置(新建项目默认值的单一来源)。核心策略子集。 */
|
||||||
|
export interface UserSettings {
|
||||||
|
autonomy: Autonomy;
|
||||||
|
concurrency: number;
|
||||||
|
maxRetries: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
autoApprovePlan: boolean;
|
||||||
|
autoApproveExec: boolean;
|
||||||
|
budgetUsd: number | null;
|
||||||
|
budgetPeriod: 'day' | 'month';
|
||||||
|
model: string | null;
|
||||||
|
}
|
||||||
|
const SETTINGS_DEFAULT: UserSettings = {
|
||||||
|
autonomy: 'manual', concurrency: 1, maxRetries: 2, timeoutMs: 1_800_000,
|
||||||
|
autoApprovePlan: false, autoApproveExec: false,
|
||||||
|
budgetUsd: null, budgetPeriod: 'month', model: null,
|
||||||
|
};
|
||||||
const id = (prefix: string): string => `${prefix}_${nanoid(12)}`;
|
const id = (prefix: string): string => `${prefix}_${nanoid(12)}`;
|
||||||
|
|
||||||
/** 预算周期起点(UTC):day=今日零点 / month=当月 1 号零点。与存储的 ISO 时间戳同基准比较。 */
|
/** 预算周期起点(UTC):day=今日零点 / month=当月 1 号零点。与存储的 ISO 时间戳同基准比较。 */
|
||||||
@@ -172,6 +190,29 @@ export class Store {
|
|||||||
return rowToProject(row);
|
return rowToProject(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Settings(用户级全局默认配置)----------
|
||||||
|
/** 取用户配置(无行→内置默认;与默认 merge 保证字段齐全)。 */
|
||||||
|
getSettings(userId = 'local'): UserSettings {
|
||||||
|
const row = this.db.prepare(`SELECT data FROM settings WHERE user_id = ?`).get(userId) as { data: string } | undefined;
|
||||||
|
let saved: Partial<UserSettings> = {};
|
||||||
|
if (row) { try { saved = JSON.parse(row.data) as Partial<UserSettings>; } catch { saved = {}; } }
|
||||||
|
return { ...SETTINGS_DEFAULT, ...saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写用户配置(upsert,仅白名单字段,缺省字段保持原值/默认)。 */
|
||||||
|
putSettings(data: Partial<UserSettings>, userId = 'local'): UserSettings {
|
||||||
|
const merged = { ...this.getSettings(userId), ...data };
|
||||||
|
const clean = {} as UserSettings;
|
||||||
|
for (const k of Object.keys(SETTINGS_DEFAULT) as (keyof UserSettings)[]) {
|
||||||
|
(clean[k] as unknown) = merged[k];
|
||||||
|
}
|
||||||
|
this.db.prepare(
|
||||||
|
`INSERT INTO settings (user_id, data, updated_at) VALUES (?,?,?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`,
|
||||||
|
).run(userId, JSON.stringify(clean), now());
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
listProjects(): Project[] {
|
listProjects(): Project[] {
|
||||||
const rows = this.db.prepare(`SELECT * FROM projects ORDER BY sort_order, created_at`).all() as ProjectRow[];
|
const rows = this.db.prepare(`SELECT * FROM projects ORDER BY sort_order, created_at`).all() as ProjectRow[];
|
||||||
return rows.map(rowToProject);
|
return rows.map(rowToProject);
|
||||||
|
|||||||
Reference in New Issue
Block a user