Files
sortof/docs/plans/2026-04-30-multi-branch-picker.md

1126 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Multi-Branch Picker Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stop emitting all branches of a multi-mod Workshop item (e.g. AuthenticZ) into `Mods=`, which silently bricks PZ servers. Add a per-wsid picker UI plus a `/api/resort` endpoint that recomputes load order and warnings against the user's selection.
**Architecture:** Spec at `/opt/sortof/docs/specs/2026-04-30-multi-branch-picker.md` (v2, 155 lines, all decisions locked in §10). Backend grows one new POST endpoint and one new WARNINGS tag. Frontend adds picker state, localStorage persistence, a `BranchPicker` sub-component, and resort fetch with sequence-number race protection. No schema migration. No new venv.
**Tech Stack:** FastAPI + asyncpg + Pydantic on the backend; React 18 + Babel-standalone (in-browser JSX, no build) on the frontend; CSS in `index.html`. Persistence via `localStorage` only.
---
## File structure
| Path | Action | Responsibility |
|---|---|---|
| `/opt/sortof/api/app.py` | Modify | Add `ResortRequest` model + `POST /api/resort` route. No other change. |
| `/opt/sortof/api/adapters.py` | Modify | Add `_compute_ambiguous_warnings()`. Inject result into `build_response`. |
| `/opt/sortof/frontend/sortof-app.jsx` | Modify | Add `COLUMN_COUNT`, `branchSelections` state + hydration + storage listener, `BranchPicker` component, refactor `ModTable` to group multi-branch wsids, `resort()` with sequence number, 5xx handling. |
| `/opt/sortof/frontend/index.html` | Modify | CSS for `.branch-affordance`, `.branch-panel`, `.branch-row`. |
No new files. The two `mlos_sort.py` copies are unchanged - dangling-deps detection already exists in `mlos_sort.sort_mods`.
**Verification fixtures (already cached, no /api/sort cold-start required):**
- 3-mod canonical (single-mod wsids, regression baseline): `2169435993;2392709985;2487022075``MODS_LINE="modoptions;tsarslib;TMC_TrueActions"`.
- AuthenticZ multi-branch (3 mod_ids on one wsid, all `incompatible_mods=[]`): wsid `2335368829` → mod_ids `Authentic Z - Current`, `AuthenticZBackpacks+`, `AuthenticZLite`.
---
## Task 1: Backend - `POST /api/resort` endpoint
**Files:**
- Modify: `/opt/sortof/api/app.py` (add Pydantic model + route)
- [ ] **Step 1: Backup app.py**
```bash
cp /opt/sortof/api/app.py /opt/sortof/api/app.py.bak-$(date +%Y%m%d-%H%M)
```
- [ ] **Step 2: Add `ResortRequest` Pydantic model**
Find the existing `class SortRequest(BaseModel):` block (around line 76). Replace it with both models:
```python
class SortRequest(BaseModel):
input: str = Field(default="")
rules: Optional[str] = Field(default=None)
class ResortRequest(BaseModel):
selected_mod_ids: List[str] = Field(default_factory=list)
```
- [ ] **Step 3: Add `/api/resort` route**
Find the line right after the existing `sort_endpoint` function returns (the line `return payload` near the bottom of the function - currently line ~236). The static-files mount block follows. Insert the new route **between** them:
```python
@app.post("/api/resort")
async def resort_endpoint(req: ResortRequest, request: Request) -> Dict[str, Any]:
"""Re-run mlos_sort against a user-supplied subset of mod_ids.
Stateless. No Steam round-trip. Filters mod_parsed by the requested
mod_ids, runs sort_mods, returns the same shape as /api/sort.
"""
t0 = time.monotonic()
ids = list(req.selected_mod_ids or [])
if not ids:
raise HTTPException(status_code=400, detail="selected_mod_ids must be non-empty")
if len(ids) > MAX_IDS:
raise HTTPException(status_code=413, detail=f"too many mod_ids ({len(ids)} > {MAX_IDS})")
for mod_id in ids:
if not isinstance(mod_id, str) or not (1 <= len(mod_id) <= 256):
raise HTTPException(status_code=400, detail="invalid mod_id")
pool = request.app.state.db
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT mp.workshop_id, mp.mod_id, mp.name, mp.category,
mp.requirements, mp.load_after, mp.load_before,
mp.incompatible_mods, mp.load_first, mp.load_last,
mp.tags, mp.maps
FROM mod_parsed mp
JOIN workshop_meta wm ON wm.workshop_id = mp.workshop_id
WHERE mp.mod_id = ANY($1::text[])
AND mp.parsed_at_time_updated = wm.time_updated
ORDER BY mp.workshop_id, mp.mod_id
""",
ids,
)
found_ids = {r["mod_id"] for r in rows}
missing = [mid for mid in ids if mid not in found_ids]
if missing:
log.info("resort dropped unknown mod_ids count=%d sample=%s",
len(missing), missing[:3])
if not rows:
raise HTTPException(status_code=400, detail="no known mod_ids in selection")
mods: List[ModInfo] = [
ModInfo(
id=r["mod_id"],
name=r["name"] or r["mod_id"],
workshop_id=r["workshop_id"],
category=r["category"] or "undefined",
requirements=list(r["requirements"] or []),
loadAfter=list(r["load_after"] or []),
loadBefore=list(r["load_before"] or []),
incompatibleMods=list(r["incompatible_mods"] or []),
loadFirst=r["load_first"] or "off",
loadLast=r["load_last"] or "off",
tags=list(r["tags"] or []),
maps=list(r["maps"] or []),
)
for r in rows
]
sort_result = sort_mods(mods, {})
payload = adapters.build_response(
input_ids=[],
hit_ids=ids,
mods=mods,
sort_result=sort_result,
status="success",
)
payload["pending"] = []
elapsed_ms = int((time.monotonic() - t0) * 1000)
log.info("resort done count=%d ms=%d", len(rows), elapsed_ms)
return payload
```
- [ ] **Step 4: py_compile + smoke import**
```bash
/opt/sortof/api/.venv/bin/python -m py_compile /opt/sortof/api/app.py && echo PY_OK
cd /opt/sortof/api && .venv/bin/python -c "import app" && echo IMPORT_OK
```
Expected: both lines print `PY_OK` and `IMPORT_OK` respectively. If either fails, fix the syntax/import before restarting.
- [ ] **Step 5: Restart API**
```bash
sudo systemctl restart sortof-api && sleep 2 && sudo systemctl is-active sortof-api
```
Expected: `active`.
- [ ] **Step 6: Verify happy path**
```bash
curl -sS -X POST http://100.114.205.53:8801/api/resort \
-H 'Content-Type: application/json' \
-d '{"selected_mod_ids":["modoptions","tsarslib","TMC_TrueActions"]}' \
| jq '{status, MODS_LINE, mod_db_count: (.MOD_DB|length), pending}'
```
Expected:
```json
{"status":"success","MODS_LINE":"modoptions;tsarslib;TMC_TrueActions","mod_db_count":3,"pending":[]}
```
- [ ] **Step 7: Verify validation 400s**
```bash
# Empty selection → 400
curl -sS -o /dev/null -w 'empty=%{http_code}\n' -X POST http://100.114.205.53:8801/api/resort \
-H 'Content-Type: application/json' -d '{"selected_mod_ids":[]}'
# All-unknown selection → 400
curl -sS -o /dev/null -w 'unknown=%{http_code}\n' -X POST http://100.114.205.53:8801/api/resort \
-H 'Content-Type: application/json' -d '{"selected_mod_ids":["ghostA","ghostB"]}'
# Mixed (one valid + one unknown) → 200, ghost dropped
curl -sS -X POST http://100.114.205.53:8801/api/resort \
-H 'Content-Type: application/json' -d '{"selected_mod_ids":["modoptions","ghostMod"]}' \
| jq '{status, mod_db_count: (.MOD_DB|length)}'
```
Expected:
```
empty=400
unknown=400
{"status":"success","mod_db_count":1}
```
- [ ] **Step 8: Checkpoint** - task complete; backup file remains as rollback.
---
## Task 2: Backend - `ambiguous-multi-branch` warning
**Files:**
- Modify: `/opt/sortof/api/adapters.py`
- [ ] **Step 1: Backup adapters.py**
```bash
cp /opt/sortof/api/adapters.py /opt/sortof/api/adapters.py.bak-$(date +%Y%m%d-%H%M)
```
- [ ] **Step 2: Add helper for ambiguous-multi-branch detection**
Open `/opt/sortof/api/adapters.py`. Just below the existing `build_warnings()` function (around line 56), insert:
```python
def _compute_ambiguous_warnings(mods: List[ModInfo]) -> List[Dict[str, str]]:
"""Emit one amber WARNINGS entry per wsid that has >=2 selected mod_ids,
none of them flagging others in `incompatibleMods`. The user is at risk
of shipping conflicting alternates (e.g. AuthenticZ Lite + Current);
show this even when they don't expand the picker.
Spec: 2026-04-30-multi-branch-picker.md §4 "default-selection safety net"
(review #1 fix).
"""
by_wsid: Dict[str, List[ModInfo]] = {}
for m in mods:
wsid = m.workshop_id or ""
if not wsid:
continue
by_wsid.setdefault(wsid, []).append(m)
out: List[Dict[str, str]] = []
for wsid, group in by_wsid.items():
if len(group) < 2:
continue
ids_in_group = {m.id for m in group}
any_flags_sibling = any(
any(other in ids_in_group for other in m.incompatibleMods)
for m in group
)
if any_flags_sibling:
continue # author marked alternates; picker will run in radio mode
title = group[0].name or wsid
out.append({
"tag": "ambiguous-multi-branch",
"level": "amber",
"msg": (
f"{len(group)} branches selected from {title} ({wsid}) - "
f"author didn't declare alternates; verify these aren't "
f"mutually exclusive (e.g., AuthenticZ Lite vs Current). "
f"Expand the row to pick one."
),
})
return out
```
- [ ] **Step 3: Wire helper into `build_response`**
Find the existing `return { ... }` at the bottom of `build_response()` (around line 103-112). Replace the `"WARNINGS": build_warnings(...)` line with:
```python
"WARNINGS": (
build_warnings(sort_result.get("warnings", {}) or {})
+ _compute_ambiguous_warnings(mods)
),
```
- [ ] **Step 4: py_compile + smoke import**
```bash
/opt/sortof/api/.venv/bin/python -m py_compile /opt/sortof/api/adapters.py && echo PY_OK
cd /opt/sortof/api && .venv/bin/python -c "import app" && echo IMPORT_OK
```
- [ ] **Step 5: Restart API**
```bash
sudo systemctl restart sortof-api && sleep 2 && sudo systemctl is-active sortof-api
```
- [ ] **Step 6: Verify warning fires for AuthenticZ**
```bash
curl -sS -X POST http://100.114.205.53:8801/api/sort \
-H 'Content-Type: application/json' \
-d '{"input":"2335368829"}' \
| jq '.WARNINGS[] | select(.tag == "ambiguous-multi-branch")'
```
Expected: one object with `tag:"ambiguous-multi-branch"`, `level:"amber"`, and a `msg` mentioning "3 branches" and `2335368829`.
- [ ] **Step 7: Verify warning DOES NOT fire for the canonical 3-mod input**
```bash
curl -sS -X POST http://100.114.205.53:8801/api/sort \
-H 'Content-Type: application/json' \
-d '{"input":"2169435993;2392709985;2487022075"}' \
| jq '[.WARNINGS[] | select(.tag == "ambiguous-multi-branch")] | length'
```
Expected: `0` (each wsid has only 1 mod row → no ambiguity).
- [ ] **Step 8: Verify warning DOES NOT fire when subset is selected via /api/resort**
```bash
curl -sS -X POST http://100.114.205.53:8801/api/resort \
-H 'Content-Type: application/json' \
-d '{"selected_mod_ids":["Authentic Z - Current"]}' \
| jq '[.WARNINGS[] | select(.tag == "ambiguous-multi-branch")] | length'
```
Expected: `0` (one mod from the wsid, not a multi-branch ambiguity).
---
## Task 3: Frontend - `COLUMN_COUNT` + `branchSelections` state + hydration + storage listener
**Files:**
- Modify: `/opt/sortof/frontend/sortof-app.jsx`
- [ ] **Step 1: Backup**
```bash
cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M)
```
- [ ] **Step 2: Add `COLUMN_COUNT` constant**
Find the helper functions near the top of the file (after `buildModsLine`, around line 35). Add:
```jsx
// Shared column count for the Mod Details table. Keep in sync with the
// <thead> in ModTable; expansion-panel <tr colSpan> reads this constant.
// Spec C will add a 7th column; bump here, do not hardcode the integer.
const COLUMN_COUNT = 6;
```
- [ ] **Step 3: Add `branchSelections` App-level state with localStorage hydration**
Find the App function's state declarations (around line 456). Below the existing `pzBuild` block, add:
```jsx
const [branchSelections, setBranchSelections] = useState(() => {
try {
const raw = localStorage.getItem('sortof.branch.selections');
if (!raw) return {};
const parsed = JSON.parse(raw);
return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
} catch { return {}; }
});
const [resortSeq, setResortSeq] = useState(0); // monotonic; for stale-response drop
```
- [ ] **Step 4: Add useEffect for persist + cross-tab `storage` listener**
Below the `pzBuild` localStorage useEffect (around line 467), add:
```jsx
useEffect(() => {
try {
localStorage.setItem('sortof.branch.selections', JSON.stringify(branchSelections));
} catch {}
}, [branchSelections]);
useEffect(() => {
function onStorage(e) {
if (e.key !== 'sortof.branch.selections' || e.newValue === null) return;
try {
const parsed = JSON.parse(e.newValue);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
setBranchSelections(parsed);
}
} catch {}
}
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
```
- [ ] **Step 5: Verify served file picks up the new constants**
```bash
curl -sS http://100.114.205.53:8801/sortof-app.jsx | grep -cE 'COLUMN_COUNT|branchSelections|sortof.branch.selections'
```
Expected: `>= 6` (constant declaration + state name uses + localStorage key uses).
- [ ] **Step 6: Manual browser smoke test**
In a browser tab on `http://100.114.205.53:8801/`:
1. Open DevTools console.
2. Run `localStorage.setItem('sortof.branch.selections', JSON.stringify({"2335368829": ["Authentic Z - Current"]}))`.
3. Hard-refresh the page.
4. In console: `localStorage.getItem('sortof.branch.selections')` - should return the same JSON.
5. App should render without errors (no behavior change yet - picker UI is Task 4-5).
If the console shows JSON parse errors or "branchSelections is not defined", revert to backup and re-do.
---
## Task 4: Frontend - `ModTable` refactor to group multi-branch wsids
**Files:**
- Modify: `/opt/sortof/frontend/sortof-app.jsx` (replace `ModTable` body around line 306; pass `branchSelections` from App)
- [ ] **Step 1: Backup** (if more than ~10 minutes since last)
```bash
cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M)
```
- [ ] **Step 2: Replace `ModTable` with the wsid-grouping version**
Find the existing `function ModTable({ defaultOpen = false }) {` block (around line 306) and the closing `}` of that function (around line 351). Replace the entire function with:
```jsx
function ModTable({ defaultOpen = false, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion }) {
const [open, setOpen] = useState(defaultOpen);
// Group MOD_DB rows by wsid. For wsids with >1 mod, render a single parent
// row and an expansion <tr colSpan={COLUMN_COUNT}> with the picker.
const groupedByWsid = useMemo(() => {
const g = {};
for (const m of (D.MOD_DB || [])) {
const w = m.wsid || '';
if (!w) continue;
(g[w] = g[w] || []).push(m);
}
return g;
}, [D.MOD_DB]);
// Walk SORTED_ORDER and emit one row-spec per wsid (deduped).
const rowSpecs = useMemo(() => {
const specs = [];
const seenWsid = new Set();
for (const modId of (D.SORTED_ORDER || [])) {
const m = (D.MOD_DB || []).find(x => x.modId === modId);
if (!m || !m.wsid) continue;
if (seenWsid.has(m.wsid)) continue;
seenWsid.add(m.wsid);
const branches = groupedByWsid[m.wsid] || [m];
const isMulti = branches.length >= 2;
specs.push({ wsid: m.wsid, primary: m, branches, isMulti });
}
// Wsids whose parent had zero selected mods: not in SORTED_ORDER. Append.
for (const w of Object.keys(groupedByWsid)) {
if (!seenWsid.has(w)) {
const branches = groupedByWsid[w];
specs.push({ wsid: w, primary: branches[0], branches, isMulti: branches.length >= 2 });
}
}
return specs;
}, [D.SORTED_ORDER, D.MOD_DB, groupedByWsid]);
return (
<div className="panel table-section">
<div className="tbl-head" onClick={() => setOpen(!open)}>
<span>mod details</span>
<span className="count">· {(D.SORTED_ORDER || []).length} mods</span>
<span style={{ color: 'var(--fg-3)', fontSize: 10.5, marginLeft: 8 }}>
why everything ended up where it did
</span>
<span className="chev">{open ? '▾' : '▸'}</span>
</div>
{open && (
<table className="mods-table">
<thead>
<tr>
<th>#</th>
<th>mod id</th>
<th>workshop id</th>
<th>category</th>
<th>dependencies</th>
<th>load</th>
</tr>
</thead>
<tbody>
{rowSpecs.map((spec, i) => {
const idx = String(i + 1).padStart(2, '0');
if (!spec.isMulti) {
const m = spec.primary;
return (
<tr key={spec.wsid}>
<td className="idx">{idx}</td>
<td><span className="modid">{m.modId}</span></td>
<td><span className="wsid">{spec.wsid}</span></td>
<td><span className={'cat ' + m.cat}>{m.cat}</span></td>
<td><span className="deps">{m.deps && m.deps.length ? m.deps.join(', ') : '-'}</span></td>
<td>
{m.pos === 'first' && <span className="pos first">first</span>}
{m.pos === 'last' && <span className="pos last">last</span>}
{!m.pos && <span className="pos">-</span>}
</td>
</tr>
);
}
// Multi-branch wsid: parent row + (optional) expansion panel.
const selected = branchSelections[spec.wsid] || [];
const N = spec.branches.length;
const X = selected.length;
const userTouched = (spec.wsid in branchSelections);
const affordance = userTouched ? `${X} of ${N}` : `${N} branches`;
const firstSelected = spec.branches.find(b => selected.includes(b.modId)) || spec.branches[0];
const showAsZero = userTouched && X === 0;
const display = showAsZero ? null : firstSelected;
const expanded = expandedWsids.has(spec.wsid);
return (
<React.Fragment key={spec.wsid}>
<tr>
<td className="idx">{idx}</td>
<td>
<button className="branch-affordance"
onClick={() => onToggleExpansion(spec.wsid)}>
{affordance}
</button>
</td>
<td><span className="wsid">{spec.wsid}</span></td>
<td>{display ? <span className={'cat ' + display.cat}>{display.cat}</span> : '-'}</td>
<td>{display && display.deps && display.deps.length ? display.deps.join(', ') : '-'}</td>
<td>
{display && display.pos === 'first' && <span className="pos first">first</span>}
{display && display.pos === 'last' && <span className="pos last">last</span>}
{(!display || !display.pos) && <span className="pos">-</span>}
</td>
</tr>
{expanded && (
<BranchPicker
wsid={spec.wsid}
branches={spec.branches}
selected={selected}
userTouched={userTouched}
onToggle={onToggleBranch}
/>
)}
</React.Fragment>
);
})}
</tbody>
</table>
)}
</div>
);
}
```
- [ ] **Step 3: Update App to maintain `expandedWsids` state and pass everything down**
Find App's other useState calls. Add:
```jsx
const [expandedWsids, setExpandedWsids] = useState(() => new Set());
const onToggleExpansion = (wsid) => {
setExpandedWsids(prev => {
const next = new Set(prev);
if (next.has(wsid)) next.delete(wsid); else next.add(wsid);
return next;
});
};
```
(`onToggleBranch` is added in Task 5 - leave it for now; the existing `<ModTable defaultOpen={modTableDefault} />` invocation in `RightColumn` needs updating in Step 4 below.)
- [ ] **Step 4: Pass props through `RightColumn` to `ModTable`**
Find the `RightColumn` function signature (around line 372). Add `branchSelections, onToggleBranch, expandedWsids, onToggleExpansion` to the destructured props:
```jsx
function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion }) {
```
Then find the `<ModTable defaultOpen={modTableDefault} />` invocation inside `RightColumn` (around line 437). Replace with:
```jsx
<ModTable
defaultOpen={modTableDefault}
branchSelections={branchSelections}
onToggleBranch={onToggleBranch}
expandedWsids={expandedWsids}
onToggleExpansion={onToggleExpansion}
/>
```
- [ ] **Step 5: Pass props from App to `RightColumn`**
Find the `<RightColumn …/>` invocation in App (around line 613). Append:
```jsx
branchSelections={branchSelections}
onToggleBranch={() => {}}
expandedWsids={expandedWsids}
onToggleExpansion={onToggleExpansion}
```
(`onToggleBranch` is a placeholder no-op for now; replaced in Task 5.)
- [ ] **Step 6: Verify served file**
```bash
curl -sS http://100.114.205.53:8801/sortof-app.jsx | grep -cE 'BranchPicker|expandedWsids|branch-affordance|onToggleExpansion'
```
Expected: `>= 6`.
- [ ] **Step 7: Manual browser smoke test**
Hard-refresh the page. Submit a sort with `2335368829` (AuthenticZ alone) and any cached single-mod input. Open the Mod Details table. Expectation:
- AuthenticZ appears as **one row** with `▾ 3 branches` (or `✓ X of N` if hydrated from earlier Task 3 testing).
- Single-mod wsids appear as normal rows.
- Clicking the `▾` affordance toggles `expandedWsids` (panel visibility - content is empty until Task 5 adds `BranchPicker`).
- Console: no errors. (`BranchPicker is not defined` is expected at this point if you click - Task 5 fixes.)
---
## Task 5: Frontend - `BranchPicker` expansion panel + default selection + radio/checkbox modes
**Files:**
- Modify: `/opt/sortof/frontend/sortof-app.jsx`
- [ ] **Step 1: Backup**
```bash
cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M)
```
- [ ] **Step 2: Add `defaultSelectionForBranches()` helper near top of file**
Right after `buildModsLine` (around line 35), insert:
```jsx
// Default selection for a multi-branch wsid (spec §4):
// - if any branch lists another in `incompatible_mods`, default = [first only]
// - else default = [all branches]
// "First" tiebreaker: branches arrive sorted by parsed_at ASC, mod_id ASC from
// the API; we trust that order and pick branches[0].
function defaultSelectionForBranches(branches) {
if (!branches || !branches.length) return [];
const ids = new Set(branches.map(b => b.modId));
const isRadio = branches.some(b => (b.conflicts || []).some(c => ids.has(c)));
if (isRadio) return [branches[0].modId];
return branches.map(b => b.modId);
}
function isRadioMode(branches) {
if (!branches || branches.length < 2) return false;
const ids = new Set(branches.map(b => b.modId));
return branches.some(b => (b.conflicts || []).some(c => ids.has(c)));
}
```
- [ ] **Step 3: Add `BranchPicker` component**
Just above `function ModTable(` (around line 306, **before** the table), insert:
```jsx
function BranchPicker({ wsid, branches, selected, userTouched, onToggle }) {
const radio = isRadioMode(branches);
const inputType = radio ? 'radio' : 'checkbox';
// If user hasn't touched yet, render the default selection (so checkboxes
// reflect the implicit state). The actual default is also assumed by the
// rest of the system whenever branchSelections[wsid] is missing.
const effective = userTouched ? selected : defaultSelectionForBranches(branches);
return (
<tr>
<td colSpan={COLUMN_COUNT} className="branch-panel">
<div className="branch-panel-inner">
<div className="branch-panel-meta">
workshop {wsid} · {branches.length} mod_ids · {radio ? 'pick one' : 'multi-select'}
</div>
{branches.map(b => {
const checked = effective.includes(b.modId);
return (
<label key={b.modId} className={'branch-row' + (checked ? ' is-checked' : '')}>
<input
type={inputType}
name={radio ? `branch-${wsid}` : undefined}
className="branch-input"
checked={checked}
onChange={() => onToggle(wsid, b.modId, branches)}
/>
<span className="branch-modid">{b.modId}</span>
<span className="branch-name">{b.name}</span>
<span className={'cat ' + b.cat}>{b.cat}</span>
<span className="branch-deps">
{b.deps && b.deps.length ? b.deps.join(', ') : '-'}
</span>
<span className="branch-pos">
{b.pos === 'first' ? 'first' : (b.pos === 'last' ? 'last' : '-')}
</span>
</label>
);
})}
</div>
</td>
</tr>
);
}
```
- [ ] **Step 4: Implement `onToggleBranch` in App**
Find the App function (around line 454). Below the `expandedWsids` state, add:
```jsx
const onToggleBranch = (wsid, modId, branches) => {
setBranchSelections(prev => {
const radio = isRadioMode(branches);
const cur = prev[wsid] !== undefined
? prev[wsid]
: defaultSelectionForBranches(branches);
let next;
if (radio) {
next = [modId]; // exactly one
} else {
next = cur.includes(modId)
? cur.filter(x => x !== modId)
: [...cur, modId];
}
return { ...prev, [wsid]: next };
});
// Resort fetch is wired in Task 6; for now this just updates state.
};
```
- [ ] **Step 5: Replace the no-op `onToggleBranch` placeholder in `<RightColumn>`**
Find the `<RightColumn …/>` invocation in App (Task 4 added `onToggleBranch={() => {}}`). Replace with:
```jsx
onToggleBranch={onToggleBranch}
```
- [ ] **Step 6: Verify served file**
```bash
curl -sS http://100.114.205.53:8801/sortof-app.jsx | grep -cE 'BranchPicker|defaultSelectionForBranches|isRadioMode|branch-row'
```
Expected: `>= 8`.
- [ ] **Step 7: Manual browser smoke**
Hard-refresh. Submit AuthenticZ (`2335368829`). Click the `▾ 3 branches` affordance.
Expected:
- Panel expands with 3 rows.
- All 3 boxes are checked (default-all per §4 since `incompatible_mods=[]` on all).
- Click one box to uncheck. Affordance changes to `✓ 2 of 3`. Storage event listener writes to localStorage (`localStorage.getItem('sortof.branch.selections')`).
- The Mods= line in the output panel currently still includes all 3 mod_ids (resort hookup is Task 6).
Console: no errors.
---
## Task 6: Frontend - `/api/resort` integration with sequence number + 5xx handling
**Files:**
- Modify: `/opt/sortof/frontend/sortof-app.jsx`
- [ ] **Step 1: Backup**
```bash
cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M)
```
- [ ] **Step 2: Add `runResort()` async function in App**
Find the App function. Below `onToggleBranch` (added in Task 5), add:
```jsx
async function runResort(nextSelections) {
// Compose the flat list of selected mod_ids from MOD_DB + nextSelections.
// For wsids not in nextSelections, use the §4 default (all-ticked or
// first-only depending on radio mode). For wsids with N=1, include the
// sole mod_id unconditionally.
const byWsid = {};
for (const m of (_liveSortData?.MOD_DB || window.SORTOF_DATA?.MOD_DB || [])) {
const w = m.wsid || '';
if (!w) continue;
(byWsid[w] = byWsid[w] || []).push(m);
}
const ids = [];
for (const w of Object.keys(byWsid)) {
const branches = byWsid[w];
if (branches.length === 1) {
ids.push(branches[0].modId);
continue;
}
const stored = nextSelections[w];
const eff = stored !== undefined ? stored : defaultSelectionForBranches(branches);
for (const id of eff) ids.push(id);
}
if (!ids.length) {
// Nothing selected; no point hitting the API. Synthesize an empty payload
// so the UI shows "0 of N" without dispatching a 400.
_liveSortData = { ..._liveSortData, MOD_DB: [], SORTED_ORDER: [],
MODS_LINE: '', WORKSHOP_ITEMS_LINE: '', MAP_LINE: 'Muldraugh, KY',
WARNINGS: [{ tag: 'cycle', level: 'amber', msg: 'no mods selected - pick at least one branch' }],
pending: [], status: 'success' };
setCounts(c => ({ ...c, cached: 0, warnings: 1 }));
return;
}
const seq = resortSeq + 1;
setResortSeq(seq);
try {
const res = await fetch('/api/resort', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Sortof-Seq': String(seq) },
body: JSON.stringify({ selected_mod_ids: ids }),
});
// Drop stale: another resort has issued since this one started.
if (seq < resortSeq) return;
if (!res.ok) {
console.error('resort failed', res.status);
_liveSortData = {
..._liveSortData,
WARNINGS: [
...(_liveSortData?.WARNINGS || []),
{ tag: 'cycle', level: 'red', msg: "couldn't recompute sort - try again" },
],
};
setCounts(c => ({ ...c, warnings: (c.warnings || 0) + 1 }));
return;
}
const json = await res.json();
_liveSortData = json;
const cached = (json.MOD_DB || []).length;
const warns = (json.WARNINGS || []).length;
setCounts({ cached, queued: 0, parsing: 0, warnings: warns });
setState(json.status || 'success');
} catch (e) {
console.error('resort threw', e);
_liveSortData = {
..._liveSortData,
WARNINGS: [
...(_liveSortData?.WARNINGS || []),
{ tag: 'cycle', level: 'red', msg: "couldn't recompute sort - try again" },
],
};
setCounts(c => ({ ...c, warnings: (c.warnings || 0) + 1 }));
}
}
```
(Note: `_liveSortData` is the module-level let-binding from Task 0 of the original frontend - already present at the top of `sortof-app.jsx`.)
- [ ] **Step 3: Update `onToggleBranch` to trigger resort**
Find the existing `onToggleBranch` (Task 5). Replace its body with:
```jsx
const onToggleBranch = (wsid, modId, branches) => {
setBranchSelections(prev => {
const radio = isRadioMode(branches);
const cur = prev[wsid] !== undefined
? prev[wsid]
: defaultSelectionForBranches(branches);
let next;
if (radio) {
next = [modId];
} else {
next = cur.includes(modId)
? cur.filter(x => x !== modId)
: [...cur, modId];
}
const updated = { ...prev, [wsid]: next };
// Fire-and-forget: state set + resort dispatch.
runResort(updated);
return updated;
});
};
```
- [ ] **Step 4: Cross-tab `storage` listener also triggers resort**
Find the storage useEffect from Task 3. Replace with:
```jsx
useEffect(() => {
function onStorage(e) {
if (e.key !== 'sortof.branch.selections' || e.newValue === null) return;
try {
const parsed = JSON.parse(e.newValue);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
setBranchSelections(parsed);
runResort(parsed);
}
} catch {}
}
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [resortSeq]);
```
- [ ] **Step 5: Trigger an initial resort if hydrated `branchSelections` differs from defaults**
Add another useEffect just below the previous, fired once on mount when `_liveSortData` first becomes truthy (i.e. after the first /api/sort response):
```jsx
useEffect(() => {
// Hydrate-driven resort: after the user submits and gets MOD_DB,
// if branchSelections has entries, re-run sort against them.
if (state === 'success' || state === 'partial') {
if (Object.keys(branchSelections).length > 0) {
runResort(branchSelections);
}
}
// eslint-disable-next-line
}, [state]);
```
- [ ] **Step 6: Verify served file**
```bash
curl -sS http://100.114.205.53:8801/sortof-app.jsx | grep -cE "runResort|/api/resort|X-Sortof-Seq|couldn't recompute sort"
```
Expected: `>= 5`.
- [ ] **Step 7: Manual browser smoke - happy path**
Hard-refresh. Submit AuthenticZ. Expand. Untick `AuthenticZLite`.
Expected:
- Network tab shows POST `/api/resort` with body `{"selected_mod_ids":["Authentic Z - Current","AuthenticZBackpacks+"]}`.
- Response 200, `MODS_LINE` contains 2 mod_ids only.
- Output panel updates.
- Affordance reads `✓ 2 of 3`.
- WARNINGS no longer contains `ambiguous-multi-branch` for that wsid (because user touched it).
- [ ] **Step 8: Manual browser smoke - race**
Click toggle 2-3 times rapidly (untick, retick, untick). Inspect Network tab - multiple resort requests fire. Whichever response is the latest wins; older responses are dropped without state mutation. Final UI state matches the last toggle.
- [ ] **Step 9: Manual browser smoke - 5xx (optional)**
Edit `/opt/sortof/api/app.py` to add `raise HTTPException(500)` at the top of `resort_endpoint`. `py_compile`, restart api. Toggle a branch in the browser. Expect:
- Output state retains prior MOD_DB/MODS_LINE.
- A red WARNINGS entry appears: `couldn't recompute sort - try again`.
- Restore `app.py` from backup, restart api, toggle again - works again.
```bash
# Restore after the 5xx test
cp /opt/sortof/api/app.py.bak-* /opt/sortof/api/app.py
sudo systemctl restart sortof-api
```
---
## Task 7: Frontend - CSS for picker
**Files:**
- Modify: `/opt/sortof/frontend/index.html`
- [ ] **Step 1: Backup**
```bash
cp /opt/sortof/frontend/index.html /opt/sortof/frontend/index.html.bak-$(date +%Y%m%d-%H%M)
```
- [ ] **Step 2: Add CSS rules**
Find the existing `.copy-btn.copied` rule in `index.html` (around line 374). Below it, add:
```css
/* multi-branch picker (Spec A) */
.branch-affordance {
appearance: none;
border: 1px solid var(--line);
background: transparent;
color: var(--acc-blue);
font-family: var(--mono);
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
cursor: pointer;
}
.branch-affordance:hover { color: var(--fg); border-color: var(--line-2); background: var(--bg-3); }
.branch-panel {
background: var(--bg-2);
border-top: 1px dashed var(--line);
border-bottom: 1px dashed var(--line);
padding: 0;
}
.branch-panel-inner {
padding: 8px 12px 10px;
font-family: var(--mono);
font-size: 11px;
}
.branch-panel-meta { color: var(--fg-3); margin-bottom: 6px; letter-spacing: 0.04em; }
.branch-row {
display: grid;
grid-template-columns: 18px 220px 1fr 70px 1fr 60px;
gap: 10px;
align-items: center;
padding: 4px 0;
border-top: 1px solid var(--line);
cursor: pointer;
}
.branch-row:hover { background: var(--bg-3); }
.branch-row.is-checked .branch-modid { color: var(--acc-green); }
.branch-input { accent-color: var(--acc-green); }
.branch-modid { color: var(--fg-2); }
.branch-name { color: var(--fg-3); }
.branch-deps { color: var(--fg-3); font-size: 10.5px; }
.branch-pos { color: var(--fg-3); font-size: 10.5px; text-align: right; }
```
- [ ] **Step 3: Verify served file**
```bash
curl -sS http://100.114.205.53:8801/ | grep -c 'branch-affordance\|branch-panel\|branch-row'
```
Expected: `>= 6`.
- [ ] **Step 4: Manual browser smoke**
Hard-refresh. Open AuthenticZ in the picker. Expected:
- Affordance is a small bordered button-like element with blue text.
- Expansion panel has dashed top/bottom borders, slightly inset background.
- Each branch row has a checkbox/radio, mod_id, name, category pill, deps, and position cell.
- Hovering a branch row tints it slightly. Checked rows show the mod_id in green.
---
## Task 8: Verification - run §13 test cases against running stack
For each case, document expected vs actual. If any fails, return to the relevant task and fix.
- [ ] **Step 1: §13.1 AuthenticZ canonical**
```bash
curl -sS -X POST http://100.114.205.53:8801/api/sort \
-H 'Content-Type: application/json' \
-d '{"input":"2335368829"}' \
| jq '{warns: [.WARNINGS[] | select(.tag=="ambiguous-multi-branch")] | length, mod_db_count: (.MOD_DB|length)}'
```
Expected: `{"warns":1,"mod_db_count":3}`. In the browser: parent row reads `▾ 3 branches`, expansion panel mode = checkboxes, all 3 ticked.
- [ ] **Step 2: §13.2 Cooperative pack**
(No 3-mod cooperative wsid is in the cache yet. Skip or use AuthenticZ as a stand-in: same UI behavior, the difference is only conceptual.)
- [ ] **Step 3: §13.3 Mutually exclusive 2-branch**
(No such wsid in cache. To test, manually inject in DB:
```sql
-- Run via sudo docker exec -i sortof_db psql -U sortof -d sortof -c "..."
UPDATE mod_parsed SET incompatible_mods = '{AuthenticZBackpacks+}'::text[]
WHERE workshop_id='2335368829' AND mod_id='Authentic Z - Current';
UPDATE mod_parsed SET incompatible_mods = '{Authentic Z - Current}'::text[]
WHERE workshop_id='2335368829' AND mod_id='AuthenticZBackpacks+';
```
Then re-submit `2335368829` in the browser. Expected: panel mode = radios, default = `Authentic Z - Current` only (first by `parsed_at, mod_id`). Restore via:
```sql
UPDATE mod_parsed SET incompatible_mods = '{}'::text[] WHERE workshop_id='2335368829';
```)
- [ ] **Step 4: §13.4 Persistence across reload**
In browser: untick `AuthenticZLite`. Hard-refresh. After the next /api/sort, the resort fires automatically (Task 6 step 5 useEffect) and the picker shows ` 2 of 3` again. `localStorage.getItem('sortof.branch.selections')` shows the persisted entry.
- [ ] **Step 5: §13.5 Stored mod_id no longer exists (checkbox mode)**
Console: `localStorage.setItem('sortof.branch.selections', JSON.stringify({"2335368829": ["GhostBranchThatDoesNotExist"]}))`. Hard-refresh. Submit AuthenticZ. Expected: panel default applied (all 3 ticked); no console error.
- [ ] **Step 6: §13.6 Cross-wsid incompatibility**
(Use the canonical 3-mod input; `TMC_TrueActions` requires `tsarslib` but they're on different wsids - both N=1.) Expected: no picker UI, no `ambiguous-multi-branch` warning.
- [ ] **Step 7: §13.7 Zero-tick wsid**
Untick all 3 AuthenticZ branches in the browser. Expected: parent row reads `✓ 0 of N`, MODS_LINE excludes all 3, a synthetic warning appears (`no mods selected - pick at least one branch` if multiple wsids present, or the resort returns 400 → red retry warning if AuthenticZ was the only input). Re-tick at least one branch - UI recovers.
- [ ] **Step 8: §13.8 Radio-mode eviction-to-empty**
Apply the SQL from Step 3 (radio mode). Console: `localStorage.setItem('sortof.branch.selections', JSON.stringify({"2335368829": ["GhostMod"]}))`. Hard-refresh. Submit. Expected: silently drops the ghost; default-first applied (only `Authentic Z - Current` ticked). Restore SQL.
- [ ] **Step 9: §13.10 Stale resort response discarded**
Throttle Network in DevTools to "Slow 3G". Click toggle 1 (untick Backpacks), wait ~200ms, click toggle 2 (untick Lite). Inspect Network: two requests fire. The slower (toggle 1) finishes second. Expected: only toggle 2's response is applied; UI shows ` 1 of 3` (only Current selected), not ` 2 of 3`.
- [ ] **Step 10: §13.12 Cross-tab sync**
Open two tabs at `http://100.114.205.53:8801/`. Submit AuthenticZ in both. In tab A, untick a branch. Expected: tab B's affordance updates to match, network tab in B shows a /api/resort (triggered by the storage event listener).
- [ ] **Step 11: §13.13 Unknown selected_mod_id from server**
```bash
curl -sS -X POST http://100.114.205.53:8801/api/resort \
-H 'Content-Type: application/json' \
-d '{"selected_mod_ids":["modoptions","ghostMod"]}' \
| jq '.MOD_DB | map(.modId)'
```
Expected: `["modoptions"]`. Server log: INFO "resort dropped unknown mod_ids count=1 sample=['ghostMod']".
```bash
sudo journalctl -u sortof-api --since "2 min ago" | grep "resort dropped"
```
- [ ] **Step 12: Final regression - canonical 3-mod**
```bash
curl -sS -X POST http://100.114.205.53:8801/api/sort \
-H 'Content-Type: application/json' \
-d '{"input":"2169435993;2392709985;2487022075"}' \
| jq '{status, MODS_LINE, warns: (.WARNINGS|length)}'
```
Expected: `{"status":"success","MODS_LINE":"modoptions;tsarslib;TMC_TrueActions","warns":0}`. No regression from baseline.
---
## Self-review (already applied)
- **Spec coverage:** all of §3§10 acceptance criteria mapped to a task.
- **Placeholders:** none in the plan body.
- **Type consistency:** `branchSelections` is `{[wsid]: string[]}` everywhere; `expandedWsids` is `Set<string>`; `runResort` takes `nextSelections` of the same shape as `branchSelections`; `defaultSelectionForBranches` and `isRadioMode` both take `branches: MOD_DB[]` and read `.modId`/`.conflicts` consistently.
- **No git, by design:** every code-changing task starts with a `cp file file.bak-$(date)` step in lieu of a commit.
- **Restart-vs-no-restart:** backend tasks (1, 2) end with `sudo systemctl restart sortof-api`; frontend tasks (37) do not - `StaticFiles` serves from disk, hard-refresh in browser is sufficient.