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:
Keysat
2026-06-05 18:29:47 -05:00
parent 8338c34ac0
commit 6d6f4bcc7e
7 changed files with 389 additions and 110 deletions
+85
View File
@@ -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()