API additions (dev-api.py):
- GET /api/tasks — list active background tasks
- DELETE /api/tasks/{task_id} — per-task cancel
- POST /api/tasks/kill — kill all stuck tasks
- POST /api/tasks/discovery|email-sync|enrich|score|sync — queue/trigger each workflow
- POST /api/jobs/archive — archive by statuses array
- POST /api/jobs/purge — hard delete by statuses or target (email/non_remote/rescrape)
- POST /api/jobs/add — queue URL imports
- POST /api/jobs/upload-csv — upload CSV with URL column
- GET /api/config/setup-banners — list undismissed onboarding hints
- POST /api/config/setup-banners/{key}/dismiss — dismiss a banner
HomeView.vue:
- 4th WorkflowButton: "Fill Missing Descriptions" (always visible, not gated on enrichment_enabled)
- Danger Zone redesign: scope radio (pending-only vs pending+approved), Archive & reset (primary)
vs Hard purge (secondary), inline confirm dialogs, active task list with per-task cancel,
Kill all stuck button, More Options (email purge / non-remote / wipe+rescrape)
- Setup banners: dismissible onboarding hints pulled from /api/config/setup-banners,
5-second polling for active task list to stay live
app/Home.py:
- Danger Zone redesign: same scope radio + archive/purge with confirm steps
- Background task list with per-task cancel and Kill all stuck button
- More options expander (email purge, non-remote, wipe+rescrape)
- Setup banners section at page bottom
- app/app.py: load_env at module level (safe in Docker, fills gaps on bare-metal)
- dev_api.py: load_env in startup handler (avoids test-env pollution)
- requirements.txt: note >= 0.7.0 requirement; TODO tag once cf-core cuts release
db.migration runner deferral: tracked in #43 (Rails-style numbered migrations)
CFOrchClient VRAM wiring: already present in task_scheduler via CF_ORCH_URL env var
- Lower vue_ui_beta gate to "free" so all licensed users can access the
new UI without a paid subscription
- Remove "Paid tier" wording from the Try New UI banner
- Fix Vue SPA navigation in cloud/demo deployments: add VITE_BASE_PATH
build arg so Vite sets the correct subpath base, and pass
import.meta.env.BASE_URL to createWebHistory() so router links
emit /peregrine/... paths that Caddy can match
- Fix feedback button missing on cloud instance by passing
FORGEJO_API_TOKEN through compose.cloud.yml
- Remove vLLM container from compose.yml (vLLM dropped from stack;
cf-research service in cfcore covers the use case)
- Fix cloud config path in Apply page (use get_config_dir() so per-user
cloud data roots resolve correctly for user.yaml and resume YAML)
- Refactor generate_cover_letter._build_system_context and
_build_mission_notes to accept explicit profile arg (enables
per-user cover letter generation in cloud multi-tenant mode)
- Add API proxy block to nginx.conf (Vue web container can now call
/api/ directly without Vite dev proxy)
- Update .env.example: remove vLLM vars, add research model + tuning
vars for external vLLM deployments
- Update llm.yaml: switch vllm base_url to host.docker.internal
(vLLM now runs outside Docker stack)
Closes#63 (feedback button)
Related: #8 (Vue SPA), #50–#62 (parity milestone)
- discover.py: run_discovery() accepts config_dir param; auto-derives it
from db_path parent (per-user in cloud, falls back to /app/config)
- task_runner.py: passes db_path.parent/config as config_dir to run_discovery
- wizard (0_Setup.py): write 'titles' key not 'job_titles' — matches what
discover.py and all custom board scrapers read
- adzuna/theladders/craigslist: fall back to 'job_titles' for existing
profiles written by older wizard versions
- Fixed Sheridan's live config in place (job_titles → titles)
sync_ui_cookie() was calling window.parent.location.reload() on every
render when user.yaml has ui_preference=vue, but no Caddy is in the
traffic path (test instances, bare Docker). This caused an infinite
reload loop because the reload just came back to Streamlit.
Gate the reload on PEREGRINE_CADDY_PROXY=1. Without it, the cookie is
still written silently but no reload is attempted. Add the env var to
compose.yml and compose.cloud.yml (both are behind Caddy); omit from
compose.test-cfcore.yml so test instances stay stable.
- Catch all exceptions (not just RuntimeError) so FileNotFoundError,
connection errors, etc. surface as error messages rather than crashing
the page silently
- Show "No new suggestions found" info message when the LLM returns
empty arrays — previously the spinner completed with no UI feedback
- Hint to upload resume when RESUME_PATH is missing (new users)
- Only rerun() when there are actual results to display
Pages were hardcoding DEFAULT_DB at import time, meaning cloud-mode
per-user DB routing was silently ignored. Pages affected:
1_Job_Review, 5_Interviews, 6_Interview_Prep, 7_Survey.
Adds resolve_session("peregrine") + get_db_path() pattern to each,
matching the pattern already used in 4_Apply.py.
Fixes#24.
- ui_switcher.py: add explicit guard that forces pref=streamlit when
DEMO_MODE=true, before the tier-downgrade check. Demo Vue SPA (#46)
is not yet implemented, so navigating there produced a blank screen.
- app.py: call sync_ui_cookie inside wizard gate block before st.stop()
so that cloud users with ui_preference=vue are redirected correctly
even when the first-run wizard is still active. Previous behaviour
called sync_ui_cookie after pg.run() which was never reached.
- demo/config/user.yaml: reset ui_preference to streamlit (belt-and-
suspenders alongside the code guard).
Closes: demo blank-screen regression reported 2026-03-24.
render_banner() was incorrectly guarded by 'if not IS_DEMO' — the spec
says the banner is open to all demo visitors. render_banner() already
handles its own eligibility check internally (_DEMO_MODE or can_use).
- Initialize simulated_tier session state for demo mode after resolve_session/init_db
- Render demo toolbar before pg.run() when IS_DEMO is set
- Render ui_switcher banner before pg.run() for non-demo paid-tier users (guarded with try/except)
- Sync ui_preference cookie after pg.run() (guarded with try/except)
- All imports are local (inside if-blocks) to avoid Streamlit circular import issues
- Add explanatory comments to all 5 bare except Exception blocks clarifying that UI components must not crash the app
- Refactor sync_ui_cookie() to load UserProfile once instead of up to 3 times in normal path
- Store profile reference and reuse it in tier downgrade protection block
- Replace importlib.reload() pattern in tests with unittest.mock.patch for _DEMO_MODE
- Improves test isolation and eliminates module state contamination across test runs
- All 5 tests pass (100%)
- Replace fragile reload pattern with unittest.mock.patch('app.wizard.tiers._DEMO_MODE', ...)
- Eliminates parallel test run failures (pytest-xdist) and improves test isolation
- All 4 demo_tier tests now use context managers for clean setup/teardown
- Add explanatory comment to _DEMO_MODE definition about immutability and env-based init
E2E harness fixes to get all three modes (demo/cloud/local) passing:
- conftest.py: use ctx.add_cookies() for cloud auth instead of
ctx.route() or set_extra_http_headers(). Playwright's route() only
intercepts HTTP; set_extra_http_headers() explicitly excludes
WebSocket handshakes. Streamlit reads st.context.headers from the
WebSocket upgrade, so cookies are the only vehicle that reaches it
without a reverse proxy.
- cloud_session.py: fall back to Cookie header when X-CF-Session is
absent — supports direct access (E2E tests, dev without Caddy).
In production Caddy sets X-CF-Session; in tests the cf_session cookie
is set on the browser context and arrives in the Cookie header.
- modes/cloud.py: add /peregrine base URL path (STREAMLIT_SERVER_BASE_URL_PATH=peregrine)
- modes/local.py: correct port from 8502 → 8501 and add /peregrine path
All three modes now pass smoke + interaction tests clean.
- Add tests/e2e/test_smoke.py: page-load error check for all pages
- Add tests/e2e/test_interactions.py: click every interactable, diff
errors, XFAIL expected demo failures, flag regressions as XPASS
- Fix conftest get_page_errors() to use text_content() instead of
inner_text() so errors inside collapsed expanders are captured with
their actual message text (inner_text respects CSS display:none)
- Fix tests/e2e/modes/demo.py base_url to include /peregrine path prefix
(STREAMLIT_SERVER_BASE_URL_PATH=peregrine set in demo container)
App fixes surfaced by the harness:
- task_runner.py: add DEMO_MODE guard for discovery task — previously
crashed with FileNotFoundError on search_profiles.yaml before any
demo guard could fire; now returns friendly error immediately
- 6_Interview_Prep.py: stop auto-triggering LLM session on page load
in demo mode; show "AI features disabled" info instead, preventing
a silent st.error() inside the collapsed Practice Q&A expander
Both smoke and interaction tests now pass clean against demo mode.
- Settings: add st.rerun() after storing _kw_suggestions so chips appear
immediately without requiring a tab switch (#18)
- Setup wizard step 4: prefill name/email/phone from parsed resume when
identity fields are blank; saved values take precedence on re-visit (#17)
- Home dashboard: sync section shows provider name when Notion is connected,
or 'Set up a sync integration' with a settings link when not configured (#16)
Implements idempotent calendar push for Apple Calendar (CalDAV) and
Google Calendar from the Interviews kanban.
- db: add calendar_event_id column (migration) + set_calendar_event_id helper
- integrations/apple_calendar: create_event / update_event via caldav + icalendar
- integrations/google_calendar: create_event / update_event via google-api-python-client;
test() now makes a real API call instead of checking file existence
- scripts/calendar_push: orchestrates push/update, builds event title from stage +
job title + company, attaches job URL and company brief to description,
defaults to noon UTC / 1hr duration
- app/pages/5_Interviews: "Add to Calendar" / "Update Calendar" button shown
when interview date is set and a calendar integration is configured
- environment.yml: pin caldav, icalendar, google-api-python-client, google-auth
- tests/test_calendar_push: 9 tests covering create, update, error handling,
event timing, idempotency, and missing job/date guards
New cloud users got a "resume_keywords.yaml not found" warning in
Settings → Skills & Keywords because the file was never created during
account provisioning. resolve_session() now writes an empty scaffold
(skills/domains/keywords: []) to the user's config dir on first visit
if the file doesn't exist, consistent with how config/ and data/ dirs
are already created. Never overwrites an existing file.
- cloud_session.py: add _ensure_provisioned() called in resolve_session() so
new Google OAuth signups get a free Heimdall key created on first page load;
previously resolve returned "free" tier but no key was ever written to
Heimdall, leaving users in an untracked state
- Home.py: replace conda run invocation in "Score All Unscored Jobs" with
sys.executable so the button works inside Docker where conda is not present
LinkedIn's unauthenticated public profile only exposes name, summary (truncated),
current employer name, and certifications. Past roles, education, and skills are
blurred server-side behind a login wall — not a scraper limitation.
- Update selectors: data-section='summary' (was 'about'), .profile-section-card
for certs, .visible-list for current experience entry
- Strip login-wall noise injected into summary text after 'see more'
- Skip aria-hidden blurred placeholder experience items
- Add info callout in UI directing users to data export zip for full history
- app.py: wizard gate now reads get_config_dir()/user.yaml instead of
hardcoded repo-level config/ — fixes perpetual onboarding loop in
cloud mode where per-user wizard_complete was never seen
- app.py: page title corrected to "Peregrine"
- cloud_session.py: add get_config_dir() returning per-user config path
in cloud mode, repo config/ locally
- cloud_session.py: replace st.error() with JS redirect on missing/invalid
session token so users land on login page instead of error screen
- Home.py, 4_Apply.py, migrate.py: remove remaining AIHawk UI references
- Pop _linkedin_extracted before st.tabs() so tab_builder sees the
freshly populated _parsed_resume in the same render pass (no extra rerun needed)
- Fix tab label capitalisation: "Build Manually" (capital M) per spec
- Add st.rerun() after LinkedIn merge in Settings so form fields
refresh immediately to show the newly applied data
Calls /admin/cloud/resolve after JWT validation to inject the user's
current subscription tier (free/paid/premium/ultra) into session_state
as cloud_tier. Cached 5 minutes via st.cache_data to avoid Heimdall
spam on every Streamlit rerun. Degrades gracefully to free on timeout
or missing token.
New env vars: HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN (added to .env.example
and compose.cloud.yml). HEIMDALL_URL defaults to http://cf-license:8000
for internal Docker network access.
New helper: get_cloud_tier() — returns tier string in cloud mode, "local"
in local-first mode, so pages can distinguish self-hosted from cloud.
T13: Three fixes:
1. backup.py: _decrypt_db_to_bytes() decrypts SQLCipher DB before archiving
so the zip is portable to any local Docker install (plain SQLite).
2. backup.py: _encrypt_db_from_bytes() re-encrypts on restore in cloud mode
so the app can open the restored DB normally.
3. 2_Settings.py: _base_dir uses get_db_path().parent in cloud mode (user's
per-tenant data dir) instead of the hardcoded app root; db_key wired
through both create_backup() and restore_backup() calls.
6 new cloud backup tests + 2 unit tests for SQLCipher helpers (pysqlcipher3
mocked — not available in the local conda test env). 419/419 total passing.
T11: Add CLOUD_MODE-gated Privacy tab to Settings with full telemetry
consent UI — hard kill switch, anonymous usage toggle, de-identified
content sharing toggle, and time-limited support access grant. All changes
persist to telemetry_consent table via new update_consent() in telemetry.py.
Tab and all DB calls are completely no-op in local mode (CLOUD_MODE=false).
T8: compose.cloud.yml — multi-tenant cloud stack on port 8505, CLOUD_MODE=true,
per-user encrypted data at /devl/menagerie-data, joins caddy-proxy_caddy-internal
network; .env.example extended with five cloud-only env vars.
T10: app/telemetry.py — log_usage_event() is the ONLY entry point to usage_events
table; hard kill switch (all_disabled) checked before any DB write; complete no-op
in local mode; swallows all exceptions so telemetry never crashes the app;
psycopg2-binary added to requirements.txt. Event calls wired into 4_Apply.py at
cover_letter_generated and job_applied. 5 tests, 413/413 total passing.
resolve_session() is a no-op in local mode — no behavior change for existing users.
In cloud mode, injects user-scoped db_path into st.session_state at page load.
cloud_session.py: no-op in local mode; in cloud mode resolves Directus JWT
from X-CF-Session header to per-user db_path in st.session_state.
get_connection() in scripts/db.py: transparent SQLCipher/sqlite3 switch —
uses encrypted driver when CLOUD_MODE=true and key provided, vanilla sqlite3
otherwise. libsqlcipher-dev added to Dockerfile for Docker builds.
6 new cloud_session tests + 1 new get_connection test — 34/34 db tests pass.
Places a ✨ Suggest button inline with the Skills & Keywords subheader.
On click, calls suggest_resume_keywords() and stores results in session
state. Suggestions render as per-category chip panels (skills, domains,
keywords); clicking a chip appends it to the YAML and removes it from
the panel. A ✕ Clear button dismisses the panel entirely.