feat(models+gate): 模型按项目可配置(默认 opus-4.8) + diff 声明外文件硬闸
① 模型可配置(项目维度,默认 opus-4.8)
- 新增 projects.models(JSON,按角色 executor/planner/reviewer/conflict
覆盖,值可为字符串=全复杂度统一 或 {easy,medium,hard} 分档)
- models.ts 重构 resolveModel:优先级 项目级 models > 旧 project.model
(仅 executor/planner) > env > 默认 DEFAULT_MODEL(opus-4.8)
- 取消内置 fable/sonnet 分档默认:所有角色默认 opus-4.8(彻底回避 fable-5
不可用问题,需要时项目级显式配置即可);回退链改 opus→sonnet→fable
- API PATCH /projects 透传 models;sanitizeModels 落库校验
② diff 声明外文件闸(task.scopeFiles)
- 新增 tasks.scope_files(JSON glob/路径数组)
- checks.ts: globToRegExp/matchesAnyGlob + scopeFileGate(改动文件越界=硬闸,
空声明跳过,git 出错不拦截);pipeline runApproveGates 接入
- planner 拆解新增每子任务 files 字段:prompt 要求 + parseDecompose 解析 +
ingest 落 scopeFiles,自动填充声明范围
- executor prompt 注入「声明文件范围约束」,让 agent 知边界(gate 才公平)
迁移:projects.models / tasks.scope_files 走 ensureColumn 幂等迁移(旧库补列)
测试:models 默认/配置/优先级、scope glob/gate、planner files 解析、
store 持久化往返、迁移补列 —— 237 通过
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,8 @@ export function openDb(file: string): Database.Database {
|
||||
ensureColumn(db, 'projects', 'auto_approve_plan', 'auto_approve_plan INTEGER NOT NULL DEFAULT 0'); // 拆解全 easy 时跳过 plan_review
|
||||
ensureColumn(db, 'projects', 'auto_approve_exec', 'auto_approve_exec INTEGER NOT NULL DEFAULT 0'); // 双复审 approve 后跳过 exec_review 自动合并
|
||||
ensureColumn(db, 'projects', 'agent_rules', 'agent_rules TEXT'); // L2 项目级 agent 执行规范(注入 worker prompt)
|
||||
ensureColumn(db, 'projects', 'models', 'models TEXT'); // 项目级可配置模型 JSON(按角色/复杂度覆盖默认 opus-4.8)
|
||||
ensureColumn(db, 'tasks', 'scope_files', 'scope_files TEXT'); // 声明的改动文件范围 JSON(diff 越界硬闸)
|
||||
const schema = readFileSync(join(HERE, 'schema.sql'), 'utf8');
|
||||
db.exec(schema);
|
||||
return db;
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface ProjectRow {
|
||||
checks: string | null; auto_approve_plan: number; auto_approve_exec: number;
|
||||
budget_usd: number | null; budget_period: string | null;
|
||||
agent_rules: string | null;
|
||||
models: string | null;
|
||||
}
|
||||
export interface TaskRow {
|
||||
id: string; project_id: string; parent_id: string | null; depth: number;
|
||||
@@ -23,6 +24,7 @@ export interface TaskRow {
|
||||
created_at: string; updated_at: string;
|
||||
source_ref: string | null;
|
||||
attachments: string | null;
|
||||
scope_files: string | null;
|
||||
last_run_error?: string | null; // pendingApprovals 扩展字段(subquery)
|
||||
}
|
||||
export interface ApprovalRow {
|
||||
@@ -53,9 +55,25 @@ export function rowToProject(r: ProjectRow): Project {
|
||||
budgetUsd: r.budget_usd ?? null,
|
||||
budgetPeriod: (r.budget_period === 'day' ? 'day' : 'month'),
|
||||
agentRules: r.agent_rules ?? null,
|
||||
models: parseModels(r.models),
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析 projects.models(JSON);非法/空 → null。仅取 4 个已知角色键。 */
|
||||
function parseModels(json: string | null): import('../model/types.js').ProjectModels | null {
|
||||
if (!json?.trim()) return null;
|
||||
try {
|
||||
const obj = JSON.parse(json) as Record<string, unknown>;
|
||||
const out: import('../model/types.js').ProjectModels = {};
|
||||
for (const role of ['executor', 'planner', 'reviewer', 'conflict'] as const) {
|
||||
const v = obj[role];
|
||||
if (typeof v === 'string' && v.trim()) out[role] = v.trim();
|
||||
else if (v && typeof v === 'object') out[role] = v as { easy?: string; medium?: string; hard?: string };
|
||||
}
|
||||
return Object.keys(out).length ? out : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** 旧 result JSON 兜底:summary/verdict/securitySummary/securityVerdict 是后加字段,老数据缺省补 null */
|
||||
function parseResult(json: string): TaskResult {
|
||||
const raw = JSON.parse(json) as Partial<TaskResult>;
|
||||
@@ -79,6 +97,7 @@ export function rowToTask(r: TaskRow, approvals: ApprovalRecord[] = []): Task {
|
||||
id: r.id, projectId: r.project_id, parentId: r.parent_id, depth: r.depth,
|
||||
title: r.title, complexity: r.complexity as Complexity, status: r.status as TaskStatus,
|
||||
priority: r.priority, deps: JSON.parse(r.deps) as string[],
|
||||
scopeFiles: parseScopeFiles(r.scope_files),
|
||||
plan: r.plan, spec: r.spec, operations: r.operations,
|
||||
approvals, result: r.result ? parseResult(r.result) : null,
|
||||
assignee: r.assignee as Task['assignee'], retryBaseline: r.retry_baseline ?? 0,
|
||||
@@ -89,6 +108,17 @@ export function rowToTask(r: TaskRow, approvals: ApprovalRecord[] = []): Task {
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析 tasks.scope_files(JSON 字符串数组);非法/空 → null。 */
|
||||
function parseScopeFiles(json: string | null): string[] | null {
|
||||
if (!json?.trim()) return null;
|
||||
try {
|
||||
const arr = JSON.parse(json) as unknown;
|
||||
if (!Array.isArray(arr)) return null;
|
||||
const out = arr.filter((x): x is string => typeof x === 'string' && x.trim().length > 0).map((s) => s.trim());
|
||||
return out.length ? out : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function rowToApproval(r: ApprovalRow): ApprovalRecord {
|
||||
return {
|
||||
gate: r.gate as GateKind, action: r.action as ApprovalRecord['action'],
|
||||
|
||||
@@ -23,7 +23,8 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
auto_approve_exec INTEGER NOT NULL DEFAULT 0, -- 双复审 approve 后跳过 exec_review(0=关)
|
||||
budget_usd REAL, -- 项目当期预算上限 USD(null=不限)
|
||||
budget_period TEXT NOT NULL DEFAULT 'month', -- 预算周期 day | month
|
||||
agent_rules TEXT -- L2 项目级 agent 执行规范(注入 worker prompt)
|
||||
agent_rules TEXT, -- L2 项目级 agent 执行规范(注入 worker prompt)
|
||||
models TEXT -- 项目级可配置模型 JSON(按角色/复杂度覆盖默认 opus-4.8)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
@@ -46,7 +47,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
source_ref TEXT, -- 旧 todo 来源标识(todo:17 / todo:17/1A),项目内唯一
|
||||
attachments TEXT -- 附件 JSON [{name,type,path}](图片/文件随任务提交)
|
||||
attachments TEXT, -- 附件 JSON [{name,type,path}](图片/文件随任务提交)
|
||||
scope_files TEXT -- 声明的改动文件范围 JSON(glob/路径);diff 越界=硬闸
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
|
||||
|
||||
+44
-4
@@ -4,7 +4,7 @@ import {
|
||||
rowToProject, rowToTask, rowToApproval, rowToRun, rowToEvent,
|
||||
type ProjectRow, type TaskRow, type ApprovalRow, type RunRow, type EventRow,
|
||||
} from './mappers.js';
|
||||
import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, EventType, Attachment } from '../model/types.js';
|
||||
import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, EventType, Attachment, ProjectModels } from '../model/types.js';
|
||||
import { DEFAULT_MAX_DEPTH, HARD_MAX_DEPTH } from '../model/types.js';
|
||||
import type { Complexity } from '../model/complexity.js';
|
||||
import { rankByScore } from '../model/scoring.js';
|
||||
@@ -31,6 +31,33 @@ function periodStartISO(period: 'day' | 'month'): string {
|
||||
|
||||
export class StoreError extends Error {}
|
||||
|
||||
const MODEL_ROLES = ['executor', 'planner', 'reviewer', 'conflict'] as const;
|
||||
/** 规范化项目级模型配置:仅保留 4 个已知角色,值为非空字符串或 {easy/medium/hard} 子集;非法值丢弃。 */
|
||||
function sanitizeModels(input: ProjectModels): ProjectModels {
|
||||
const out: ProjectModels = {};
|
||||
for (const role of MODEL_ROLES) {
|
||||
const v = (input as Record<string, unknown>)[role];
|
||||
if (typeof v === 'string') {
|
||||
if (v.trim()) out[role] = v.trim();
|
||||
} else if (v && typeof v === 'object') {
|
||||
const tier: { easy?: string; medium?: string; hard?: string } = {};
|
||||
for (const c of ['easy', 'medium', 'hard'] as const) {
|
||||
const m = (v as Record<string, unknown>)[c];
|
||||
if (typeof m === 'string' && m.trim()) tier[c] = m.trim();
|
||||
}
|
||||
if (Object.keys(tier).length) out[role] = tier;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 规范化声明文件范围:去空白/空项;空数组或非数组 → null(落库存 JSON 文本或 null)。 */
|
||||
function normalizeScopeFiles(input: string[] | null | undefined): string | null {
|
||||
if (!Array.isArray(input)) return null;
|
||||
const out = input.filter((s): s is string => typeof s === 'string' && s.trim().length > 0).map((s) => s.trim());
|
||||
return out.length ? JSON.stringify(out) : null;
|
||||
}
|
||||
|
||||
export interface CreateProjectInput {
|
||||
name: string; repoPath: string; defaultBranch?: string; verifyCmd?: string | null;
|
||||
autonomy?: Autonomy; model?: string | null; concurrency?: number;
|
||||
@@ -39,6 +66,7 @@ export interface CreateProjectInput {
|
||||
export interface CreateTaskInput {
|
||||
projectId: string; title: string; complexity: Complexity;
|
||||
parentId?: string | null; priority?: number; deps?: string[];
|
||||
scopeFiles?: string[] | null;
|
||||
}
|
||||
export interface PatchProjectInput {
|
||||
autonomy?: Autonomy; concurrency?: number; verifyCmd?: string | null;
|
||||
@@ -47,9 +75,11 @@ export interface PatchProjectInput {
|
||||
budgetUsd?: number | null; budgetPeriod?: 'day' | 'month';
|
||||
autoApprovePlan?: boolean; autoApproveExec?: boolean; checks?: string | null;
|
||||
agentRules?: string | null;
|
||||
models?: ProjectModels | null;
|
||||
}
|
||||
export interface PatchTaskInput {
|
||||
title?: string; priority?: number; complexity?: Complexity; deps?: string[];
|
||||
scopeFiles?: string[] | null;
|
||||
}
|
||||
/** 成本明细(按项目 / 按模型 / 总计),供 GET /api/usage 与预算判定 */
|
||||
export interface CostSummary {
|
||||
@@ -132,7 +162,7 @@ export class Store {
|
||||
status: 'active', created_at: now(),
|
||||
last_sync_at: null, logo: null, sort_order: maxOrder + 1,
|
||||
checks: null, auto_approve_plan: 0, auto_approve_exec: 0,
|
||||
budget_usd: null, budget_period: 'month', agent_rules: null,
|
||||
budget_usd: null, budget_period: 'month', agent_rules: null, models: null,
|
||||
};
|
||||
this.db.prepare(
|
||||
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,max_retries,timeout_ms,status,created_at,last_sync_at,logo,sort_order,checks,auto_approve_plan,auto_approve_exec)
|
||||
@@ -240,6 +270,10 @@ export class Store {
|
||||
}
|
||||
if (patch.checks !== undefined) { sets.push('checks = @checks'); args.checks = patch.checks; }
|
||||
if (patch.agentRules !== undefined) { sets.push('agent_rules = @agent_rules'); args.agent_rules = patch.agentRules; }
|
||||
if (patch.models !== undefined) {
|
||||
sets.push('models = @models');
|
||||
args.models = patch.models == null ? null : JSON.stringify(sanitizeModels(patch.models));
|
||||
}
|
||||
|
||||
if (sets.length > 0) {
|
||||
this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = @id`).run(args);
|
||||
@@ -288,10 +322,11 @@ export class Store {
|
||||
deps: JSON.stringify(input.deps ?? []), plan: null, spec: null, operations: null,
|
||||
result: null, assignee: null, retry_baseline: 0, next_eligible_at: null,
|
||||
created_at: now(), updated_at: now(), source_ref: null, attachments: null,
|
||||
scope_files: normalizeScopeFiles(input.scopeFiles),
|
||||
};
|
||||
this.db.prepare(
|
||||
`INSERT INTO tasks (id,project_id,parent_id,depth,title,complexity,status,priority,deps,plan,spec,operations,result,assignee,retry_baseline,created_at,updated_at,source_ref)
|
||||
VALUES (@id,@project_id,@parent_id,@depth,@title,@complexity,@status,@priority,@deps,@plan,@spec,@operations,@result,@assignee,@retry_baseline,@created_at,@updated_at,@source_ref)`,
|
||||
`INSERT INTO tasks (id,project_id,parent_id,depth,title,complexity,status,priority,deps,plan,spec,operations,result,assignee,retry_baseline,created_at,updated_at,source_ref,scope_files)
|
||||
VALUES (@id,@project_id,@parent_id,@depth,@title,@complexity,@status,@priority,@deps,@plan,@spec,@operations,@result,@assignee,@retry_baseline,@created_at,@updated_at,@source_ref,@scope_files)`,
|
||||
).run(row);
|
||||
this.emit(input.projectId, row.id, 'task.created', { title: row.title, complexity: row.complexity, status });
|
||||
return rowToTask(row);
|
||||
@@ -477,6 +512,11 @@ export class Store {
|
||||
this.db.prepare(`UPDATE tasks SET priority = ?, updated_at = ? WHERE id = ?`).run(patch.priority, now(), taskId);
|
||||
fields.push('priority');
|
||||
}
|
||||
if (patch.scopeFiles !== undefined) {
|
||||
this.db.prepare(`UPDATE tasks SET scope_files = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(normalizeScopeFiles(patch.scopeFiles), now(), taskId);
|
||||
fields.push('scopeFiles');
|
||||
}
|
||||
|
||||
let statusChange: { from: TaskStatus; to: TaskStatus } | null = null;
|
||||
if (patch.complexity !== undefined && patch.complexity !== row.complexity) {
|
||||
|
||||
Reference in New Issue
Block a user