Commit e696cd489d05e0c12bb89983374bf5397c107cf2

Authored by bboonnee
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>
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,&gt;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()
... ...