feat(statehub): add offline write buffer relay

This commit is contained in:
2026-06-25 13:44:27 +02:00
parent 63f0398304
commit b536741539
21 changed files with 1963 additions and 25 deletions

View File

@@ -572,7 +572,7 @@ def _api_patch(api_base: str, path: str, body: dict) -> Any:
def _api_post(api_base: str, path: str, body: dict) -> Any:
if not _HAS_HTTPX:
return None
return {"_error": "httpx is not installed"}
if not path.endswith("/"):
path += "/"
try:
@@ -580,8 +580,13 @@ def _api_post(api_base: str, path: str, body: dict) -> Any:
r = c.post(path, json=body)
r.raise_for_status()
return r.json()
except Exception:
return None
except _httpx.HTTPStatusError as exc:
detail = exc.response.text
if len(detail) > 500:
detail = detail[:497] + "..."
return {"_error": f"{exc.response.status_code} {exc.response.reason_phrase}: {detail}"}
except Exception as exc:
return {"_error": str(exc)}
# ---------------------------------------------------------------------------
@@ -836,6 +841,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
"repo_id": repo_id,
"domain": file_domain,
"repo_market_domain": repo_market_domain,
"repo_slug": repo_slug,
},
)
continue
@@ -1019,11 +1025,13 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
existing_dep_keys = set()
if isinstance(existing_deps, list):
for dep in existing_deps:
if dep.get("from_workstream_id") != ws_id:
from_id = dep.get("from_workstream_id") or dep.get("from_workplan_id")
if from_id != ws_id:
continue
rel = dep.get("relationship_type") or "blocks"
if dep.get("to_workstream_id"):
existing_dep_keys.add(("workstream", dep["to_workstream_id"], rel))
to_workplan_id = dep.get("to_workstream_id") or dep.get("to_workplan_id")
if to_workplan_id:
existing_dep_keys.add(("workstream", to_workplan_id, rel))
if dep.get("to_task_id"):
existing_dep_keys.add(("task", dep["to_task_id"], rel))
@@ -1770,20 +1778,58 @@ def fix_repo(
)
continue
slug = re.sub(r"[^a-z0-9-]", "-", wp_id.lower()).strip("-")
ws_data = _api_post(api_base, "/workstreams", {
"topic_id": topic_id,
"repo_id": repo_id_val,
"slug": slug,
"title": title or wp_id,
"status": status,
"owner": str(meta.get("owner", "")).strip() or None,
"planning_priority": str(meta.get("planning_priority", "")).strip() or None,
"planning_order": _as_int_or_none(meta.get("planning_order")),
})
base_slug = re.sub(r"[^a-z0-9-]", "-", wp_id.lower()).strip("-") or "workplan"
repo_slug_part = re.sub(
r"[^a-z0-9-]", "-", str(ctx.get("repo_slug") or "").lower()
).strip("-")
slug_candidates = [base_slug]
repo_qualified_slug = base_slug
if repo_slug_part and not base_slug.startswith(f"{repo_slug_part}-"):
repo_qualified_slug = f"{repo_slug_part}-{base_slug}"
slug_candidates.append(repo_qualified_slug)
for suffix in range(2, 21):
slug_candidates.append(f"{repo_qualified_slug}-{suffix}")
ws_data = None
last_error = None
for slug in slug_candidates:
existing = _api_get(api_base, "/workstreams", {"slug": slug}, return_error=True)
if isinstance(existing, dict) and "_error" in existing:
last_error = existing["_error"]
continue
if isinstance(existing, list) and existing:
existing_same_repo = next(
(w for w in existing if w.get("repo_id") == repo_id_val),
None,
)
if existing_same_repo and existing_same_repo.get("title") == (title or wp_id):
ws_data = existing_same_repo
report.fixes_applied.append(
f"C-06 reusing existing workstream {ws_data['id'][:8]}... for {wp_id}"
)
break
last_error = f"slug {slug!r} already belongs to another workstream"
continue
ws_data = _api_post(api_base, "/workstreams", {
"topic_id": topic_id,
"repo_id": repo_id_val,
"slug": slug,
"title": title or wp_id,
"status": status,
"owner": str(meta.get("owner", "")).strip() or None,
"planning_priority": str(meta.get("planning_priority", "")).strip() or None,
"planning_order": _as_int_or_none(meta.get("planning_order")),
})
if ws_data is None or (isinstance(ws_data, dict) and "_error" in ws_data):
last_error = ws_data.get("_error") if isinstance(ws_data, dict) else "no response"
ws_data = None
continue
break
if ws_data is None:
report.fixes_applied.append(
f"C-06 FAIL {wp_id}: could not create workstream in DB"
f"C-06 FAIL {wp_id}: could not create workstream in DB: {last_error or 'no usable slug'}"
)
continue
@@ -1814,7 +1860,7 @@ def fix_repo(
"priority": t_priority,
"assignee": task.get("assignee") or None,
})
if t_data:
if t_data and "_error" not in t_data:
t_db_id = t_data["id"]
injected = _inject_task_id_into_block(
wp_file, "state_hub_task_id", t_db_id, t_id
@@ -1822,6 +1868,10 @@ def fix_repo(
if not injected:
_inject_task_id_frontmatter_list(wp_file, t_db_id, t_id)
report.fixes_applied.append(f" + task {t_id}{t_db_id[:8]}")
elif t_data:
report.fixes_applied.append(
f" ! task {t_id} not created: {t_data.get('_error', t_data)}"
)
elif issue.check_id == "C-09":
ws_id = ctx["ws_id"]

View File

@@ -20,6 +20,12 @@ there is no MCP server for Codex agents.
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
| Optional local edge relay | http://127.0.0.1:18080 |
When an operator has enabled the edge relay, set API_BASE to the relay URL.
Queueable writes return an explicit queued receipt if the central hub is
unreachable. Treat that as pending local evidence, then ask the operator to run
statehub outbox status/replay after connectivity returns.
### Orient at session start