diff --git a/docs/development-plan.md b/docs/development-plan.md
index bb8227e..74430b5 100644
--- a/docs/development-plan.md
+++ b/docs/development-plan.md
@@ -275,6 +275,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
- **U-030 [Value Path]** `v_concurrency_settings` + `v_budget_settings` + `v_log_retention_settings`
- 依赖:U-019(预算配置)、U-021 涉及的 `log_retention_config`(U-001 已建表,此处只是配置页 UI)
+ - Done:三个配置页可用且改动生效(原文档缺 Done/覆盖行,2026-07-31 补上)
+ - **实现补充(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 的完整读写循环。
+ - 覆盖: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`
---
diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json
index e75e44d..92253d2 100644
--- a/docs/sdd/unit_matrix.json
+++ b/docs/sdd/unit_matrix.json
@@ -45,7 +45,7 @@
{"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 未覆盖)"},
{"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 渲染/代码高亮(避免引入前端第三方依赖)"},
{"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 可注入,测试绝不碰真实仓库)"},
- {"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": "三个配置页可用且改动生效"},
+ {"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 至此全部完成"},
{"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 的全部用例通过一次完整回归"},
{"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 可同时稳定运行"},
diff --git a/frontend/app.js b/frontend/app.js
index 06b1383..533411e 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -79,7 +79,9 @@ const routes = {
"#/tasks": { render: renderTaskBoard, mount: mountTaskBoard },
"#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser },
"#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary },
- "#/settings": { render: () => "
Settings
placeholder -- real content is PH3 (U-030)
" },
+ "#/settings/concurrency": { render: renderConcurrencySettings, mount: mountConcurrencySettings },
+ "#/settings/budget": { render: renderBudgetSettings, mount: mountBudgetSettings },
+ "#/settings/log-retention": { render: renderLogRetentionSettings, mount: mountLogRetentionSettings },
"#/setup": { render: renderProjectSetup, mount: mountProjectSetup },
"#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer },
"#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback },
@@ -1216,3 +1218,215 @@ function mountSkillAuditLog(skillId) {
refresh();
}
+
+// ---- v_concurrency_settings / v_budget_settings / v_log_retention_settings
+// (02-V §2.11/§2.12/§2.13) -- three small config pages, none of them
+// project-scoped (all three tables are platform-wide singletons, config_id
+// ='default'), so like the Skill pages they skip the getCurrentProjectId()
+// guard. renderSettingsSubnav() stands in for 02-V's nav-dropdown, which
+// this shell's single-level #nav bar doesn't have room for.
+
+function renderSettingsSubnav(active) {
+ const items = [["concurrency", "并发数"], ["budget", "预算护栏"], ["log-retention", "日志保留"]];
+ return '' + items.map(([key, label]) =>
+ '
' + label + ""
+ ).join("") + "
";
+}
+
+function renderConcurrencySettings() {
+ return `
+
+
并发数配置
+ ${renderSettingsSubnav("concurrency")}
+
+
+
+
+
+
+
调大后下一个新 Goal 起生效
+
+
+
+
+ `;
+}
+
+function mountConcurrencySettings() {
+ const input = document.getElementById("cs_max_concurrent_goals");
+ const errorEl = document.getElementById("cs_error");
+ const successEl = document.getElementById("cs_success");
+
+ fetch("/api/settings/concurrency").then((r) => r.json()).then((config) => {
+ if (config) input.value = config.max_concurrent_goals;
+ });
+
+ document.getElementById("btn_save_concurrency").addEventListener("click", async () => {
+ errorEl.style.display = "none";
+ successEl.style.display = "none";
+ const resp = await fetch("/api/settings/concurrency", {
+ method: "PUT",
+ body: JSON.stringify({ max_concurrent_goals: Number(input.value) }),
+ });
+ const body = await resp.json();
+ if (resp.status !== 200) {
+ errorEl.textContent = body.error + (body.detail ? ":" + body.detail : "");
+ errorEl.style.display = "block";
+ return;
+ }
+ successEl.textContent = "已更新,下一个新 Goal 起生效";
+ successEl.style.display = "block";
+ });
+}
+
+function renderBudgetSettings() {
+ return `
+
+
预算护栏配置
+ ${renderSettingsSubnav("budget")}
+
人工配置层,与 Claude Code plan 额度耗尽是两套独立机制
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+function mountBudgetSettings() {
+ const scopeEl = document.getElementById("bs_scope");
+ const limitTypeEl = document.getElementById("bs_limit_type");
+ const limitValueEl = document.getElementById("bs_limit_value");
+ const usageEl = document.getElementById("bs_usage");
+ const usageFillEl = document.getElementById("bs_usage_fill");
+ const alertEl = document.getElementById("bs_alert");
+ const errorEl = document.getElementById("bs_error");
+ const successEl = document.getElementById("bs_success");
+
+ async function refresh() {
+ const configs = await fetch("/api/settings/budget").then((r) => r.json());
+ const config = configs[0];
+ if (config) {
+ scopeEl.value = config.scope;
+ limitTypeEl.value = config.limit_type;
+ limitValueEl.value = config.limit_value;
+ usageEl.textContent = "当前用量:" + config.current_usage.toLocaleString() + " / " + config.limit_value.toLocaleString();
+ const pct = config.limit_value > 0 ? Math.min(100, Math.round((config.current_usage / config.limit_value) * 100)) : 0;
+ usageFillEl.style.width = pct + "%";
+ } else {
+ usageEl.textContent = "尚未配置";
+ }
+
+ const projectId = getCurrentProjectId();
+ const blocked = projectId
+ ? await fetch("/api/tasks?project_id=" + encodeURIComponent(projectId) + "&status=blocked_pending_human")
+ .then((r) => (r.ok ? r.json() : []))
+ : [];
+ if (blocked.length > 0) {
+ alertEl.innerHTML = "已有 " + blocked.length + " 个任务处于 blocked_pending_human · " + '查看已暂停任务';
+ alertEl.style.display = "flex";
+ } else {
+ alertEl.style.display = "none";
+ }
+ }
+
+ document.getElementById("btn_save_budget").addEventListener("click", async () => {
+ errorEl.style.display = "none";
+ successEl.style.display = "none";
+ const resp = await fetch("/api/settings/budget", {
+ method: "PUT",
+ body: JSON.stringify({ scope: scopeEl.value, limit_type: limitTypeEl.value, limit_value: Number(limitValueEl.value) }),
+ });
+ const body = await resp.json();
+ if (resp.status !== 200) {
+ errorEl.textContent = body.error + (body.detail ? ":" + body.detail : "");
+ errorEl.style.display = "block";
+ return;
+ }
+ successEl.textContent = "已更新" + (body.resumed_task_ids.length ? ",已恢复 " + body.resumed_task_ids.length + " 个因超限阻塞的任务" : "");
+ successEl.style.display = "block";
+ refresh();
+ });
+
+ refresh();
+}
+
+function renderLogRetentionSettings() {
+ return `
+
+
日志保留策略配置
+ ${renderSettingsSubnav("log-retention")}
+
+
+
+
+
+
+
+
+
+
+
超过该天数的 closed 状态日志会被清理任务标记 purged 并删除物理文件;清理任务触发频率本身不可配
+
+
+
+
+ `;
+}
+
+function mountLogRetentionSettings() {
+ const daysEl = document.getElementById("lrs_retention_days");
+ const sizeEl = document.getElementById("lrs_max_total_size_mb");
+ const errorEl = document.getElementById("lrs_error");
+ const successEl = document.getElementById("lrs_success");
+
+ fetch("/api/settings/log-retention").then((r) => r.json()).then((config) => {
+ if (config) {
+ daysEl.value = config.retention_days;
+ if (config.max_total_size_mb != null) sizeEl.value = config.max_total_size_mb;
+ }
+ });
+
+ document.getElementById("btn_save_log_retention").addEventListener("click", async () => {
+ errorEl.style.display = "none";
+ successEl.style.display = "none";
+ const resp = await fetch("/api/settings/log-retention", {
+ method: "PUT",
+ body: JSON.stringify({
+ retention_days: Number(daysEl.value),
+ max_total_size_mb: sizeEl.value ? Number(sizeEl.value) : undefined,
+ }),
+ });
+ const body = await resp.json();
+ if (resp.status !== 200) {
+ errorEl.textContent = body.error + (body.detail ? ":" + body.detail : "");
+ errorEl.style.display = "block";
+ return;
+ }
+ successEl.textContent = "已更新";
+ successEl.style.display = "block";
+ });
+}
diff --git a/frontend/index.html b/frontend/index.html
index 65a83c5..3ccfac2 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -11,7 +11,7 @@
Tasks
Artifacts
Skills
- Settings
+ Settings
diff --git a/frontend/style.css b/frontend/style.css
index 1b67532..a184afd 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -15,7 +15,7 @@ body { font-family: system-ui, sans-serif; margin: 0; }
.panel { border: 1px solid #e5e7eb; border-radius: 10px; padding: 24px; background: #f9fafb; }
.field { margin-bottom: 18px; }
.field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
-.field input, .field textarea { width: 100%; padding: 9px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; font-family: inherit; }
+.field input, .field textarea, .field select { width: 100%; padding: 9px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; font-family: inherit; }
.field input[readonly] { background: #f3f4f6; color: #6b7280; }
.field .hint { font-size: 12px; color: #6b7280; margin-top: 5px; }
.btn { display: inline-block; padding: 9px 16px; border-radius: 6px; border: 1px solid #e5e7eb; background: #fff; font-size: 14px; cursor: pointer; }
diff --git a/src/backend_server.py b/src/backend_server.py
index 44ad7d3..71178c8 100644
--- a/src/backend_server.py
+++ b/src/backend_server.py
@@ -19,9 +19,11 @@ viewing, `c_artifact_list`/`c_artifact_get` (U-028, the former unnamed in
either 03-C or the MVCP JSON contract) for `v_artifact_browser`,
`c_skill_diff`/`c_skill_rollback` + skill/skill_iteration list/get (U-029,
`c_skill_diff` likewise only named in the MVCP JSON) for `v_skill_library`/
-`v_skill_audit_log`, and serving the U-008 frontend shell as static files
-(no build step, 05 §5.7: whitelisted by exact path, not a generic
-directory walker, so there's no path-traversal surface to reason about).
+`v_skill_audit_log`, `c_concurrency_config_update`/`c_budget_config_update`/
+`c_log_retention_config_update` (03-C §3.13/§3.13.1, U-030) for the three
+settings pages, and serving the U-008 frontend shell as static files (no
+build step, 05 §5.7: whitelisted by exact path, not a generic directory
+walker, so there's no path-traversal surface to reason about).
"""
import json
@@ -36,8 +38,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from src import (
artifact_query, budget_guardrail, bug_record, execution_log,
- org_loop_route, paths, project_scaffold, schema, skill_query,
- skill_update, task_create, task_query, ws_subscribe,
+ org_loop_route, paths, project_scaffold, schema, settings_config,
+ skill_query, skill_update, task_create, task_query, ws_subscribe,
)
_FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
@@ -148,6 +150,12 @@ class _Handler(BaseHTTPRequestHandler):
if parsed.path == "/api/settings/budget":
self._handle_budget_list()
return
+ if parsed.path == "/api/settings/concurrency":
+ self._handle_concurrency_get()
+ return
+ if parsed.path == "/api/settings/log-retention":
+ self._handle_log_retention_get()
+ return
if parsed.path.startswith(_WORK_NODES_PREFIX) and parsed.path.endswith(_LOG_SUFFIX):
node_exec_id = parsed.path[len(_WORK_NODES_PREFIX):-len(_LOG_SUFFIX)]
self._handle_execution_log_get(node_exec_id, parsed.query)
@@ -202,6 +210,16 @@ class _Handler(BaseHTTPRequestHandler):
else:
self._send_json(404, {"error": "NOT_FOUND"})
+ def do_PUT(self):
+ if self.path == "/api/settings/concurrency":
+ self._handle_concurrency_update()
+ elif self.path == "/api/settings/budget":
+ self._handle_budget_update()
+ elif self.path == "/api/settings/log-retention":
+ self._handle_log_retention_update()
+ else:
+ self._send_json(404, {"error": "NOT_FOUND"})
+
def _handle_pick_folder(self):
try:
self._send_json(200, pick_folder())
@@ -328,6 +346,66 @@ class _Handler(BaseHTTPRequestHandler):
result = budget_guardrail.list_budget_configs(self.server.conn)
self._send_json(200, result)
+ def _handle_budget_update(self):
+ payload, error = self._read_json_body()
+ if error:
+ self._send_json(400, error)
+ return
+ try:
+ with self.server.conn_lock:
+ result = budget_guardrail.upsert_budget_config(
+ self.server.conn, payload.get("scope"), payload.get("limit_type"),
+ payload.get("limit_value"), payload.get("updated_by", "console"),
+ )
+ except (budget_guardrail.InvalidParamError, TypeError) as e:
+ # TypeError here means a required numeric field (limit_value) was
+ # missing/null -- comparing None <= 0 raises TypeError, not
+ # InvalidParamError, before upsert_budget_config's own checks run
+ self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)})
+ else:
+ self._send_json(200, result)
+
+ def _handle_concurrency_get(self):
+ with self.server.conn_lock:
+ result = settings_config.get_concurrency_config(self.server.conn)
+ self._send_json(200, result)
+
+ def _handle_concurrency_update(self):
+ payload, error = self._read_json_body()
+ if error:
+ self._send_json(400, error)
+ return
+ try:
+ with self.server.conn_lock:
+ result = settings_config.upsert_concurrency_config(
+ self.server.conn, payload.get("max_concurrent_goals"), payload.get("updated_by", "console"),
+ )
+ except (settings_config.InvalidParamError, TypeError) as e:
+ self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)})
+ else:
+ self._send_json(200, result)
+
+ def _handle_log_retention_get(self):
+ with self.server.conn_lock:
+ result = settings_config.get_log_retention_config(self.server.conn)
+ self._send_json(200, result)
+
+ def _handle_log_retention_update(self):
+ payload, error = self._read_json_body()
+ if error:
+ self._send_json(400, error)
+ return
+ try:
+ with self.server.conn_lock:
+ result = settings_config.upsert_log_retention_config(
+ self.server.conn, payload.get("retention_days"), payload.get("updated_by", "console"),
+ max_total_size_mb=payload.get("max_total_size_mb"),
+ )
+ except (settings_config.InvalidParamError, TypeError) as e:
+ self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)})
+ else:
+ self._send_json(200, result)
+
def _handle_artifact_list(self, query_string):
params = urllib.parse.parse_qs(query_string)
project_id = params.get("project_id", [None])[0]
diff --git a/src/budget_guardrail.py b/src/budget_guardrail.py
index 2d9acf3..ee13a71 100644
--- a/src/budget_guardrail.py
+++ b/src/budget_guardrail.py
@@ -13,11 +13,21 @@ import uuid
from src import org_loop_route
+_VALID_SCOPES = ("global", "per_task", "per_day")
+_VALID_LIMIT_TYPES = ("token_count", "call_count")
+
class BudgetExceededError(Exception):
"""BUDGET_EXCEEDED -- 03-C §3.12."""
+class InvalidParamError(Exception):
+ """INVALID_PARAM -- 03-C §3.13: limit_value<=0, or scope/limit_type not
+ one of budget_guardrail_config's own enum values (checked here rather
+ than left to surface as a raw sqlite CHECK-constraint IntegrityError --
+ same reasoning as U-023's bug_record/task_id existence checks)."""
+
+
def _write_status_event(conn, task_id, project_id, from_status, to_status):
conn.execute(
"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):
return {"allowed": True, "remaining_quota": limit_value - projected}
+def _resume_budget_blocked_tasks(conn):
+ blocked_tasks = conn.execute(
+ "SELECT task_id FROM task WHERE status = 'blocked_pending_human' AND blocked_reason = 'budget_exceeded'"
+ ).fetchall()
+ resumed = []
+ for (task_id,) in blocked_tasks:
+ org_loop_route.route_org_loop(conn, task_id, "manual_resume")
+ resumed.append(task_id)
+ return resumed
+
+
def update_budget_limit_and_resume(conn, config_id, new_limit_value, updated_by):
"""04-P §4.4: paused -> monitoring, guard "人工在 v_budget_settings 调高额度".
- This is the minimal backend mechanism REQ-10's boundary case needs, not
- the full settings-page controller (c_budget_config_update's complete
- field set is U-030's job, PH3) -- reuses U-016's manual_resume (via
- org_loop_route.route_org_loop) for the actual status restoration rather
- than reimplementing it.
+ This is the minimal mechanism REQ-10's boundary case needs -- updates
+ an *existing* row's limit_value only, silently no-ops if config_id
+ doesn't exist yet. upsert_budget_config() (U-030) is the fuller
+ v_budget_settings controller that also handles first-time creation.
"""
conn.execute(
"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)
(new_limit_value, updated_by, config_id),
)
conn.commit()
+ return {"resumed_task_ids": _resume_budget_blocked_tasks(conn)}
+
+
+def upsert_budget_config(conn, scope, limit_type, limit_value, updated_by, config_id="default"):
+ """c_budget_config_update (03-C §3.13): v_budget_settings' submit_save.
+ Creates the row on first use (current_usage starts at 0) or updates an
+ existing one (current_usage is preserved -- this isn't a reset, just a
+ limit/scope/type change). Raising limit_value past current_usage is
+ exactly REQ-10's "人工在 v_budget_settings 调高额度" resume trigger
+ (04-P §4.4), so this also resumes any budget_exceeded blocked tasks --
+ same as update_budget_limit_and_resume, just reachable from a fresh
+ config too, not only an existing row."""
+ if scope not in _VALID_SCOPES:
+ raise InvalidParamError("scope must be one of {}, got {!r}".format(_VALID_SCOPES, scope))
+ if limit_type not in _VALID_LIMIT_TYPES:
+ raise InvalidParamError("limit_type must be one of {}, got {!r}".format(_VALID_LIMIT_TYPES, limit_type))
+ if limit_value <= 0:
+ raise InvalidParamError("limit_value must be > 0, got {!r}".format(limit_value))
+
+ exists = conn.execute(
+ "SELECT 1 FROM budget_guardrail_config WHERE config_id = ?", (config_id,)
+ ).fetchone() is not None
+ if exists:
+ conn.execute(
+ "UPDATE budget_guardrail_config SET scope = ?, limit_type = ?, limit_value = ?, updated_by = ?, "
+ "updated_at = CURRENT_TIMESTAMP WHERE config_id = ?",
+ (scope, limit_type, limit_value, updated_by, config_id),
+ )
+ else:
+ conn.execute(
+ "INSERT INTO budget_guardrail_config (config_id, scope, limit_type, limit_value, current_usage, "
+ "updated_by) VALUES (?, ?, ?, ?, 0, ?)",
+ (config_id, scope, limit_type, limit_value, updated_by),
+ )
+ conn.commit()
- blocked_tasks = conn.execute(
- "SELECT task_id FROM task WHERE status = 'blocked_pending_human' AND blocked_reason = 'budget_exceeded'"
- ).fetchall()
- resumed = []
- for (task_id,) in blocked_tasks:
- org_loop_route.route_org_loop(conn, task_id, "manual_resume")
- resumed.append(task_id)
- return {"resumed_task_ids": resumed}
+ resumed = _resume_budget_blocked_tasks(conn)
+ row = conn.execute(
+ "SELECT config_id, scope, limit_type, limit_value, current_usage, updated_at "
+ "FROM budget_guardrail_config WHERE config_id = ?", (config_id,)
+ ).fetchone()
+ return {
+ "config": {
+ "config_id": row[0], "scope": row[1], "limit_type": row[2],
+ "limit_value": row[3], "current_usage": row[4], "updated_at": row[5],
+ },
+ "resumed_task_ids": resumed,
+ }
diff --git a/src/settings_config.py b/src/settings_config.py
new file mode 100644
index 0000000..53c1f12
--- /dev/null
+++ b/src/settings_config.py
@@ -0,0 +1,81 @@
+"""c_concurrency_config_update (03-C §3.13) + c_log_retention_config_update
+(03-C §3.13.1): the two `v_settings/*` upserts that don't already have a
+home in a bigger module (c_budget_config_update lives in budget_guardrail.py
+next to check_budget/list_budget_configs, which it shares state with).
+Both are singleton config_id='default' rows -- create on first use, update
+in place after.
+"""
+
+
+class InvalidParamError(Exception):
+ """INVALID_PARAM -- 03-C §3.13/§3.13.1: value out of range."""
+
+
+def get_concurrency_config(conn, config_id="default"):
+ row = conn.execute(
+ "SELECT config_id, max_concurrent_goals, updated_at FROM org_concurrency_config WHERE config_id = ?",
+ (config_id,),
+ ).fetchone()
+ if row is None:
+ return None
+ return {"config_id": row[0], "max_concurrent_goals": row[1], "updated_at": row[2]}
+
+
+def upsert_concurrency_config(conn, max_concurrent_goals, updated_by, config_id="default"):
+ """c_concurrency_config_update (03-C §3.13): v_concurrency_settings'
+ submit_save. 02-V §2.11: range 1-50."""
+ if not (1 <= max_concurrent_goals <= 50):
+ raise InvalidParamError("max_concurrent_goals must be in range 1-50, got {!r}".format(max_concurrent_goals))
+
+ exists = conn.execute(
+ "SELECT 1 FROM org_concurrency_config WHERE config_id = ?", (config_id,)
+ ).fetchone() is not None
+ if exists:
+ conn.execute(
+ "UPDATE org_concurrency_config SET max_concurrent_goals = ?, updated_by = ?, "
+ "updated_at = CURRENT_TIMESTAMP WHERE config_id = ?",
+ (max_concurrent_goals, updated_by, config_id),
+ )
+ else:
+ conn.execute(
+ "INSERT INTO org_concurrency_config (config_id, max_concurrent_goals, updated_by) VALUES (?, ?, ?)",
+ (config_id, max_concurrent_goals, updated_by),
+ )
+ conn.commit()
+ return get_concurrency_config(conn, config_id)
+
+
+def get_log_retention_config(conn, config_id="default"):
+ row = conn.execute(
+ "SELECT config_id, retention_days, max_total_size_mb, updated_at FROM log_retention_config "
+ "WHERE config_id = ?",
+ (config_id,),
+ ).fetchone()
+ if row is None:
+ return None
+ return {"config_id": row[0], "retention_days": row[1], "max_total_size_mb": row[2], "updated_at": row[3]}
+
+
+def upsert_log_retention_config(conn, retention_days, updated_by, max_total_size_mb=None, config_id="default"):
+ """c_log_retention_config_update (03-C §3.13.1): v_log_retention_settings'
+ submit_save."""
+ if retention_days <= 0:
+ raise InvalidParamError("retention_days must be > 0, got {!r}".format(retention_days))
+
+ exists = conn.execute(
+ "SELECT 1 FROM log_retention_config WHERE config_id = ?", (config_id,)
+ ).fetchone() is not None
+ if exists:
+ conn.execute(
+ "UPDATE log_retention_config SET retention_days = ?, max_total_size_mb = ?, updated_by = ?, "
+ "updated_at = CURRENT_TIMESTAMP WHERE config_id = ?",
+ (retention_days, max_total_size_mb, updated_by, config_id),
+ )
+ else:
+ conn.execute(
+ "INSERT INTO log_retention_config (config_id, retention_days, max_total_size_mb, updated_by) "
+ "VALUES (?, ?, ?, ?)",
+ (config_id, retention_days, max_total_size_mb, updated_by),
+ )
+ conn.commit()
+ return get_log_retention_config(conn, config_id)
diff --git a/src/test_backend_server.py b/src/test_backend_server.py
index 2db1ce8..3eefb37 100644
--- a/src/test_backend_server.py
+++ b/src/test_backend_server.py
@@ -104,7 +104,7 @@ class StaticFileServingTest(unittest.TestCase):
self.assertIn("javascript", content_type)
self.assertIn(b"hashchange", body)
self.assertIn(b'"#/tasks"', body)
- self.assertIn(b'"#/settings"', body)
+ self.assertIn(b'"#/settings/concurrency"', body)
def test_style_css_served_with_css_content_type(self):
status, content_type, body = self._get("/style.css")
@@ -987,6 +987,107 @@ class SkillRouteTest(unittest.TestCase):
self.assertEqual(status, 400)
+class SettingsRouteTest(unittest.TestCase):
+ """U-030: c_concurrency_config_update / c_budget_config_update /
+ c_log_retention_config_update -- the three v_*_settings pages."""
+
+ def setUp(self):
+ self.server = backend_server.create_server(db_path=":memory:")
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+ self.thread.start()
+ self.server.conn.execute(
+ "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
+ "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
+ )
+ self.server.conn.commit()
+
+ def tearDown(self):
+ self.server.shutdown()
+ self.server.server_close()
+ self.thread.join(timeout=5)
+
+ def _get(self, path):
+ host, port = self.server.server_address
+ conn = http.client.HTTPConnection(host, port, timeout=5)
+ conn.request("GET", path)
+ resp = conn.getresponse()
+ body = json.loads(resp.read())
+ conn.close()
+ return resp.status, body
+
+ def _put(self, path, payload):
+ host, port = self.server.server_address
+ conn = http.client.HTTPConnection(host, port, timeout=5)
+ conn.request("PUT", path, body=json.dumps(payload).encode("utf-8"))
+ resp = conn.getresponse()
+ body = json.loads(resp.read())
+ conn.close()
+ return resp.status, body
+
+ def test_concurrency_get_returns_null_when_unconfigured(self):
+ status, body = self._get("/api/settings/concurrency")
+ self.assertEqual(status, 200)
+ self.assertIsNone(body)
+
+ def test_concurrency_update_and_get(self):
+ status, body = self._put("/api/settings/concurrency", {"max_concurrent_goals": 8})
+ self.assertEqual(status, 200)
+ self.assertEqual(body["max_concurrent_goals"], 8)
+ status, body = self._get("/api/settings/concurrency")
+ self.assertEqual(body["max_concurrent_goals"], 8)
+
+ def test_concurrency_out_of_range_returns_400(self):
+ status, body = self._put("/api/settings/concurrency", {"max_concurrent_goals": 100})
+ self.assertEqual(status, 400)
+ self.assertEqual(body["error"], "INVALID_PARAM")
+
+ def test_concurrency_missing_field_returns_400_not_500(self):
+ status, body = self._put("/api/settings/concurrency", {})
+ self.assertEqual(status, 400)
+ self.assertEqual(body["error"], "INVALID_PARAM")
+
+ def test_budget_update_creates_and_returns_config(self):
+ status, body = self._put(
+ "/api/settings/budget", {"scope": "global", "limit_type": "token_count", "limit_value": 5000}
+ )
+ self.assertEqual(status, 200)
+ self.assertEqual(body["config"]["limit_value"], 5000)
+
+ def test_budget_invalid_scope_returns_400(self):
+ status, body = self._put(
+ "/api/settings/budget", {"scope": "bogus", "limit_type": "token_count", "limit_value": 100}
+ )
+ self.assertEqual(status, 400)
+ self.assertEqual(body["error"], "INVALID_PARAM")
+
+ def test_budget_missing_limit_value_returns_400_not_500(self):
+ status, body = self._put("/api/settings/budget", {"scope": "global", "limit_type": "token_count"})
+ self.assertEqual(status, 400)
+ self.assertEqual(body["error"], "INVALID_PARAM")
+
+ def test_log_retention_get_returns_null_when_unconfigured(self):
+ status, body = self._get("/api/settings/log-retention")
+ self.assertEqual(status, 200)
+ self.assertIsNone(body)
+
+ def test_log_retention_update_and_get(self):
+ status, body = self._put("/api/settings/log-retention", {"retention_days": 30, "max_total_size_mb": 200})
+ self.assertEqual(status, 200)
+ self.assertEqual(body["retention_days"], 30)
+ status, body = self._get("/api/settings/log-retention")
+ self.assertEqual(body["max_total_size_mb"], 200)
+
+ def test_log_retention_zero_days_returns_400(self):
+ status, body = self._put("/api/settings/log-retention", {"retention_days": 0})
+ self.assertEqual(status, 400)
+ self.assertEqual(body["error"], "INVALID_PARAM")
+
+ def test_log_retention_missing_field_returns_400_not_500(self):
+ status, body = self._put("/api/settings/log-retention", {})
+ self.assertEqual(status, 400)
+ self.assertEqual(body["error"], "INVALID_PARAM")
+
+
class ZeroThirdPartyDependencyTest(unittest.TestCase):
"""05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this
doesn't silently rot if someone adds an import later."""
diff --git a/src/test_budget_guardrail.py b/src/test_budget_guardrail.py
index 78ac0f1..6eb3b01 100644
--- a/src/test_budget_guardrail.py
+++ b/src/test_budget_guardrail.py
@@ -101,5 +101,39 @@ class ResumeAfterLimitRaiseTest(BudgetGuardrailTestBase):
self.assertEqual(status, "blocked_pending_human")
+class UpsertBudgetConfigTest(BudgetGuardrailTestBase):
+ def test_updates_existing_config_preserving_current_usage(self):
+ self.conn.execute("UPDATE budget_guardrail_config SET current_usage = 42 WHERE config_id = 'default'")
+ self.conn.commit()
+ result = budget_guardrail.upsert_budget_config(self.conn, "global", "token_count", 500, "admin")
+ self.assertEqual(result["config"]["limit_value"], 500)
+ self.assertEqual(result["config"]["current_usage"], 42)
+
+ def test_creates_config_when_none_exists(self):
+ self.conn.execute("DELETE FROM budget_guardrail_config")
+ self.conn.commit()
+ result = budget_guardrail.upsert_budget_config(self.conn, "per_task", "call_count", 10, "admin")
+ self.assertEqual(result["config"]["current_usage"], 0)
+ self.assertEqual(result["config"]["scope"], "per_task")
+
+ def test_resumes_blocked_tasks_same_as_update_budget_limit(self):
+ with self.assertRaises(budget_guardrail.BudgetExceededError):
+ budget_guardrail.check_budget(self.conn, "task-1", "default", 150)
+ result = budget_guardrail.upsert_budget_config(self.conn, "global", "token_count", 1000, "admin")
+ self.assertEqual(result["resumed_task_ids"], ["task-1"])
+
+ def test_invalid_scope_rejected(self):
+ with self.assertRaises(budget_guardrail.InvalidParamError):
+ budget_guardrail.upsert_budget_config(self.conn, "not_a_scope", "token_count", 100, "admin")
+
+ def test_invalid_limit_type_rejected(self):
+ with self.assertRaises(budget_guardrail.InvalidParamError):
+ budget_guardrail.upsert_budget_config(self.conn, "global", "not_a_type", 100, "admin")
+
+ def test_zero_or_negative_limit_value_rejected(self):
+ with self.assertRaises(budget_guardrail.InvalidParamError):
+ budget_guardrail.upsert_budget_config(self.conn, "global", "token_count", 0, "admin")
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/src/test_settings_config.py b/src/test_settings_config.py
new file mode 100644
index 0000000..4e9036c
--- /dev/null
+++ b/src/test_settings_config.py
@@ -0,0 +1,76 @@
+"""U-030 tests: c_concurrency_config_update / c_log_retention_config_update.
+Real sqlite, no mocking needed.
+
+Run: python3 -m unittest src.test_settings_config -v
+"""
+
+import unittest
+
+from src import schema, settings_config
+
+
+class SettingsConfigTestBase(unittest.TestCase):
+ def setUp(self):
+ self.conn = schema.connect(":memory:")
+ schema.create_schema(self.conn)
+ self.conn.commit()
+
+ def tearDown(self):
+ self.conn.close()
+
+
+class ConcurrencyConfigTest(SettingsConfigTestBase):
+ def test_get_returns_none_when_unconfigured(self):
+ self.assertIsNone(settings_config.get_concurrency_config(self.conn))
+
+ def test_creates_config_on_first_use(self):
+ result = settings_config.upsert_concurrency_config(self.conn, 5, "admin")
+ self.assertEqual(result["max_concurrent_goals"], 5)
+ self.assertEqual(settings_config.get_concurrency_config(self.conn)["max_concurrent_goals"], 5)
+
+ def test_updates_existing_config(self):
+ settings_config.upsert_concurrency_config(self.conn, 5, "admin")
+ result = settings_config.upsert_concurrency_config(self.conn, 10, "admin2")
+ self.assertEqual(result["max_concurrent_goals"], 10)
+ self.assertEqual(
+ self.conn.execute("SELECT COUNT(*) FROM org_concurrency_config").fetchone()[0], 1
+ )
+
+ def test_below_range_rejected(self):
+ with self.assertRaises(settings_config.InvalidParamError):
+ settings_config.upsert_concurrency_config(self.conn, 0, "admin")
+
+ def test_above_range_rejected(self):
+ with self.assertRaises(settings_config.InvalidParamError):
+ settings_config.upsert_concurrency_config(self.conn, 51, "admin")
+
+ def test_range_boundaries_accepted(self):
+ settings_config.upsert_concurrency_config(self.conn, 1, "admin")
+ settings_config.upsert_concurrency_config(self.conn, 50, "admin")
+
+
+class LogRetentionConfigTest(SettingsConfigTestBase):
+ def test_get_returns_none_when_unconfigured(self):
+ self.assertIsNone(settings_config.get_log_retention_config(self.conn))
+
+ def test_creates_config_on_first_use(self):
+ result = settings_config.upsert_log_retention_config(self.conn, 14, "admin")
+ self.assertEqual(result["retention_days"], 14)
+ self.assertIsNone(result["max_total_size_mb"])
+
+ def test_updates_existing_config(self):
+ settings_config.upsert_log_retention_config(self.conn, 14, "admin")
+ result = settings_config.upsert_log_retention_config(self.conn, 30, "admin", max_total_size_mb=500)
+ self.assertEqual(result["retention_days"], 30)
+ self.assertEqual(result["max_total_size_mb"], 500)
+ self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM log_retention_config").fetchone()[0], 1)
+
+ def test_zero_or_negative_retention_days_rejected(self):
+ with self.assertRaises(settings_config.InvalidParamError):
+ settings_config.upsert_log_retention_config(self.conn, 0, "admin")
+ with self.assertRaises(settings_config.InvalidParamError):
+ settings_config.upsert_log_retention_config(self.conn, -5, "admin")
+
+
+if __name__ == "__main__":
+ unittest.main()