Commit e696cd489d05e0c12bb89983374bf5397c107cf2
1 parent
1f7cec11
U-030: 并发/预算/日志保留配置页(PH3 收尾)
三个都是 config_id='default' 单例配置,首次使用即创建、之后原地更新 (upsert),不需要单独的初始化流程: - c_concurrency_config_update:新增 src/settings_config.py( org_concurrency_config 之前完全没人碰过),校验 max_concurrent_goals 落在 1-50。 - c_budget_config_update:budget_guardrail.py 新增 upsert_budget_config() ——跟 U-019 已有的 update_budget_limit_and_resume()(只更新已存在的 行)不是一回事,这个是真正完整的设置页控制器,支持首次创建,沿用 同一套"额度调高后自动恢复 budget_exceeded 阻塞任务"副作用(提取成 _resume_budget_blocked_tasks() 共享)。 - c_log_retention_config_update:同样在 settings_config.py 里,校验 retention_days > 0。 三处校验都在业务层做完整检查,HTTP 层额外兜了 except (InvalidParamError, TypeError)——payload 数值字段缺失时 None<=0 这类比较会抛 TypeError 而 不是业务异常,两个都要接住才能保证返回干净的 400 而不是裸 500。 backend_server.py 第一次加了 do_PUT。 前端三个页面共享一个 renderSettingsSubnav() 小组件模拟 02-V 原型里的 设置下拉菜单。真实服务器端到端验证过三个 PUT/GET 的完整读写循环。 PH3(人机交互控制台)至此全部完成:U-022~U-030。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
11 changed files
with
669 additions
and
23 deletions
docs/development-plan.md
| ... | ... | @@ -275,6 +275,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 275 | 275 | |
| 276 | 276 | - **U-030 [Value Path]** `v_concurrency_settings` + `v_budget_settings` + `v_log_retention_settings` |
| 277 | 277 | - 依赖:U-019(预算配置)、U-021 涉及的 `log_retention_config`(U-001 已建表,此处只是配置页 UI) |
| 278 | + - Done:三个配置页可用且改动生效(原文档缺 Done/覆盖行,2026-07-31 补上) | |
| 279 | + - **实现补充(2026-07-31)**:三个都是 `config_id='default'` 单例配置,首次使用即创建、之后原地更新(upsert),不需要单独的"初始化"流程。① `c_concurrency_config_update`:新增 `src/settings_config.py`(`org_concurrency_config` 之前完全没人碰过),校验 `max_concurrent_goals` 落在 1-50;② `c_budget_config_update`:`budget_guardrail.py` 里新增 `upsert_budget_config()`,跟 U-019 已有的 `update_budget_limit_and_resume()`(只更新已存在的行,不建新的)不是一回事——`upsert_budget_config()` 是真正完整的设置页控制器,支持首次创建,而且沿用了同一套"额度调高后自动恢复 `budget_exceeded` 阻塞任务"的副作用(04-P §4.4),复用了同一段恢复逻辑(提取成 `_resume_budget_blocked_tasks()` 共享);③ `c_log_retention_config_update`:同样在 `settings_config.py` 里,校验 `retention_days > 0`。三处的数值/枚举校验都在业务层做完整检查(而不是让请求体里的 `None`/超范围值直接怼到 SQL 层,被动等 `CHECK` 约束或 `TypeError` 崩掉),HTTP 层额外兜了一层 `except (InvalidParamError, TypeError)`——payload 里数值字段缺失时 `None <= 0` 这类比较会抛 `TypeError` 而不是业务异常,两个都要接住才能保证返回干净的 400 而不是裸 500。backend_server.py 第一次加了 `do_PUT`(之前只有 GET/POST)。前端三个页面共享一个 `renderSettingsSubnav()` 小组件模拟 02-V 原型里的"设置"下拉菜单(这个控制台的单层 `#nav` 顶栏放不下嵌套下拉,属于纯 UI 简化,不影响信息结构)。真实服务器端到端验证过三个 PUT/GET 的完整读写循环。 | |
| 280 | + - 覆盖:02-V `v_concurrency_settings`/`v_budget_settings`/`v_log_retention_settings`;03-C `c_concurrency_config_update`/`c_budget_config_update`/`c_log_retention_config_update` | |
| 278 | 281 | |
| 279 | 282 | --- |
| 280 | 283 | ... | ... |
docs/sdd/unit_matrix.json
| ... | ... | @@ -45,7 +45,7 @@ |
| 45 | 45 | {"id": "U-027", "phase": "PH3", "sub_phase": "PH3-S3", "name": "execution_log_file + 日志查看器(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": ["execution_log_file", "log_retention_config"], "view": ["v_log_viewer"], "controller": ["c_execution_log_get", "c_ws_log_tail"], "process": ["p_log_retention_cleanup"], "requirement": ["REQ-13"]}, "done": "REQ-13 happy/boundary/failure 通过;补上此前从未存在的 append_log 写路径;顺带修了 U-024 就存在的 server_close() 并发 segfault 隐患(conn_lock 未覆盖)"}, |
| 46 | 46 | {"id": "U-028", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_artifact_browser(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-011"], "coverage": {"model": [], "view": ["v_artifact_browser"], "controller": ["c_artifact_get", "c_artifact_list"], "process": [], "requirement": []}, "done": "Artifact 列表+预览+血缘链可用;预览为纯文本,不做 Markdown 渲染/代码高亮(避免引入前端第三方依赖)"}, |
| 47 | 47 | {"id": "U-029", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_skill_library + v_skill_audit_log(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-018"], "coverage": {"model": [], "view": ["v_skill_library", "v_skill_audit_log"], "controller": ["c_skill_diff", "c_skill_list", "c_skill_get", "c_skill_rollback"], "process": [], "requirement": []}, "done": "Skill 库与变更审计日志可查看;c_skill_rollback 首次接上 HTTP(skill_repo_path 可注入,测试绝不碰真实仓库)"}, |
| 48 | - {"id": "U-030", "phase": "PH3", "sub_phase": "PH3-S4", "name": "并发/预算/日志保留配置页", "classification": "Value Path", "depends_on": ["U-019", "U-021"], "coverage": {"model": ["org_concurrency_config"], "view": ["v_concurrency_settings", "v_budget_settings", "v_log_retention_settings"], "controller": ["c_concurrency_config_update", "c_budget_config_update", "c_log_retention_config_update"], "process": [], "requirement": []}, "done": "三个配置页可用且改动生效"}, | |
| 48 | + {"id": "U-030", "phase": "PH3", "sub_phase": "PH3-S4", "name": "并发/预算/日志保留配置页(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-019", "U-021"], "coverage": {"model": ["org_concurrency_config"], "view": ["v_concurrency_settings", "v_budget_settings", "v_log_retention_settings"], "controller": ["c_concurrency_config_update", "c_budget_config_update", "c_log_retention_config_update"], "process": [], "requirement": []}, "done": "三个配置页可用且改动生效;PH3 至此全部完成"}, | |
| 49 | 49 | |
| 50 | 50 | {"id": "U-031", "phase": "PH4", "sub_phase": "PH4-S1", "name": "REQ-01~14 全量自动化回归", "classification": "Risk Gate", "depends_on": ["U-014", "U-017", "U-020", "U-021", "U-022", "U-023", "U-024.5", "U-025", "U-026", "U-027", "U-028", "U-029", "U-030"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-01", "REQ-02", "REQ-03", "REQ-04", "REQ-05", "REQ-06", "REQ-07", "REQ-08", "REQ-09", "REQ-10", "REQ-11", "REQ-12", "REQ-13", "REQ-14"]}, "done": "06 节全部 14 条 REQ 的全部用例通过一次完整回归"}, |
| 51 | 51 | {"id": "U-032", "phase": "PH4", "sub_phase": "PH4-S2", "name": "多 Goal 并发 + 长时间运行稳定性压测", "classification": "Risk Gate", "depends_on": ["U-031"], "coverage": {"model": ["org_concurrency_config"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "并发数调高后多个独立 Goal 可同时稳定运行"}, | ... | ... |
frontend/app.js
| ... | ... | @@ -79,7 +79,9 @@ const routes = { |
| 79 | 79 | "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard }, |
| 80 | 80 | "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser }, |
| 81 | 81 | "#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary }, |
| 82 | - "#/settings": { render: () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>" }, | |
| 82 | + "#/settings/concurrency": { render: renderConcurrencySettings, mount: mountConcurrencySettings }, | |
| 83 | + "#/settings/budget": { render: renderBudgetSettings, mount: mountBudgetSettings }, | |
| 84 | + "#/settings/log-retention": { render: renderLogRetentionSettings, mount: mountLogRetentionSettings }, | |
| 83 | 85 | "#/setup": { render: renderProjectSetup, mount: mountProjectSetup }, |
| 84 | 86 | "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer }, |
| 85 | 87 | "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback }, |
| ... | ... | @@ -1216,3 +1218,215 @@ function mountSkillAuditLog(skillId) { |
| 1216 | 1218 | |
| 1217 | 1219 | refresh(); |
| 1218 | 1220 | } |
| 1221 | + | |
| 1222 | +// ---- v_concurrency_settings / v_budget_settings / v_log_retention_settings | |
| 1223 | +// (02-V §2.11/§2.12/§2.13) -- three small config pages, none of them | |
| 1224 | +// project-scoped (all three tables are platform-wide singletons, config_id | |
| 1225 | +// ='default'), so like the Skill pages they skip the getCurrentProjectId() | |
| 1226 | +// guard. renderSettingsSubnav() stands in for 02-V's nav-dropdown, which | |
| 1227 | +// this shell's single-level #nav bar doesn't have room for. | |
| 1228 | + | |
| 1229 | +function renderSettingsSubnav(active) { | |
| 1230 | + const items = [["concurrency", "并发数"], ["budget", "预算护栏"], ["log-retention", "日志保留"]]; | |
| 1231 | + return '<div class="filters">' + items.map(([key, label]) => | |
| 1232 | + '<a class="btn' + (key === active ? " btn-primary" : "") + '" href="#/settings/' + key + '">' + label + "</a>" | |
| 1233 | + ).join("") + "</div>"; | |
| 1234 | +} | |
| 1235 | + | |
| 1236 | +function renderConcurrencySettings() { | |
| 1237 | + return ` | |
| 1238 | + <div class="page"> | |
| 1239 | + <h1>并发数配置</h1> | |
| 1240 | + ${renderSettingsSubnav("concurrency")} | |
| 1241 | + <div id="cs_error" class="error-banner" style="display:none;"></div> | |
| 1242 | + <div id="cs_success" class="success-banner" style="display:none;"></div> | |
| 1243 | + <div class="panel"> | |
| 1244 | + <div class="field"> | |
| 1245 | + <label>最大并发 Goal 数(max_concurrent_goals,1-50)</label> | |
| 1246 | + <input type="number" id="cs_max_concurrent_goals" min="1" max="50" value="1"> | |
| 1247 | + <div class="hint">调大后下一个新 Goal 起生效</div> | |
| 1248 | + </div> | |
| 1249 | + <button class="btn btn-primary" id="btn_save_concurrency" type="button">保存</button> | |
| 1250 | + </div> | |
| 1251 | + </div> | |
| 1252 | + `; | |
| 1253 | +} | |
| 1254 | + | |
| 1255 | +function mountConcurrencySettings() { | |
| 1256 | + const input = document.getElementById("cs_max_concurrent_goals"); | |
| 1257 | + const errorEl = document.getElementById("cs_error"); | |
| 1258 | + const successEl = document.getElementById("cs_success"); | |
| 1259 | + | |
| 1260 | + fetch("/api/settings/concurrency").then((r) => r.json()).then((config) => { | |
| 1261 | + if (config) input.value = config.max_concurrent_goals; | |
| 1262 | + }); | |
| 1263 | + | |
| 1264 | + document.getElementById("btn_save_concurrency").addEventListener("click", async () => { | |
| 1265 | + errorEl.style.display = "none"; | |
| 1266 | + successEl.style.display = "none"; | |
| 1267 | + const resp = await fetch("/api/settings/concurrency", { | |
| 1268 | + method: "PUT", | |
| 1269 | + body: JSON.stringify({ max_concurrent_goals: Number(input.value) }), | |
| 1270 | + }); | |
| 1271 | + const body = await resp.json(); | |
| 1272 | + if (resp.status !== 200) { | |
| 1273 | + errorEl.textContent = body.error + (body.detail ? ":" + body.detail : ""); | |
| 1274 | + errorEl.style.display = "block"; | |
| 1275 | + return; | |
| 1276 | + } | |
| 1277 | + successEl.textContent = "已更新,下一个新 Goal 起生效"; | |
| 1278 | + successEl.style.display = "block"; | |
| 1279 | + }); | |
| 1280 | +} | |
| 1281 | + | |
| 1282 | +function renderBudgetSettings() { | |
| 1283 | + return ` | |
| 1284 | + <div class="page"> | |
| 1285 | + <h1>预算护栏配置</h1> | |
| 1286 | + ${renderSettingsSubnav("budget")} | |
| 1287 | + <div class="page-sub">人工配置层,与 Claude Code plan 额度耗尽是两套独立机制</div> | |
| 1288 | + <div id="bs_error" class="error-banner" style="display:none;"></div> | |
| 1289 | + <div id="bs_success" class="success-banner" style="display:none;"></div> | |
| 1290 | + <div class="panel"> | |
| 1291 | + <div class="field"> | |
| 1292 | + <label>作用范围(scope)</label> | |
| 1293 | + <select id="bs_scope"> | |
| 1294 | + <option value="global">global</option> | |
| 1295 | + <option value="per_task">per_task</option> | |
| 1296 | + <option value="per_day">per_day</option> | |
| 1297 | + </select> | |
| 1298 | + </div> | |
| 1299 | + <div class="field"> | |
| 1300 | + <label>限额类型(limit_type)</label> | |
| 1301 | + <select id="bs_limit_type"> | |
| 1302 | + <option value="token_count">token_count</option> | |
| 1303 | + <option value="call_count">call_count</option> | |
| 1304 | + </select> | |
| 1305 | + </div> | |
| 1306 | + <div class="field"> | |
| 1307 | + <label>限额值(limit_value)</label> | |
| 1308 | + <input type="number" id="bs_limit_value" value="100000"> | |
| 1309 | + </div> | |
| 1310 | + <div id="bs_usage" class="hint"></div> | |
| 1311 | + <div class="progress"><div class="fill" id="bs_usage_fill" style="width:0%"></div></div> | |
| 1312 | + <div id="bs_alert" class="alert-banner" style="display:none; margin-top:14px;"></div> | |
| 1313 | + <button class="btn btn-primary" id="btn_save_budget" type="button" style="margin-top:16px;">保存</button> | |
| 1314 | + </div> | |
| 1315 | + </div> | |
| 1316 | + `; | |
| 1317 | +} | |
| 1318 | + | |
| 1319 | +function mountBudgetSettings() { | |
| 1320 | + const scopeEl = document.getElementById("bs_scope"); | |
| 1321 | + const limitTypeEl = document.getElementById("bs_limit_type"); | |
| 1322 | + const limitValueEl = document.getElementById("bs_limit_value"); | |
| 1323 | + const usageEl = document.getElementById("bs_usage"); | |
| 1324 | + const usageFillEl = document.getElementById("bs_usage_fill"); | |
| 1325 | + const alertEl = document.getElementById("bs_alert"); | |
| 1326 | + const errorEl = document.getElementById("bs_error"); | |
| 1327 | + const successEl = document.getElementById("bs_success"); | |
| 1328 | + | |
| 1329 | + async function refresh() { | |
| 1330 | + const configs = await fetch("/api/settings/budget").then((r) => r.json()); | |
| 1331 | + const config = configs[0]; | |
| 1332 | + if (config) { | |
| 1333 | + scopeEl.value = config.scope; | |
| 1334 | + limitTypeEl.value = config.limit_type; | |
| 1335 | + limitValueEl.value = config.limit_value; | |
| 1336 | + usageEl.textContent = "当前用量:" + config.current_usage.toLocaleString() + " / " + config.limit_value.toLocaleString(); | |
| 1337 | + const pct = config.limit_value > 0 ? Math.min(100, Math.round((config.current_usage / config.limit_value) * 100)) : 0; | |
| 1338 | + usageFillEl.style.width = pct + "%"; | |
| 1339 | + } else { | |
| 1340 | + usageEl.textContent = "尚未配置"; | |
| 1341 | + } | |
| 1342 | + | |
| 1343 | + const projectId = getCurrentProjectId(); | |
| 1344 | + const blocked = projectId | |
| 1345 | + ? await fetch("/api/tasks?project_id=" + encodeURIComponent(projectId) + "&status=blocked_pending_human") | |
| 1346 | + .then((r) => (r.ok ? r.json() : [])) | |
| 1347 | + : []; | |
| 1348 | + if (blocked.length > 0) { | |
| 1349 | + alertEl.innerHTML = "已有 " + blocked.length + " 个任务处于 blocked_pending_human · " + '<a href="#/tasks">查看已暂停任务</a>'; | |
| 1350 | + alertEl.style.display = "flex"; | |
| 1351 | + } else { | |
| 1352 | + alertEl.style.display = "none"; | |
| 1353 | + } | |
| 1354 | + } | |
| 1355 | + | |
| 1356 | + document.getElementById("btn_save_budget").addEventListener("click", async () => { | |
| 1357 | + errorEl.style.display = "none"; | |
| 1358 | + successEl.style.display = "none"; | |
| 1359 | + const resp = await fetch("/api/settings/budget", { | |
| 1360 | + method: "PUT", | |
| 1361 | + body: JSON.stringify({ scope: scopeEl.value, limit_type: limitTypeEl.value, limit_value: Number(limitValueEl.value) }), | |
| 1362 | + }); | |
| 1363 | + const body = await resp.json(); | |
| 1364 | + if (resp.status !== 200) { | |
| 1365 | + errorEl.textContent = body.error + (body.detail ? ":" + body.detail : ""); | |
| 1366 | + errorEl.style.display = "block"; | |
| 1367 | + return; | |
| 1368 | + } | |
| 1369 | + successEl.textContent = "已更新" + (body.resumed_task_ids.length ? ",已恢复 " + body.resumed_task_ids.length + " 个因超限阻塞的任务" : ""); | |
| 1370 | + successEl.style.display = "block"; | |
| 1371 | + refresh(); | |
| 1372 | + }); | |
| 1373 | + | |
| 1374 | + refresh(); | |
| 1375 | +} | |
| 1376 | + | |
| 1377 | +function renderLogRetentionSettings() { | |
| 1378 | + return ` | |
| 1379 | + <div class="page"> | |
| 1380 | + <h1>日志保留策略配置</h1> | |
| 1381 | + ${renderSettingsSubnav("log-retention")} | |
| 1382 | + <div id="lrs_error" class="error-banner" style="display:none;"></div> | |
| 1383 | + <div id="lrs_success" class="success-banner" style="display:none;"></div> | |
| 1384 | + <div class="panel"> | |
| 1385 | + <div class="field"> | |
| 1386 | + <label>保留天数(retention_days,>0)</label> | |
| 1387 | + <input type="number" id="lrs_retention_days" value="14"> | |
| 1388 | + </div> | |
| 1389 | + <div class="field"> | |
| 1390 | + <label>总大小上限(max_total_size_mb,非必填)</label> | |
| 1391 | + <input type="number" id="lrs_max_total_size_mb" placeholder="不限制"> | |
| 1392 | + <div class="hint">超过该天数的 closed 状态日志会被清理任务标记 purged 并删除物理文件;清理任务触发频率本身不可配</div> | |
| 1393 | + </div> | |
| 1394 | + <button class="btn btn-primary" id="btn_save_log_retention" type="button">保存</button> | |
| 1395 | + </div> | |
| 1396 | + </div> | |
| 1397 | + `; | |
| 1398 | +} | |
| 1399 | + | |
| 1400 | +function mountLogRetentionSettings() { | |
| 1401 | + const daysEl = document.getElementById("lrs_retention_days"); | |
| 1402 | + const sizeEl = document.getElementById("lrs_max_total_size_mb"); | |
| 1403 | + const errorEl = document.getElementById("lrs_error"); | |
| 1404 | + const successEl = document.getElementById("lrs_success"); | |
| 1405 | + | |
| 1406 | + fetch("/api/settings/log-retention").then((r) => r.json()).then((config) => { | |
| 1407 | + if (config) { | |
| 1408 | + daysEl.value = config.retention_days; | |
| 1409 | + if (config.max_total_size_mb != null) sizeEl.value = config.max_total_size_mb; | |
| 1410 | + } | |
| 1411 | + }); | |
| 1412 | + | |
| 1413 | + document.getElementById("btn_save_log_retention").addEventListener("click", async () => { | |
| 1414 | + errorEl.style.display = "none"; | |
| 1415 | + successEl.style.display = "none"; | |
| 1416 | + const resp = await fetch("/api/settings/log-retention", { | |
| 1417 | + method: "PUT", | |
| 1418 | + body: JSON.stringify({ | |
| 1419 | + retention_days: Number(daysEl.value), | |
| 1420 | + max_total_size_mb: sizeEl.value ? Number(sizeEl.value) : undefined, | |
| 1421 | + }), | |
| 1422 | + }); | |
| 1423 | + const body = await resp.json(); | |
| 1424 | + if (resp.status !== 200) { | |
| 1425 | + errorEl.textContent = body.error + (body.detail ? ":" + body.detail : ""); | |
| 1426 | + errorEl.style.display = "block"; | |
| 1427 | + return; | |
| 1428 | + } | |
| 1429 | + successEl.textContent = "已更新"; | |
| 1430 | + successEl.style.display = "block"; | |
| 1431 | + }); | |
| 1432 | +} | ... | ... |
frontend/index.html
| ... | ... | @@ -11,7 +11,7 @@ |
| 11 | 11 | <a href="#/tasks">Tasks</a> |
| 12 | 12 | <a href="#/artifacts">Artifacts</a> |
| 13 | 13 | <a href="#/skills">Skills</a> |
| 14 | - <a href="#/settings">Settings</a> | |
| 14 | + <a href="#/settings/concurrency">Settings</a> | |
| 15 | 15 | </nav> |
| 16 | 16 | <main id="app"></main> |
| 17 | 17 | <script src="/app.js"></script> | ... | ... |
frontend/style.css
| ... | ... | @@ -15,7 +15,7 @@ body { font-family: system-ui, sans-serif; margin: 0; } |
| 15 | 15 | .panel { border: 1px solid #e5e7eb; border-radius: 10px; padding: 24px; background: #f9fafb; } |
| 16 | 16 | .field { margin-bottom: 18px; } |
| 17 | 17 | .field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; } |
| 18 | -.field input, .field textarea { width: 100%; padding: 9px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; font-family: inherit; } | |
| 18 | +.field input, .field textarea, .field select { width: 100%; padding: 9px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; font-family: inherit; } | |
| 19 | 19 | .field input[readonly] { background: #f3f4f6; color: #6b7280; } |
| 20 | 20 | .field .hint { font-size: 12px; color: #6b7280; margin-top: 5px; } |
| 21 | 21 | .btn { display: inline-block; padding: 9px 16px; border-radius: 6px; border: 1px solid #e5e7eb; background: #fff; font-size: 14px; cursor: pointer; } | ... | ... |
src/backend_server.py
| ... | ... | @@ -19,9 +19,11 @@ viewing, `c_artifact_list`/`c_artifact_get` (U-028, the former unnamed in |
| 19 | 19 | either 03-C or the MVCP JSON contract) for `v_artifact_browser`, |
| 20 | 20 | `c_skill_diff`/`c_skill_rollback` + skill/skill_iteration list/get (U-029, |
| 21 | 21 | `c_skill_diff` likewise only named in the MVCP JSON) for `v_skill_library`/ |
| 22 | -`v_skill_audit_log`, and serving the U-008 frontend shell as static files | |
| 23 | -(no build step, 05 §5.7: whitelisted by exact path, not a generic | |
| 24 | -directory walker, so there's no path-traversal surface to reason about). | |
| 22 | +`v_skill_audit_log`, `c_concurrency_config_update`/`c_budget_config_update`/ | |
| 23 | +`c_log_retention_config_update` (03-C §3.13/§3.13.1, U-030) for the three | |
| 24 | +settings pages, and serving the U-008 frontend shell as static files (no | |
| 25 | +build step, 05 §5.7: whitelisted by exact path, not a generic directory | |
| 26 | +walker, so there's no path-traversal surface to reason about). | |
| 25 | 27 | """ |
| 26 | 28 | |
| 27 | 29 | import json |
| ... | ... | @@ -36,8 +38,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| 36 | 38 | |
| 37 | 39 | from src import ( |
| 38 | 40 | artifact_query, budget_guardrail, bug_record, execution_log, |
| 39 | - org_loop_route, paths, project_scaffold, schema, skill_query, | |
| 40 | - skill_update, task_create, task_query, ws_subscribe, | |
| 41 | + org_loop_route, paths, project_scaffold, schema, settings_config, | |
| 42 | + skill_query, skill_update, task_create, task_query, ws_subscribe, | |
| 41 | 43 | ) |
| 42 | 44 | |
| 43 | 45 | _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") |
| ... | ... | @@ -148,6 +150,12 @@ class _Handler(BaseHTTPRequestHandler): |
| 148 | 150 | if parsed.path == "/api/settings/budget": |
| 149 | 151 | self._handle_budget_list() |
| 150 | 152 | return |
| 153 | + if parsed.path == "/api/settings/concurrency": | |
| 154 | + self._handle_concurrency_get() | |
| 155 | + return | |
| 156 | + if parsed.path == "/api/settings/log-retention": | |
| 157 | + self._handle_log_retention_get() | |
| 158 | + return | |
| 151 | 159 | if parsed.path.startswith(_WORK_NODES_PREFIX) and parsed.path.endswith(_LOG_SUFFIX): |
| 152 | 160 | node_exec_id = parsed.path[len(_WORK_NODES_PREFIX):-len(_LOG_SUFFIX)] |
| 153 | 161 | self._handle_execution_log_get(node_exec_id, parsed.query) |
| ... | ... | @@ -202,6 +210,16 @@ class _Handler(BaseHTTPRequestHandler): |
| 202 | 210 | else: |
| 203 | 211 | self._send_json(404, {"error": "NOT_FOUND"}) |
| 204 | 212 | |
| 213 | + def do_PUT(self): | |
| 214 | + if self.path == "/api/settings/concurrency": | |
| 215 | + self._handle_concurrency_update() | |
| 216 | + elif self.path == "/api/settings/budget": | |
| 217 | + self._handle_budget_update() | |
| 218 | + elif self.path == "/api/settings/log-retention": | |
| 219 | + self._handle_log_retention_update() | |
| 220 | + else: | |
| 221 | + self._send_json(404, {"error": "NOT_FOUND"}) | |
| 222 | + | |
| 205 | 223 | def _handle_pick_folder(self): |
| 206 | 224 | try: |
| 207 | 225 | self._send_json(200, pick_folder()) |
| ... | ... | @@ -328,6 +346,66 @@ class _Handler(BaseHTTPRequestHandler): |
| 328 | 346 | result = budget_guardrail.list_budget_configs(self.server.conn) |
| 329 | 347 | self._send_json(200, result) |
| 330 | 348 | |
| 349 | + def _handle_budget_update(self): | |
| 350 | + payload, error = self._read_json_body() | |
| 351 | + if error: | |
| 352 | + self._send_json(400, error) | |
| 353 | + return | |
| 354 | + try: | |
| 355 | + with self.server.conn_lock: | |
| 356 | + result = budget_guardrail.upsert_budget_config( | |
| 357 | + self.server.conn, payload.get("scope"), payload.get("limit_type"), | |
| 358 | + payload.get("limit_value"), payload.get("updated_by", "console"), | |
| 359 | + ) | |
| 360 | + except (budget_guardrail.InvalidParamError, TypeError) as e: | |
| 361 | + # TypeError here means a required numeric field (limit_value) was | |
| 362 | + # missing/null -- comparing None <= 0 raises TypeError, not | |
| 363 | + # InvalidParamError, before upsert_budget_config's own checks run | |
| 364 | + self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)}) | |
| 365 | + else: | |
| 366 | + self._send_json(200, result) | |
| 367 | + | |
| 368 | + def _handle_concurrency_get(self): | |
| 369 | + with self.server.conn_lock: | |
| 370 | + result = settings_config.get_concurrency_config(self.server.conn) | |
| 371 | + self._send_json(200, result) | |
| 372 | + | |
| 373 | + def _handle_concurrency_update(self): | |
| 374 | + payload, error = self._read_json_body() | |
| 375 | + if error: | |
| 376 | + self._send_json(400, error) | |
| 377 | + return | |
| 378 | + try: | |
| 379 | + with self.server.conn_lock: | |
| 380 | + result = settings_config.upsert_concurrency_config( | |
| 381 | + self.server.conn, payload.get("max_concurrent_goals"), payload.get("updated_by", "console"), | |
| 382 | + ) | |
| 383 | + except (settings_config.InvalidParamError, TypeError) as e: | |
| 384 | + self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)}) | |
| 385 | + else: | |
| 386 | + self._send_json(200, result) | |
| 387 | + | |
| 388 | + def _handle_log_retention_get(self): | |
| 389 | + with self.server.conn_lock: | |
| 390 | + result = settings_config.get_log_retention_config(self.server.conn) | |
| 391 | + self._send_json(200, result) | |
| 392 | + | |
| 393 | + def _handle_log_retention_update(self): | |
| 394 | + payload, error = self._read_json_body() | |
| 395 | + if error: | |
| 396 | + self._send_json(400, error) | |
| 397 | + return | |
| 398 | + try: | |
| 399 | + with self.server.conn_lock: | |
| 400 | + result = settings_config.upsert_log_retention_config( | |
| 401 | + self.server.conn, payload.get("retention_days"), payload.get("updated_by", "console"), | |
| 402 | + max_total_size_mb=payload.get("max_total_size_mb"), | |
| 403 | + ) | |
| 404 | + except (settings_config.InvalidParamError, TypeError) as e: | |
| 405 | + self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)}) | |
| 406 | + else: | |
| 407 | + self._send_json(200, result) | |
| 408 | + | |
| 331 | 409 | def _handle_artifact_list(self, query_string): |
| 332 | 410 | params = urllib.parse.parse_qs(query_string) |
| 333 | 411 | project_id = params.get("project_id", [None])[0] | ... | ... |
src/budget_guardrail.py
| ... | ... | @@ -13,11 +13,21 @@ import uuid |
| 13 | 13 | |
| 14 | 14 | from src import org_loop_route |
| 15 | 15 | |
| 16 | +_VALID_SCOPES = ("global", "per_task", "per_day") | |
| 17 | +_VALID_LIMIT_TYPES = ("token_count", "call_count") | |
| 18 | + | |
| 16 | 19 | |
| 17 | 20 | class BudgetExceededError(Exception): |
| 18 | 21 | """BUDGET_EXCEEDED -- 03-C §3.12.""" |
| 19 | 22 | |
| 20 | 23 | |
| 24 | +class InvalidParamError(Exception): | |
| 25 | + """INVALID_PARAM -- 03-C §3.13: limit_value<=0, or scope/limit_type not | |
| 26 | + one of budget_guardrail_config's own enum values (checked here rather | |
| 27 | + than left to surface as a raw sqlite CHECK-constraint IntegrityError -- | |
| 28 | + same reasoning as U-023's bug_record/task_id existence checks).""" | |
| 29 | + | |
| 30 | + | |
| 21 | 31 | def _write_status_event(conn, task_id, project_id, from_status, to_status): |
| 22 | 32 | conn.execute( |
| 23 | 33 | "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " |
| ... | ... | @@ -80,14 +90,24 @@ def check_budget(conn, task_id, config_id, estimated_usage): |
| 80 | 90 | return {"allowed": True, "remaining_quota": limit_value - projected} |
| 81 | 91 | |
| 82 | 92 | |
| 93 | +def _resume_budget_blocked_tasks(conn): | |
| 94 | + blocked_tasks = conn.execute( | |
| 95 | + "SELECT task_id FROM task WHERE status = 'blocked_pending_human' AND blocked_reason = 'budget_exceeded'" | |
| 96 | + ).fetchall() | |
| 97 | + resumed = [] | |
| 98 | + for (task_id,) in blocked_tasks: | |
| 99 | + org_loop_route.route_org_loop(conn, task_id, "manual_resume") | |
| 100 | + resumed.append(task_id) | |
| 101 | + return resumed | |
| 102 | + | |
| 103 | + | |
| 83 | 104 | def update_budget_limit_and_resume(conn, config_id, new_limit_value, updated_by): |
| 84 | 105 | """04-P §4.4: paused -> monitoring, guard "人工在 v_budget_settings 调高额度". |
| 85 | 106 | |
| 86 | - This is the minimal backend mechanism REQ-10's boundary case needs, not | |
| 87 | - the full settings-page controller (c_budget_config_update's complete | |
| 88 | - field set is U-030's job, PH3) -- reuses U-016's manual_resume (via | |
| 89 | - org_loop_route.route_org_loop) for the actual status restoration rather | |
| 90 | - than reimplementing it. | |
| 107 | + This is the minimal mechanism REQ-10's boundary case needs -- updates | |
| 108 | + an *existing* row's limit_value only, silently no-ops if config_id | |
| 109 | + doesn't exist yet. upsert_budget_config() (U-030) is the fuller | |
| 110 | + v_budget_settings controller that also handles first-time creation. | |
| 91 | 111 | """ |
| 92 | 112 | conn.execute( |
| 93 | 113 | "UPDATE budget_guardrail_config SET limit_value = ?, updated_by = ?, updated_at = CURRENT_TIMESTAMP " |
| ... | ... | @@ -95,12 +115,51 @@ def update_budget_limit_and_resume(conn, config_id, new_limit_value, updated_by) |
| 95 | 115 | (new_limit_value, updated_by, config_id), |
| 96 | 116 | ) |
| 97 | 117 | conn.commit() |
| 118 | + return {"resumed_task_ids": _resume_budget_blocked_tasks(conn)} | |
| 119 | + | |
| 120 | + | |
| 121 | +def upsert_budget_config(conn, scope, limit_type, limit_value, updated_by, config_id="default"): | |
| 122 | + """c_budget_config_update (03-C §3.13): v_budget_settings' submit_save. | |
| 123 | + Creates the row on first use (current_usage starts at 0) or updates an | |
| 124 | + existing one (current_usage is preserved -- this isn't a reset, just a | |
| 125 | + limit/scope/type change). Raising limit_value past current_usage is | |
| 126 | + exactly REQ-10's "人工在 v_budget_settings 调高额度" resume trigger | |
| 127 | + (04-P §4.4), so this also resumes any budget_exceeded blocked tasks -- | |
| 128 | + same as update_budget_limit_and_resume, just reachable from a fresh | |
| 129 | + config too, not only an existing row.""" | |
| 130 | + if scope not in _VALID_SCOPES: | |
| 131 | + raise InvalidParamError("scope must be one of {}, got {!r}".format(_VALID_SCOPES, scope)) | |
| 132 | + if limit_type not in _VALID_LIMIT_TYPES: | |
| 133 | + raise InvalidParamError("limit_type must be one of {}, got {!r}".format(_VALID_LIMIT_TYPES, limit_type)) | |
| 134 | + if limit_value <= 0: | |
| 135 | + raise InvalidParamError("limit_value must be > 0, got {!r}".format(limit_value)) | |
| 136 | + | |
| 137 | + exists = conn.execute( | |
| 138 | + "SELECT 1 FROM budget_guardrail_config WHERE config_id = ?", (config_id,) | |
| 139 | + ).fetchone() is not None | |
| 140 | + if exists: | |
| 141 | + conn.execute( | |
| 142 | + "UPDATE budget_guardrail_config SET scope = ?, limit_type = ?, limit_value = ?, updated_by = ?, " | |
| 143 | + "updated_at = CURRENT_TIMESTAMP WHERE config_id = ?", | |
| 144 | + (scope, limit_type, limit_value, updated_by, config_id), | |
| 145 | + ) | |
| 146 | + else: | |
| 147 | + conn.execute( | |
| 148 | + "INSERT INTO budget_guardrail_config (config_id, scope, limit_type, limit_value, current_usage, " | |
| 149 | + "updated_by) VALUES (?, ?, ?, ?, 0, ?)", | |
| 150 | + (config_id, scope, limit_type, limit_value, updated_by), | |
| 151 | + ) | |
| 152 | + conn.commit() | |
| 98 | 153 | |
| 99 | - blocked_tasks = conn.execute( | |
| 100 | - "SELECT task_id FROM task WHERE status = 'blocked_pending_human' AND blocked_reason = 'budget_exceeded'" | |
| 101 | - ).fetchall() | |
| 102 | - resumed = [] | |
| 103 | - for (task_id,) in blocked_tasks: | |
| 104 | - org_loop_route.route_org_loop(conn, task_id, "manual_resume") | |
| 105 | - resumed.append(task_id) | |
| 106 | - return {"resumed_task_ids": resumed} | |
| 154 | + resumed = _resume_budget_blocked_tasks(conn) | |
| 155 | + row = conn.execute( | |
| 156 | + "SELECT config_id, scope, limit_type, limit_value, current_usage, updated_at " | |
| 157 | + "FROM budget_guardrail_config WHERE config_id = ?", (config_id,) | |
| 158 | + ).fetchone() | |
| 159 | + return { | |
| 160 | + "config": { | |
| 161 | + "config_id": row[0], "scope": row[1], "limit_type": row[2], | |
| 162 | + "limit_value": row[3], "current_usage": row[4], "updated_at": row[5], | |
| 163 | + }, | |
| 164 | + "resumed_task_ids": resumed, | |
| 165 | + } | ... | ... |
src/settings_config.py
0 → 100644
| 1 | +"""c_concurrency_config_update (03-C §3.13) + c_log_retention_config_update | |
| 2 | +(03-C §3.13.1): the two `v_settings/*` upserts that don't already have a | |
| 3 | +home in a bigger module (c_budget_config_update lives in budget_guardrail.py | |
| 4 | +next to check_budget/list_budget_configs, which it shares state with). | |
| 5 | +Both are singleton config_id='default' rows -- create on first use, update | |
| 6 | +in place after. | |
| 7 | +""" | |
| 8 | + | |
| 9 | + | |
| 10 | +class InvalidParamError(Exception): | |
| 11 | + """INVALID_PARAM -- 03-C §3.13/§3.13.1: value out of range.""" | |
| 12 | + | |
| 13 | + | |
| 14 | +def get_concurrency_config(conn, config_id="default"): | |
| 15 | + row = conn.execute( | |
| 16 | + "SELECT config_id, max_concurrent_goals, updated_at FROM org_concurrency_config WHERE config_id = ?", | |
| 17 | + (config_id,), | |
| 18 | + ).fetchone() | |
| 19 | + if row is None: | |
| 20 | + return None | |
| 21 | + return {"config_id": row[0], "max_concurrent_goals": row[1], "updated_at": row[2]} | |
| 22 | + | |
| 23 | + | |
| 24 | +def upsert_concurrency_config(conn, max_concurrent_goals, updated_by, config_id="default"): | |
| 25 | + """c_concurrency_config_update (03-C §3.13): v_concurrency_settings' | |
| 26 | + submit_save. 02-V §2.11: range 1-50.""" | |
| 27 | + if not (1 <= max_concurrent_goals <= 50): | |
| 28 | + raise InvalidParamError("max_concurrent_goals must be in range 1-50, got {!r}".format(max_concurrent_goals)) | |
| 29 | + | |
| 30 | + exists = conn.execute( | |
| 31 | + "SELECT 1 FROM org_concurrency_config WHERE config_id = ?", (config_id,) | |
| 32 | + ).fetchone() is not None | |
| 33 | + if exists: | |
| 34 | + conn.execute( | |
| 35 | + "UPDATE org_concurrency_config SET max_concurrent_goals = ?, updated_by = ?, " | |
| 36 | + "updated_at = CURRENT_TIMESTAMP WHERE config_id = ?", | |
| 37 | + (max_concurrent_goals, updated_by, config_id), | |
| 38 | + ) | |
| 39 | + else: | |
| 40 | + conn.execute( | |
| 41 | + "INSERT INTO org_concurrency_config (config_id, max_concurrent_goals, updated_by) VALUES (?, ?, ?)", | |
| 42 | + (config_id, max_concurrent_goals, updated_by), | |
| 43 | + ) | |
| 44 | + conn.commit() | |
| 45 | + return get_concurrency_config(conn, config_id) | |
| 46 | + | |
| 47 | + | |
| 48 | +def get_log_retention_config(conn, config_id="default"): | |
| 49 | + row = conn.execute( | |
| 50 | + "SELECT config_id, retention_days, max_total_size_mb, updated_at FROM log_retention_config " | |
| 51 | + "WHERE config_id = ?", | |
| 52 | + (config_id,), | |
| 53 | + ).fetchone() | |
| 54 | + if row is None: | |
| 55 | + return None | |
| 56 | + return {"config_id": row[0], "retention_days": row[1], "max_total_size_mb": row[2], "updated_at": row[3]} | |
| 57 | + | |
| 58 | + | |
| 59 | +def upsert_log_retention_config(conn, retention_days, updated_by, max_total_size_mb=None, config_id="default"): | |
| 60 | + """c_log_retention_config_update (03-C §3.13.1): v_log_retention_settings' | |
| 61 | + submit_save.""" | |
| 62 | + if retention_days <= 0: | |
| 63 | + raise InvalidParamError("retention_days must be > 0, got {!r}".format(retention_days)) | |
| 64 | + | |
| 65 | + exists = conn.execute( | |
| 66 | + "SELECT 1 FROM log_retention_config WHERE config_id = ?", (config_id,) | |
| 67 | + ).fetchone() is not None | |
| 68 | + if exists: | |
| 69 | + conn.execute( | |
| 70 | + "UPDATE log_retention_config SET retention_days = ?, max_total_size_mb = ?, updated_by = ?, " | |
| 71 | + "updated_at = CURRENT_TIMESTAMP WHERE config_id = ?", | |
| 72 | + (retention_days, max_total_size_mb, updated_by, config_id), | |
| 73 | + ) | |
| 74 | + else: | |
| 75 | + conn.execute( | |
| 76 | + "INSERT INTO log_retention_config (config_id, retention_days, max_total_size_mb, updated_by) " | |
| 77 | + "VALUES (?, ?, ?, ?)", | |
| 78 | + (config_id, retention_days, max_total_size_mb, updated_by), | |
| 79 | + ) | |
| 80 | + conn.commit() | |
| 81 | + return get_log_retention_config(conn, config_id) | ... | ... |
src/test_backend_server.py
| ... | ... | @@ -104,7 +104,7 @@ class StaticFileServingTest(unittest.TestCase): |
| 104 | 104 | self.assertIn("javascript", content_type) |
| 105 | 105 | self.assertIn(b"hashchange", body) |
| 106 | 106 | self.assertIn(b'"#/tasks"', body) |
| 107 | - self.assertIn(b'"#/settings"', body) | |
| 107 | + self.assertIn(b'"#/settings/concurrency"', body) | |
| 108 | 108 | |
| 109 | 109 | def test_style_css_served_with_css_content_type(self): |
| 110 | 110 | status, content_type, body = self._get("/style.css") |
| ... | ... | @@ -987,6 +987,107 @@ class SkillRouteTest(unittest.TestCase): |
| 987 | 987 | self.assertEqual(status, 400) |
| 988 | 988 | |
| 989 | 989 | |
| 990 | +class SettingsRouteTest(unittest.TestCase): | |
| 991 | + """U-030: c_concurrency_config_update / c_budget_config_update / | |
| 992 | + c_log_retention_config_update -- the three v_*_settings pages.""" | |
| 993 | + | |
| 994 | + def setUp(self): | |
| 995 | + self.server = backend_server.create_server(db_path=":memory:") | |
| 996 | + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) | |
| 997 | + self.thread.start() | |
| 998 | + self.server.conn.execute( | |
| 999 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | |
| 1000 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | |
| 1001 | + ) | |
| 1002 | + self.server.conn.commit() | |
| 1003 | + | |
| 1004 | + def tearDown(self): | |
| 1005 | + self.server.shutdown() | |
| 1006 | + self.server.server_close() | |
| 1007 | + self.thread.join(timeout=5) | |
| 1008 | + | |
| 1009 | + def _get(self, path): | |
| 1010 | + host, port = self.server.server_address | |
| 1011 | + conn = http.client.HTTPConnection(host, port, timeout=5) | |
| 1012 | + conn.request("GET", path) | |
| 1013 | + resp = conn.getresponse() | |
| 1014 | + body = json.loads(resp.read()) | |
| 1015 | + conn.close() | |
| 1016 | + return resp.status, body | |
| 1017 | + | |
| 1018 | + def _put(self, path, payload): | |
| 1019 | + host, port = self.server.server_address | |
| 1020 | + conn = http.client.HTTPConnection(host, port, timeout=5) | |
| 1021 | + conn.request("PUT", path, body=json.dumps(payload).encode("utf-8")) | |
| 1022 | + resp = conn.getresponse() | |
| 1023 | + body = json.loads(resp.read()) | |
| 1024 | + conn.close() | |
| 1025 | + return resp.status, body | |
| 1026 | + | |
| 1027 | + def test_concurrency_get_returns_null_when_unconfigured(self): | |
| 1028 | + status, body = self._get("/api/settings/concurrency") | |
| 1029 | + self.assertEqual(status, 200) | |
| 1030 | + self.assertIsNone(body) | |
| 1031 | + | |
| 1032 | + def test_concurrency_update_and_get(self): | |
| 1033 | + status, body = self._put("/api/settings/concurrency", {"max_concurrent_goals": 8}) | |
| 1034 | + self.assertEqual(status, 200) | |
| 1035 | + self.assertEqual(body["max_concurrent_goals"], 8) | |
| 1036 | + status, body = self._get("/api/settings/concurrency") | |
| 1037 | + self.assertEqual(body["max_concurrent_goals"], 8) | |
| 1038 | + | |
| 1039 | + def test_concurrency_out_of_range_returns_400(self): | |
| 1040 | + status, body = self._put("/api/settings/concurrency", {"max_concurrent_goals": 100}) | |
| 1041 | + self.assertEqual(status, 400) | |
| 1042 | + self.assertEqual(body["error"], "INVALID_PARAM") | |
| 1043 | + | |
| 1044 | + def test_concurrency_missing_field_returns_400_not_500(self): | |
| 1045 | + status, body = self._put("/api/settings/concurrency", {}) | |
| 1046 | + self.assertEqual(status, 400) | |
| 1047 | + self.assertEqual(body["error"], "INVALID_PARAM") | |
| 1048 | + | |
| 1049 | + def test_budget_update_creates_and_returns_config(self): | |
| 1050 | + status, body = self._put( | |
| 1051 | + "/api/settings/budget", {"scope": "global", "limit_type": "token_count", "limit_value": 5000} | |
| 1052 | + ) | |
| 1053 | + self.assertEqual(status, 200) | |
| 1054 | + self.assertEqual(body["config"]["limit_value"], 5000) | |
| 1055 | + | |
| 1056 | + def test_budget_invalid_scope_returns_400(self): | |
| 1057 | + status, body = self._put( | |
| 1058 | + "/api/settings/budget", {"scope": "bogus", "limit_type": "token_count", "limit_value": 100} | |
| 1059 | + ) | |
| 1060 | + self.assertEqual(status, 400) | |
| 1061 | + self.assertEqual(body["error"], "INVALID_PARAM") | |
| 1062 | + | |
| 1063 | + def test_budget_missing_limit_value_returns_400_not_500(self): | |
| 1064 | + status, body = self._put("/api/settings/budget", {"scope": "global", "limit_type": "token_count"}) | |
| 1065 | + self.assertEqual(status, 400) | |
| 1066 | + self.assertEqual(body["error"], "INVALID_PARAM") | |
| 1067 | + | |
| 1068 | + def test_log_retention_get_returns_null_when_unconfigured(self): | |
| 1069 | + status, body = self._get("/api/settings/log-retention") | |
| 1070 | + self.assertEqual(status, 200) | |
| 1071 | + self.assertIsNone(body) | |
| 1072 | + | |
| 1073 | + def test_log_retention_update_and_get(self): | |
| 1074 | + status, body = self._put("/api/settings/log-retention", {"retention_days": 30, "max_total_size_mb": 200}) | |
| 1075 | + self.assertEqual(status, 200) | |
| 1076 | + self.assertEqual(body["retention_days"], 30) | |
| 1077 | + status, body = self._get("/api/settings/log-retention") | |
| 1078 | + self.assertEqual(body["max_total_size_mb"], 200) | |
| 1079 | + | |
| 1080 | + def test_log_retention_zero_days_returns_400(self): | |
| 1081 | + status, body = self._put("/api/settings/log-retention", {"retention_days": 0}) | |
| 1082 | + self.assertEqual(status, 400) | |
| 1083 | + self.assertEqual(body["error"], "INVALID_PARAM") | |
| 1084 | + | |
| 1085 | + def test_log_retention_missing_field_returns_400_not_500(self): | |
| 1086 | + status, body = self._put("/api/settings/log-retention", {}) | |
| 1087 | + self.assertEqual(status, 400) | |
| 1088 | + self.assertEqual(body["error"], "INVALID_PARAM") | |
| 1089 | + | |
| 1090 | + | |
| 990 | 1091 | class ZeroThirdPartyDependencyTest(unittest.TestCase): |
| 991 | 1092 | """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this |
| 992 | 1093 | doesn't silently rot if someone adds an import later.""" | ... | ... |
src/test_budget_guardrail.py
| ... | ... | @@ -101,5 +101,39 @@ class ResumeAfterLimitRaiseTest(BudgetGuardrailTestBase): |
| 101 | 101 | self.assertEqual(status, "blocked_pending_human") |
| 102 | 102 | |
| 103 | 103 | |
| 104 | +class UpsertBudgetConfigTest(BudgetGuardrailTestBase): | |
| 105 | + def test_updates_existing_config_preserving_current_usage(self): | |
| 106 | + self.conn.execute("UPDATE budget_guardrail_config SET current_usage = 42 WHERE config_id = 'default'") | |
| 107 | + self.conn.commit() | |
| 108 | + result = budget_guardrail.upsert_budget_config(self.conn, "global", "token_count", 500, "admin") | |
| 109 | + self.assertEqual(result["config"]["limit_value"], 500) | |
| 110 | + self.assertEqual(result["config"]["current_usage"], 42) | |
| 111 | + | |
| 112 | + def test_creates_config_when_none_exists(self): | |
| 113 | + self.conn.execute("DELETE FROM budget_guardrail_config") | |
| 114 | + self.conn.commit() | |
| 115 | + result = budget_guardrail.upsert_budget_config(self.conn, "per_task", "call_count", 10, "admin") | |
| 116 | + self.assertEqual(result["config"]["current_usage"], 0) | |
| 117 | + self.assertEqual(result["config"]["scope"], "per_task") | |
| 118 | + | |
| 119 | + def test_resumes_blocked_tasks_same_as_update_budget_limit(self): | |
| 120 | + with self.assertRaises(budget_guardrail.BudgetExceededError): | |
| 121 | + budget_guardrail.check_budget(self.conn, "task-1", "default", 150) | |
| 122 | + result = budget_guardrail.upsert_budget_config(self.conn, "global", "token_count", 1000, "admin") | |
| 123 | + self.assertEqual(result["resumed_task_ids"], ["task-1"]) | |
| 124 | + | |
| 125 | + def test_invalid_scope_rejected(self): | |
| 126 | + with self.assertRaises(budget_guardrail.InvalidParamError): | |
| 127 | + budget_guardrail.upsert_budget_config(self.conn, "not_a_scope", "token_count", 100, "admin") | |
| 128 | + | |
| 129 | + def test_invalid_limit_type_rejected(self): | |
| 130 | + with self.assertRaises(budget_guardrail.InvalidParamError): | |
| 131 | + budget_guardrail.upsert_budget_config(self.conn, "global", "not_a_type", 100, "admin") | |
| 132 | + | |
| 133 | + def test_zero_or_negative_limit_value_rejected(self): | |
| 134 | + with self.assertRaises(budget_guardrail.InvalidParamError): | |
| 135 | + budget_guardrail.upsert_budget_config(self.conn, "global", "token_count", 0, "admin") | |
| 136 | + | |
| 137 | + | |
| 104 | 138 | if __name__ == "__main__": |
| 105 | 139 | unittest.main() | ... | ... |
src/test_settings_config.py
0 → 100644
| 1 | +"""U-030 tests: c_concurrency_config_update / c_log_retention_config_update. | |
| 2 | +Real sqlite, no mocking needed. | |
| 3 | + | |
| 4 | +Run: python3 -m unittest src.test_settings_config -v | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import unittest | |
| 8 | + | |
| 9 | +from src import schema, settings_config | |
| 10 | + | |
| 11 | + | |
| 12 | +class SettingsConfigTestBase(unittest.TestCase): | |
| 13 | + def setUp(self): | |
| 14 | + self.conn = schema.connect(":memory:") | |
| 15 | + schema.create_schema(self.conn) | |
| 16 | + self.conn.commit() | |
| 17 | + | |
| 18 | + def tearDown(self): | |
| 19 | + self.conn.close() | |
| 20 | + | |
| 21 | + | |
| 22 | +class ConcurrencyConfigTest(SettingsConfigTestBase): | |
| 23 | + def test_get_returns_none_when_unconfigured(self): | |
| 24 | + self.assertIsNone(settings_config.get_concurrency_config(self.conn)) | |
| 25 | + | |
| 26 | + def test_creates_config_on_first_use(self): | |
| 27 | + result = settings_config.upsert_concurrency_config(self.conn, 5, "admin") | |
| 28 | + self.assertEqual(result["max_concurrent_goals"], 5) | |
| 29 | + self.assertEqual(settings_config.get_concurrency_config(self.conn)["max_concurrent_goals"], 5) | |
| 30 | + | |
| 31 | + def test_updates_existing_config(self): | |
| 32 | + settings_config.upsert_concurrency_config(self.conn, 5, "admin") | |
| 33 | + result = settings_config.upsert_concurrency_config(self.conn, 10, "admin2") | |
| 34 | + self.assertEqual(result["max_concurrent_goals"], 10) | |
| 35 | + self.assertEqual( | |
| 36 | + self.conn.execute("SELECT COUNT(*) FROM org_concurrency_config").fetchone()[0], 1 | |
| 37 | + ) | |
| 38 | + | |
| 39 | + def test_below_range_rejected(self): | |
| 40 | + with self.assertRaises(settings_config.InvalidParamError): | |
| 41 | + settings_config.upsert_concurrency_config(self.conn, 0, "admin") | |
| 42 | + | |
| 43 | + def test_above_range_rejected(self): | |
| 44 | + with self.assertRaises(settings_config.InvalidParamError): | |
| 45 | + settings_config.upsert_concurrency_config(self.conn, 51, "admin") | |
| 46 | + | |
| 47 | + def test_range_boundaries_accepted(self): | |
| 48 | + settings_config.upsert_concurrency_config(self.conn, 1, "admin") | |
| 49 | + settings_config.upsert_concurrency_config(self.conn, 50, "admin") | |
| 50 | + | |
| 51 | + | |
| 52 | +class LogRetentionConfigTest(SettingsConfigTestBase): | |
| 53 | + def test_get_returns_none_when_unconfigured(self): | |
| 54 | + self.assertIsNone(settings_config.get_log_retention_config(self.conn)) | |
| 55 | + | |
| 56 | + def test_creates_config_on_first_use(self): | |
| 57 | + result = settings_config.upsert_log_retention_config(self.conn, 14, "admin") | |
| 58 | + self.assertEqual(result["retention_days"], 14) | |
| 59 | + self.assertIsNone(result["max_total_size_mb"]) | |
| 60 | + | |
| 61 | + def test_updates_existing_config(self): | |
| 62 | + settings_config.upsert_log_retention_config(self.conn, 14, "admin") | |
| 63 | + result = settings_config.upsert_log_retention_config(self.conn, 30, "admin", max_total_size_mb=500) | |
| 64 | + self.assertEqual(result["retention_days"], 30) | |
| 65 | + self.assertEqual(result["max_total_size_mb"], 500) | |
| 66 | + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM log_retention_config").fetchone()[0], 1) | |
| 67 | + | |
| 68 | + def test_zero_or_negative_retention_days_rejected(self): | |
| 69 | + with self.assertRaises(settings_config.InvalidParamError): | |
| 70 | + settings_config.upsert_log_retention_config(self.conn, 0, "admin") | |
| 71 | + with self.assertRaises(settings_config.InvalidParamError): | |
| 72 | + settings_config.upsert_log_retention_config(self.conn, -5, "admin") | |
| 73 | + | |
| 74 | + | |
| 75 | +if __name__ == "__main__": | |
| 76 | + unittest.main() | ... | ... |