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()