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

41 KiB
Raw Blame History

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;2487022075MODS_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

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:

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:

@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
/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
sudo systemctl restart sortof-api && sleep 2 && sudo systemctl is-active sortof-api

Expected: active.

  • Step 6: Verify happy path
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:

{"status":"success","MODS_LINE":"modoptions;tsarslib;TMC_TrueActions","mod_db_count":3,"pending":[]}
  • Step 7: Verify validation 400s
# 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

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:

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:

        "WARNINGS": (
            build_warnings(sort_result.get("warnings", {}) or {})
            + _compute_ambiguous_warnings(mods)
        ),
  • Step 4: py_compile + smoke import
/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
sudo systemctl restart sortof-api && sleep 2 && sudo systemctl is-active sortof-api
  • Step 6: Verify warning fires for AuthenticZ
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
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
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

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:

// 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:

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:

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

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:

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:

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:

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:

<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:

branchSelections={branchSelections}
onToggleBranch={() => {}}
expandedWsids={expandedWsids}
onToggleExpansion={onToggleExpansion}

(onToggleBranch is a placeholder no-op for now; replaced in Task 5.)

  • Step 6: Verify served file
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

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:

// 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:

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:

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:

onToggleBranch={onToggleBranch}
  • Step 6: Verify served file
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

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:

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:

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:

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

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
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.
# 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

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:

  /* 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
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
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:

-- 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:

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']".

sudo journalctl -u sortof-api --since "2 min ago" | grep "resort dropped"
  • Step 12: Final regression - canonical 3-mod
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.