fix: Settings widget crash, stale setup banners, Docker service controls

- Settings → Search: add-title (+) and Import buttons crashed with
  StreamlitAPIException when writing to _sp_titles_multi after it was
  already instantiated. Fix: pending-key pattern (_sp_titles_pending /
  _sp_locs_pending) applied before widget renders on next pass.

- Home setup banners: fired for email/notion/keywords even when those
  features were already configured. Add 'done' condition callables
  (_email_configured, _notion_configured, _keywords_configured) to
  suppress banners automatically when config files are present.

- Services tab start/stop buttons: docker CLI was unavailable inside
  the container so _docker_available was False and buttons never showed.
  Bind-mount host /usr/bin/docker (ro) + /var/run/docker.sock into the
  app container so it can control sibling containers via DooD pattern.
This commit is contained in:
pyr0ball 2026-03-04 12:11:23 -08:00
parent a3e4e3a493
commit c5e18da572
3 changed files with 48 additions and 7 deletions

View file

@ -25,17 +25,45 @@ from scripts.task_runner import submit_task
init_db(DEFAULT_DB) init_db(DEFAULT_DB)
def _email_configured() -> bool:
_e = Path(__file__).parent.parent / "config" / "email.yaml"
if not _e.exists():
return False
import yaml as _yaml
_cfg = _yaml.safe_load(_e.read_text()) or {}
return bool(_cfg.get("username") or _cfg.get("user") or _cfg.get("imap_host"))
def _notion_configured() -> bool:
_n = Path(__file__).parent.parent / "config" / "notion.yaml"
if not _n.exists():
return False
import yaml as _yaml
_cfg = _yaml.safe_load(_n.read_text()) or {}
return bool(_cfg.get("token"))
def _keywords_configured() -> bool:
_k = Path(__file__).parent.parent / "config" / "resume_keywords.yaml"
if not _k.exists():
return False
import yaml as _yaml
_cfg = _yaml.safe_load(_k.read_text()) or {}
return bool(_cfg.get("keywords") or _cfg.get("required") or _cfg.get("preferred"))
_SETUP_BANNERS = [ _SETUP_BANNERS = [
{"key": "connect_cloud", "text": "Connect a cloud service for resume/cover letter storage", {"key": "connect_cloud", "text": "Connect a cloud service for resume/cover letter storage",
"link_label": "Settings → Integrations"}, "link_label": "Settings → Integrations",
"done": _notion_configured},
{"key": "setup_email", "text": "Set up email sync to catch recruiter outreach", {"key": "setup_email", "text": "Set up email sync to catch recruiter outreach",
"link_label": "Settings → Email"}, "link_label": "Settings → Email",
"done": _email_configured},
{"key": "setup_email_labels", "text": "Set up email label filters for auto-classification", {"key": "setup_email_labels", "text": "Set up email label filters for auto-classification",
"link_label": "Settings → Email (label guide)"}, "link_label": "Settings → Email (label guide)",
"done": _email_configured},
{"key": "tune_mission", "text": "Tune your mission preferences for better cover letters", {"key": "tune_mission", "text": "Tune your mission preferences for better cover letters",
"link_label": "Settings → My Profile"}, "link_label": "Settings → My Profile"},
{"key": "configure_keywords", "text": "Configure keywords and blocklist for smarter search", {"key": "configure_keywords", "text": "Configure keywords and blocklist for smarter search",
"link_label": "Settings → Search"}, "link_label": "Settings → Search",
"done": _keywords_configured},
{"key": "upload_corpus", "text": "Upload your cover letter corpus for voice fine-tuning", {"key": "upload_corpus", "text": "Upload your cover letter corpus for voice fine-tuning",
"link_label": "Settings → Fine-Tune"}, "link_label": "Settings → Fine-Tune"},
{"key": "configure_linkedin", "text": "Configure LinkedIn Easy Apply automation", {"key": "configure_linkedin", "text": "Configure LinkedIn Easy Apply automation",
@ -513,7 +541,10 @@ with st.expander("⚠️ Danger Zone", expanded=False):
# ── Setup banners ───────────────────────────────────────────────────────────── # ── Setup banners ─────────────────────────────────────────────────────────────
if _profile and _profile.wizard_complete: if _profile and _profile.wizard_complete:
_dismissed = set(_profile.dismissed_banners) _dismissed = set(_profile.dismissed_banners)
_pending_banners = [b for b in _SETUP_BANNERS if b["key"] not in _dismissed] _pending_banners = [
b for b in _SETUP_BANNERS
if b["key"] not in _dismissed and not b.get("done", lambda: False)()
]
if _pending_banners: if _pending_banners:
st.divider() st.divider()
st.markdown("#### Finish setting up Peregrine") st.markdown("#### Finish setting up Peregrine")

View file

@ -324,6 +324,14 @@ with tab_search:
st.session_state["_sp_excludes"] = "\n".join(p.get("exclude_keywords", [])) st.session_state["_sp_excludes"] = "\n".join(p.get("exclude_keywords", []))
st.session_state["_sp_hash"] = _sp_hash st.session_state["_sp_hash"] = _sp_hash
# Apply any pending programmatic updates BEFORE widgets are instantiated.
# Streamlit forbids writing to a widget's key after it renders on the same pass;
# button handlers write to *_pending keys instead, consumed here on the next pass.
for _pend, _wkey in [("_sp_titles_pending", "_sp_titles_multi"),
("_sp_locs_pending", "_sp_locations_multi")]:
if _pend in st.session_state:
st.session_state[_wkey] = st.session_state.pop(_pend)
# ── Titles ──────────────────────────────────────────────────────────────── # ── Titles ────────────────────────────────────────────────────────────────
_title_row, _suggest_btn_col = st.columns([4, 1]) _title_row, _suggest_btn_col = st.columns([4, 1])
with _title_row: with _title_row:
@ -355,7 +363,7 @@ with tab_search:
st.session_state["_sp_title_options"] = _opts st.session_state["_sp_title_options"] = _opts
if _t not in _sel: if _t not in _sel:
_sel.append(_t) _sel.append(_t)
st.session_state["_sp_titles_multi"] = _sel st.session_state["_sp_titles_pending"] = _sel
st.session_state["_sp_new_title"] = "" st.session_state["_sp_new_title"] = ""
st.rerun() st.rerun()
with st.expander("📋 Paste a list of titles"): with st.expander("📋 Paste a list of titles"):
@ -371,7 +379,7 @@ with tab_search:
if _t not in _sel: if _t not in _sel:
_sel.append(_t) _sel.append(_t)
st.session_state["_sp_title_options"] = _opts st.session_state["_sp_title_options"] = _opts
st.session_state["_sp_titles_multi"] = _sel st.session_state["_sp_titles_pending"] = _sel
st.session_state["_sp_paste_titles"] = "" st.session_state["_sp_paste_titles"] = ""
st.rerun() st.rerun()

View file

@ -16,6 +16,8 @@ services:
- ./config:/app/config - ./config:/app/config
- ./data:/app/data - ./data:/app/data
- ${DOCS_DIR:-~/Documents/JobSearch}:/docs - ${DOCS_DIR:-~/Documents/JobSearch}:/docs
- /var/run/docker.sock:/var/run/docker.sock
- /usr/bin/docker:/usr/bin/docker:ro
environment: environment:
- STAGING_DB=/app/data/staging.db - STAGING_DB=/app/data/staging.db
- DOCS_DIR=/docs - DOCS_DIR=/docs