Thesis Workshop redesign: edit/choose/delete + approve-as-current (v0.1.0:56)
Addresses Grant's feedback that the Workshop was confusing and underbuilt (no delete,
no approve, redundant generate-vs-feedback panels, and a stray "0" on segment lines).
Backend (architect_tools.py + server.py routes/handlers):
- retire_node: soft-delete a node + its subtree (reversible). DELETE /api/thesis/nodes/{id}.
- choose_variant: 'Use this' — keep this option, soft-delete the others in its group,
mark it approved. POST /api/thesis/nodes/{id}/choose.
- upsert_thesis_node gains actor_type so a manual human edit is recorded as 'human'.
PUT /api/thesis/nodes/{id} edits a part's text directly.
- handle_approve_line: one-click 'approve as current' — records this admin's approval on
the line's in-review version (creating + submitting one from the live tree if none),
promoting to canonical at the required distinct-approval count. POST /api/thesis/lines/{key}/approve.
Frontend (ThesisWorkshop redesign):
- Merged the redundant "Generate options" + "Give feedback" panels into one "Ask the
Architect for options" box (revise was just generate-with-guidance).
- Per option: Use this / Edit (inline) / Delete. Per part: edit + delete via the same.
- "Approve as current" bar with dual-sign-off state + a "Current ✓" badge, and a one-line
"how it works" hint. Refreshes the tree after every action.
- Fixed the stray "0": `{line.is_core && <badge>}` rendered 0 for non-core lines (SQLite
integer 0); now `{!!line.is_core && ...}`.
Verified: backend test_thesis_actions.py (choose/edit/retire-subtree/dual-approval->canonical),
and a live in-browser smoke test (JSX compiles, Workshop renders, options show Use/Edit/Delete,
approve returns 1-of-2, no runtime errors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -192,7 +192,7 @@ def create_thesis_line(line_key, name, segment_key=None, is_core=False, descript
|
||||
|
||||
def upsert_thesis_node(line_id, node_type, body, title=None, parent_id=None, ord=None,
|
||||
variant_group=None, node_id=None, change_reason=None, change_summary=None,
|
||||
actor_id="architect", claude_session_id=None, meta=None, db=None):
|
||||
actor_id="architect", actor_type="agent", claude_session_id=None, meta=None, db=None):
|
||||
"""Create or edit a node. On edit, the prior state is written to
|
||||
thesis_node_revisions before the live row changes (full provenance)."""
|
||||
c = _conn(db)
|
||||
@@ -209,7 +209,7 @@ def upsert_thesis_node(line_id, node_type, body, title=None, parent_id=None, ord
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(str(uuid.uuid4()), node_id, prev["line_id"], rev_no, prev["node_type"], prev["title"], prev["body"],
|
||||
prev["status"], prev["ord"], prev["variant_group"], prev["meta"], change_summary, change_reason,
|
||||
"agent", actor_id, claude_session_id, _now()))
|
||||
actor_type, actor_id, claude_session_id, _now()))
|
||||
c.execute("""UPDATE thesis_nodes SET node_type=?, title=COALESCE(?,title), body=?, ord=COALESCE(?,ord),
|
||||
variant_group=?, meta=COALESCE(?,meta), updated_at=? WHERE id=?""",
|
||||
(node_type, title, body, ord, variant_group, json.dumps(meta) if meta else None, _now(), node_id))
|
||||
@@ -280,6 +280,50 @@ def submit_version_for_review(version_id, db=None):
|
||||
return {"version_id": version_id, "status": "in_review"}
|
||||
|
||||
|
||||
def retire_node(node_id, actor_id="human", db=None):
|
||||
"""Soft-delete a node and its whole subtree (deleted_at). Reversible (guardrail #3);
|
||||
the node + descendants drop out of the tree but the rows + revision history remain."""
|
||||
c = _conn(db)
|
||||
row = c.execute("SELECT id FROM thesis_nodes WHERE id=? AND deleted_at IS NULL", (node_id,)).fetchone()
|
||||
if not row:
|
||||
c.close()
|
||||
return {"error": "not_found", "node_id": node_id}
|
||||
ids, frontier = [node_id], [node_id]
|
||||
while frontier:
|
||||
nxt = []
|
||||
for pid in frontier:
|
||||
for r in c.execute("SELECT id FROM thesis_nodes WHERE parent_id=? AND deleted_at IS NULL", (pid,)):
|
||||
ids.append(r["id"]); nxt.append(r["id"])
|
||||
frontier = nxt
|
||||
for nid in ids:
|
||||
c.execute("UPDATE thesis_nodes SET deleted_at=?, updated_at=? WHERE id=?", (_now(), _now(), nid))
|
||||
_log(c, "thesis.node_retired", node_id, {"count": len(ids)}, actor_id=actor_id, actor_type="human")
|
||||
c.commit()
|
||||
c.close()
|
||||
return {"retired": node_id, "count": len(ids)}
|
||||
|
||||
|
||||
def choose_variant(node_id, actor_id="human", db=None):
|
||||
"""'Use this option': keep this variant, soft-delete the OTHER variants in its group,
|
||||
and mark it approved. Collapses a node's competing options down to the chosen one."""
|
||||
c = _conn(db)
|
||||
node = c.execute("SELECT id, variant_group FROM thesis_nodes WHERE id=? AND deleted_at IS NULL", (node_id,)).fetchone()
|
||||
if not node:
|
||||
c.close()
|
||||
return {"error": "not_found", "node_id": node_id}
|
||||
retired = []
|
||||
if node["variant_group"]:
|
||||
for r in c.execute("SELECT id FROM thesis_nodes WHERE variant_group=? AND id<>? AND deleted_at IS NULL",
|
||||
(node["variant_group"], node_id)):
|
||||
c.execute("UPDATE thesis_nodes SET deleted_at=?, updated_at=? WHERE id=?", (_now(), _now(), r["id"]))
|
||||
retired.append(r["id"])
|
||||
c.execute("UPDATE thesis_nodes SET status='approved', updated_at=? WHERE id=?", (_now(), node_id))
|
||||
_log(c, "thesis.variant_chosen", node_id, {"retired_siblings": len(retired)}, actor_id=actor_id, actor_type="human")
|
||||
c.commit()
|
||||
c.close()
|
||||
return {"chosen": node_id, "retired_siblings": len(retired)}
|
||||
|
||||
|
||||
def upsert_segment(segment_key, name, definition=None, needs_to_hear=None, avoid=None, db=None):
|
||||
"""Create/replace a segment's active definition (retire the prior active row)."""
|
||||
c = _conn(db)
|
||||
|
||||
@@ -1898,6 +1898,10 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_node_feedback(user, path.split('/')[-2], body)
|
||||
if path == '/api/architect/ground':
|
||||
return self.handle_architect_ground(user, body)
|
||||
if re.match(r'^/api/thesis/nodes/[^/]+/choose$', path):
|
||||
return self.handle_choose_variant(user, path.split('/')[-2])
|
||||
if re.match(r'^/api/thesis/lines/[^/]+/approve$', path):
|
||||
return self.handle_approve_line(user, path.split('/')[-2])
|
||||
if path == '/api/thesis/lines':
|
||||
return self.handle_create_thesis_line(user, body)
|
||||
if re.match(r'^/api/thesis/lines/[^/]+/nodes$', path):
|
||||
@@ -1938,6 +1942,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_update_lp_profile(user, path.split('/')[-1], body)
|
||||
if path == '/api/fundraising/state':
|
||||
return self.handle_update_fundraising_state(user, body)
|
||||
if re.match(r'^/api/thesis/nodes/[^/]+$', path):
|
||||
return self.handle_edit_thesis_node(user, path.split('/')[-1], body)
|
||||
|
||||
self.send_error_json("Not found", 404)
|
||||
|
||||
@@ -1987,6 +1993,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_delete_opportunity(user, path.split('/')[-1])
|
||||
if re.match(r'^/api/communications/[^/]+$', path):
|
||||
return self.handle_delete_communication(user, path.split('/')[-1])
|
||||
if re.match(r'^/api/thesis/nodes/[^/]+$', path):
|
||||
return self.handle_retire_thesis_node(user, path.split('/')[-1])
|
||||
self.send_error_json("Not found", 404)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -3669,6 +3677,73 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
return row['line_key'] if row else None
|
||||
|
||||
def handle_edit_thesis_node(self, user, node_id, body):
|
||||
"""Manual human edit of a node's title/body (no Architect)."""
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
if _architect_tools is None:
|
||||
return self.send_error_json("Unavailable", 503)
|
||||
node = _architect_tools.get_node(node_id, db=DB_PATH)
|
||||
if node.get('error'):
|
||||
return self.send_error_json("Node not found", 404)
|
||||
body = body or {}
|
||||
res = _architect_tools.upsert_thesis_node(
|
||||
node['line_id'], node['node_type'], body.get('body', node.get('body') or ''),
|
||||
title=body.get('title', node.get('title')), node_id=node_id,
|
||||
change_reason='manual edit', actor_id=user['user_id'], actor_type='human', db=DB_PATH)
|
||||
return self.send_json({"data": res})
|
||||
|
||||
def handle_retire_thesis_node(self, user, node_id):
|
||||
"""Soft-delete a node + its subtree."""
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
if _architect_tools is None:
|
||||
return self.send_error_json("Unavailable", 503)
|
||||
res = _architect_tools.retire_node(node_id, actor_id=user['user_id'], db=DB_PATH)
|
||||
if res.get('error'):
|
||||
return self.send_error_json("Node not found", 404)
|
||||
return self.send_json({"data": res})
|
||||
|
||||
def handle_choose_variant(self, user, node_id):
|
||||
"""'Use this option' — keep this variant, retire its siblings."""
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
if _architect_tools is None:
|
||||
return self.send_error_json("Unavailable", 503)
|
||||
res = _architect_tools.choose_variant(node_id, actor_id=user['user_id'], db=DB_PATH)
|
||||
if res.get('error'):
|
||||
return self.send_error_json("Node not found", 404)
|
||||
return self.send_json({"data": res})
|
||||
|
||||
def handle_approve_line(self, user, line_key):
|
||||
"""One-click 'approve as current': record this admin's approval on the line's
|
||||
in-review version (creating + submitting one from the live tree if none exists);
|
||||
promotes to canonical once the required distinct approvals are reached."""
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
if _architect_tools is None or thesis_review is None:
|
||||
return self.send_error_json("Unavailable", 503)
|
||||
conn = get_db()
|
||||
line = conn.execute("SELECT id FROM thesis_lines WHERE line_key=? AND deleted_at IS NULL", (line_key,)).fetchone()
|
||||
v = None
|
||||
if line:
|
||||
v = conn.execute("SELECT id FROM thesis_versions WHERE line_id=? AND status='in_review' ORDER BY version_no DESC LIMIT 1",
|
||||
(line['id'],)).fetchone()
|
||||
conn.close()
|
||||
if not line:
|
||||
return self.send_error_json("Line not found", 404)
|
||||
if v:
|
||||
version_id = v['id']
|
||||
else:
|
||||
ver = _architect_tools.create_thesis_version(line_key, rationale="Approved as current (Workshop)",
|
||||
created_by=user['user_id'], db=DB_PATH)
|
||||
if ver.get('error'):
|
||||
return self.send_error_json(ver['error'], 400)
|
||||
_architect_tools.submit_version_for_review(ver['id'], db=DB_PATH)
|
||||
version_id = ver['id']
|
||||
res = thesis_review.record_review(DB_PATH, version_id, user['user_id'], 'approve')
|
||||
return self.send_json({"data": res})
|
||||
|
||||
def handle_generate_options(self, user, node_id, body):
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backend test for the Workshop's new thesis actions: edit, retire-subtree,
|
||||
choose-variant, and one-click dual-approval -> canonical. Runs init_db (which seeds the
|
||||
v5 thesis) against a throwaway DB, then exercises the architect_tools + thesis_review
|
||||
functions the new routes call. Offline + synthetic.
|
||||
|
||||
Run: cd backend && python3 test_thesis_actions.py
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
_tmp = tempfile.mkdtemp()
|
||||
os.environ["CRM_DATA_DIR"] = _tmp
|
||||
os.environ["CRM_DB_PATH"] = os.path.join(_tmp, "crm.db")
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import server # noqa: E402 (the CRM server; it imports architect_tools + thesis_review for us)
|
||||
at = server._architect_tools
|
||||
thesis_review = server.thesis_review
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" PASS " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
def main():
|
||||
server.init_db()
|
||||
db = server.DB_PATH
|
||||
c = sqlite3.connect(db)
|
||||
c.row_factory = sqlite3.Row
|
||||
|
||||
# choose-variant: pick Option A in the positioning group -> Option B retired
|
||||
opts = c.execute("SELECT id, title FROM thesis_nodes WHERE variant_group='positioning' AND deleted_at IS NULL").fetchall()
|
||||
check(len(opts) == 2, f"positioning starts with 2 options (got {len(opts)})")
|
||||
a = next(o for o in opts if "Option A" in (o["title"] or ""))
|
||||
res = at.choose_variant(a["id"], db=db)
|
||||
check(res.get("retired_siblings") == 1, "choose_variant retired the 1 sibling")
|
||||
live = c.execute("SELECT COUNT(*) FROM thesis_nodes WHERE variant_group='positioning' AND deleted_at IS NULL").fetchone()[0]
|
||||
check(live == 1, f"positioning now has 1 live option (got {live})")
|
||||
check(c.execute("SELECT status FROM thesis_nodes WHERE id=?", (a["id"],)).fetchone()[0] == "approved",
|
||||
"chosen option marked approved")
|
||||
|
||||
# edit: manual human edit updates body + writes a revision
|
||||
thr = c.execute("SELECT id FROM thesis_nodes WHERE node_type='throughline' AND deleted_at IS NULL").fetchone()
|
||||
at.upsert_thesis_node(None, "throughline", "EDITED THROUGHLINE.", node_id=thr["id"], actor_type="human",
|
||||
change_reason="manual edit", actor_id="u1", db=db)
|
||||
check(c.execute("SELECT body FROM thesis_nodes WHERE id=?", (thr["id"],)).fetchone()[0] == "EDITED THROUGHLINE.",
|
||||
"manual edit updated the node body")
|
||||
rev = c.execute("SELECT actor_type FROM thesis_node_revisions WHERE node_id=? ORDER BY rev_no DESC LIMIT 1", (thr["id"],)).fetchone()
|
||||
check(rev and rev[0] == "human", "edit recorded a 'human' revision")
|
||||
|
||||
# retire-subtree: retiring the Pillars section drops it + its 3 pillar claims
|
||||
sec = c.execute("SELECT id FROM thesis_nodes WHERE node_type='section' AND title='Pillars' AND deleted_at IS NULL").fetchone()
|
||||
kids = c.execute("SELECT COUNT(*) FROM thesis_nodes WHERE parent_id=? AND deleted_at IS NULL", (sec["id"],)).fetchone()[0]
|
||||
res = at.retire_node(sec["id"], db=db)
|
||||
check(res.get("count") == kids + 1, f"retire_node soft-deleted the section + {kids} children (got {res.get('count')})")
|
||||
check(c.execute("SELECT deleted_at FROM thesis_nodes WHERE id=?", (sec["id"],)).fetchone()[0] is not None,
|
||||
"section is soft-deleted")
|
||||
|
||||
# one-click dual approval -> canonical (two distinct admins)
|
||||
ver = at.create_thesis_version("core", created_by="u1", db=db)
|
||||
at.submit_version_for_review(ver["id"], db=db)
|
||||
r1 = thesis_review.record_review(db, ver["id"], "u1", "approve")
|
||||
check(r1["approvals"] == 1 and not r1["promoted_to_canonical"], "1st approval does not promote")
|
||||
r2 = thesis_review.record_review(db, ver["id"], "u2", "approve")
|
||||
check(r2["approvals"] == 2 and r2["promoted_to_canonical"], "2nd distinct approval promotes to canonical")
|
||||
check(thesis_review.get_canonical(db, "core").get("status") == "ok", "core line now has a canonical version")
|
||||
# a 2nd approval from the SAME admin would NOT have promoted (distinct approvers only) — covered by record_review
|
||||
|
||||
c.close()
|
||||
print()
|
||||
if FAILS:
|
||||
print(f"FAILED ({len(FAILS)})")
|
||||
sys.exit(1)
|
||||
print("ALL PASS (thesis workshop actions)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user