diff --git a/docs/GUI.md b/docs/GUI.md index 9621acd..379ae67 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -90,7 +90,8 @@ A question from a run (an unknown form field, an option to pick, "did it submit? stays. Esc clears the search. A text search shows every match as its own card (8 at a time, "Show more"; the posting cut short), each with its own Approve / Not for me buttons (y / n keys are off there; a decided card turns into one line with Change). The job you were on is left undecided; clearing the - search comes back to it. A kind-of-work choice alone stays one job at a time. + search comes back to it. A kind-of-work choice alone stays one job at a time, and is picked from the + list the page already has (2026-10-28, my request: it took ~10 s): no trip to the server, instant. Apply (2026-10-16, my request: one place per sheet tab; the Sheet screen merged in) three tabs, each with its count, the one you picked remembered for the session: NUworks co-ops (the sheet's NUworks tab: the weekly limit), " internships" ("Other jobs" when internships are off: the sheet's Other jobs tab, no @@ -255,7 +256,11 @@ A question from a run (an unknown form field, an option to pick, "did it submit? confirmed, the screen when it asked; Approve / Decline with a note), the end question (Yes / No / No: do it on the laptop), a question Claude Code asks in its own terminal (answer it there; the reply box stays off), Reply, End session, End: do it on the laptop. It asks again every 4 s; what you type is kept - (docs/REMOTE.md, AN ASSISTANT'S SCREEN) + (docs/REMOTE.md, AN ASSISTANT'S SCREEN). The screen keeps the session it opened on (its key, + review.key): a session that ended, a slot nobody runs in, or the next session started in that slot + goes back to Apply's Assistants tab with a toast (2026-10-28, my request: it showed "No session there", + or switched to the new session as if it were the same). Its taps carry the key; the server refuses one + for a session no longer in that slot (gui.assistant_post). SETUP WIZARD (onboard.py + gui_static/setup.js; each step checks itself; a step opens when you click it, the first one not done is open at the start; "You're set up" + Go to Today at the end; Settings reuses some steps' bodies) @@ -298,6 +303,9 @@ SPEED (2026-10-22, my request: "the apply page takes quite a while to load") gui.other_rows); opening the screen (recheck=1) reads both again in the background, and a change reloads the lists. - Review: the list (pool, taste model, order) is kept until data/, your settings or the sheet's rows change (gui.review_lists, keyed by every data/ file's size and time: a few ms); searches and filters only narrow it. The + page sends no kind of work: it picks that from the list itself; and it never waits for decisions still being sent (the + server keeps every list's jobs decidable, APP.queue; the page leaves out what it decided, app.js pick), so a decision + that writes the sheet (an Approve: 3-4 Google calls) never holds up the next list. The taste model trains again only when your ratings or a job's text change (jobs.taste_for). The lists are made ahead at start and a few seconds after your last decision (warm_review). (2026-10-22, my request: Review still took 0.5-3 s) Review asks with quick=1: the list made last comes at once (about 10 ms), never waiting on Google or a rebuild; when diff --git a/docs/REMOTE.md b/docs/REMOTE.md index 30869d3..8cc78fa 100644 --- a/docs/REMOTE.md +++ b/docs/REMOTE.md @@ -173,6 +173,10 @@ AN ASSISTANT'S SCREEN (#/assistant/ in the homelab's app; review.view / ac back to Apply > Assistants, and the screen says your answer until the run closes. A session you answered is no longer listed or counted there (gui.assistants), though it holds its slot a few seconds more while it writes the sheet and cleans up (2026-10-23, my request). +- The screen is for the session it opened on, not the slot (2026-10-28, my request: once one ended, the next session + started in its slot showed there as if it were the same one): review.key (its run folder, hashed) comes with the view + and goes with every tap; a different key, or none in the slot, sends the screen back to Apply > Assistants with a + toast, and gui.assistant_post refuses a tap whose key is not the slot's session now ("That session has ended"). - Each answer goes in the run's actions.log with the paired device's name. - The agent on the homelab never opens your own network: localhost, the LAN, Tailscale addresses (so not this page, not the browser view) are refused in its browser and WebFetch (assist.own_network). diff --git a/src/nuauto/cli.py b/src/nuauto/cli.py index d655b74..bdfa890 100755 --- a/src/nuauto/cli.py +++ b/src/nuauto/cli.py @@ -19,6 +19,7 @@ nuauto intern ... Summer 2027 internships from Simplify's and vanshb03's lists (update, approve, rate, stats, pool, fetch ) nuauto accounts ... the logins the assistant made on job sites (list; export: Bitwarden's import file) + nuauto workday [] your statuses on each company's Workday (read-only); offers to mark rejections Rejected nuauto review ... NUauto's page on the homelab: pair a phone, list / unpair devices, demo (docs/REMOTE.md) nuauto link ... this laptop's link to that page (pair, status, forget) nuauto answers ... answer bank (init, list) @@ -43,7 +44,7 @@ TOOLS = {"jobs": "jobs", "answers": "answers", "sheet": "sheet", "setup-sheet": "setup_sheet", "inspect": "inspect_form", "daily": "daily", "web": "web", "health": "health", "demo": "demo", "onboard": "onboard", "selftest": "selftest", "insights": "insights", "intern": "intern", "accounts": "accounts", - "review": "review", "link": "link"} + "review": "review", "link": "link", "workday": "workday"} COMMANDS = ("approve", "rate", "apply", "status", "update", "login", "test", "doctor", "assist", "gui", "deploy") diff --git a/src/nuauto/gui.py b/src/nuauto/gui.py index 4d7333b..3b40019 100644 --- a/src/nuauto/gui.py +++ b/src/nuauto/gui.py @@ -223,7 +223,7 @@ def __init__(self): self.first_round_done = False self.rows, self.rows_at, self.rows_error, self.rows_try_at = None, 0, None, 0 self.rows_version = 0 # sheet_version when these rows were read (the Apply screen's answer says it) - self.queue = {} # job id -> pool entry, from the last review queue + self.queue = {} # job id -> pool entry, from every review queue made this session self.other_urls = None # the Other jobs tab's links (not Proposed), as last read: the internship count self.other_cache = None # (when, rows, sheet_version then) of the Other jobs tab (gui.other_rows) self.other_last, self.other_exists = None, None # the tab's rows as last read (kept by other_changed), is there one @@ -933,7 +933,8 @@ def gone(job_id): # decided here after the list was made (approve / no in Appro todo = [r for r in todo if not gone(r["id"])] urgent = [r for r in urgent if not gone(r["id"])] ratings = jobs.load("ratings.json", {}) - APP.queue = {r["id"]: r for r in todo} + APP.queue.update((r["id"], r) for r in todo) # (kept from earlier lists too: a decision sent behind the screen + # may arrive after the page asked for a newer list without that job) counts = {} for r in todo: counts[r["category"]] = counts.get(r["category"], 0) + 1 @@ -2474,7 +2475,7 @@ def assistant_post(body): raise Refused("An assistant on this computer runs in its own terminal window: answer it there.") slot = body.get("slot") s = review.session(int(slot)) if str(slot).isdigit() else None - if not s: + if not s or ("key" in body and body["key"] != review.key(s)): # (key: the page's session; its slot may hold the next) raise Refused("That session has ended.") return review.act(s, body, device_name()) diff --git a/src/nuauto/gui_static/app.js b/src/nuauto/gui_static/app.js index 8568b4d..73ff6a1 100644 --- a/src/nuauto/gui_static/app.js +++ b/src/nuauto/gui_static/app.js @@ -699,6 +699,7 @@ screens.review = async (view) => { const interns = (S.state && S.state.internships) || { on: false }; let source = interns.on && sessionStorage.getItem("reviewSource") === "intern" ? "intern" : "nuworks"; let jobs = [], i = 0, card = null, history = {}, shown = 0, total = 0, resumeId = null, listed = 0, loadSeq = 0; + let all = []; // the list the server sent (every kind of work); jobs: the part of it the kind-of-work menu picks let moved = false; // you went to another job (or decided one) since the list came: a newer list keeps your place let q = sessionStorage.getItem("reviewQ") || "", category = sessionStorage.getItem("reviewCat") || ""; const cards = new Map(); // job id -> promise of its card (the next ones are fetched ahead) @@ -737,10 +738,22 @@ screens.review = async (view) => { function setFilter(newQ, newCat) { if (newQ === q && newCat === category) return; if (!q && newQ && jobs[i]) resumeId = jobs[i].id; // the job you were on stays undecided; clearing the search comes back to it + const same = newQ === q; q = newQ; category = newCat; sessionStorage.setItem("reviewQ", q); sessionStorage.setItem("reviewCat", category); - load(); + if (!same) return load(); + // only the kind of work changed: picked from the list already here, with no trip to the server + shown++; relist(); moved = false; show(); + } + + // Decided on this screen in a way that takes the job out of this list (as the server does: gui.review's gone): + // in Rate only, any rating; in Approve, a decision made in Approve. A decision still being sent counts already. + const decidedHere = (j) => { const h = history[j.id]; return !!h && !h.failed && (mode === "rate" || h.mode === "approve"); }; + const pick = () => all.filter((j) => (!category || j.category === category) && !decidedHere(j)); + function relist() { + jobs = pick(); i = 0; listed = 0; + if (!q && resumeId) { i = Math.max(0, jobs.findIndex((x) => x.id === resumeId)); resumeId = null; } } function drawCategories(list) { @@ -772,15 +785,16 @@ screens.review = async (view) => { const mine = ++loadSeq; // only the newest load may show its list (an older, slower answer is dropped) jobs = []; i = 0; card = null; shown++; // while it loads, no key decides on the old list (and no card of it comes back) const slow = setTimeout(() => { if (mine === loadSeq && body.isConnected) fill(body, el("p", { class: "empty", text: "Loading the review list…" })); }, 150); - await queue; // decisions still being written go first: the server decides on its last list, which this one replaces - const params = new URLSearchParams({ mode, q, category, source }); + // (no waiting for decisions still being sent: the server keeps every list's jobs decidable, and the ones decided + // here are left out by pick; the kind of work is picked here too, so the server sends every kind) + const params = new URLSearchParams({ mode, q, source }); let r; try { r = await api.get(`/api/review?${params}&quick=1`); } catch (e) { clearTimeout(slow); if (mine === loadSeq && body.isConnected) fill(body, el("div", { class: "note fail", text: e.message })); return; } clearTimeout(slow); if (mine !== loadSeq || !body.isConnected) return; - jobs = r.jobs; total = r.total; i = 0; listed = 0; cards.clear(); drawCategories(r.categories); - if (!q && resumeId) { i = Math.max(0, jobs.findIndex((x) => x.id === resumeId)); resumeId = null; } + all = r.jobs; total = r.total; cards.clear(); drawCategories(r.categories); + relist(); moved = i > 0; show(); if (r.stale) refresh(mine, params); @@ -791,14 +805,16 @@ screens.review = async (view) => { // Not moved yet: the new list from its first job (the quick list's first may no longer come first). async function refresh(mine, params) { let r; - try { await queue; r = await api.get(`/api/review?${params}`); } catch (e) { return; } + try { r = await api.get(`/api/review?${params}`); } catch (e) { return; } if (mine !== loadSeq || !body.isConnected) return; const old = jobs, was = new Map(old.map((j) => [j.id, JSON.stringify({ ...j, rating: null })])); const changed = (j) => was.get(j.id) !== JSON.stringify({ ...j, rating: null }); // (a rating made here is not a change) total = r.total; drawCategories(r.categories); - if (r.jobs.length === old.length && r.jobs.every((j, k) => j.id === old[k].id && !changed(j))) return position(); - r.jobs.forEach((j) => { if (changed(j)) cards.delete(j.id); }); // (its card may say something new too) - jobs = r.jobs; + all = r.jobs; + const fresh = pick(); + if (fresh.length === old.length && fresh.every((j, k) => j.id === old[k].id && !changed(j))) return position(); + fresh.forEach((j) => { if (changed(j)) cards.delete(j.id); }); // (its card may say something new too) + jobs = fresh; if (listMode()) { const had = listed, y = window.scrollY; showList(true, had); window.scrollTo(0, y); return; } const at = new Map(jobs.map((j, k) => [j.id, k])); const next = moved ? old.slice(i).find((j) => at.has(j.id)) : jobs[0]; @@ -923,7 +939,7 @@ screens.review = async (view) => { function submit(j, decision, onfail) { const before = history[j.id], oldRating = j.rating; if (before && before.decision === "approve" && decision === "approve") return false; // already in the sheet - const entry = { decision, row: null, tab: null, previous: before ? before.previous : null }; + const entry = { decision, mode, row: null, tab: null, previous: before ? before.previous : null }; history[j.id] = entry; j.rating = decision === "no" ? 0 : 1; let undone = false; @@ -2019,6 +2035,7 @@ screens.assistant = async (view) => { const there = String(S.param || "").startsWith("h"), slot = String(S.param || "").replace(/^h/, ""); const api_ = there ? "/api/homelab/assistant" : "/api/assistant"; let d = null, version = null, timer = null, first = true; + let who = null; // the session this screen opened on (its key): the slot is reused by the next one once it ends const note = el("input", { type: "text", placeholder: "A note for the agent (when you decline)", "aria-label": "Note", testid: "as-note" }); const reply = el("textarea", { rows: "4", maxlength: "4000", "aria-label": "Reply", testid: "as-reply" }); const term = el("pre", { class: "log term", testid: "as-terminal", tabindex: "0", "aria-label": "Its terminal" }); @@ -2030,7 +2047,7 @@ screens.assistant = async (view) => { async function send(body, busy) { if (busy) { busy.disabled = true; busy.textContent = busy.dataset.busy || "Working…"; } try { - const r = await api.post(api_, { slot: Number(slot), ...body }); + const r = await api.post(api_, { slot: Number(slot), key: who, ...body }); toast(r.message); if (body.do === "reply") reply.value = ""; if (r.closing) { sessionStorage.setItem("applyTab", "assistants"); location.hash = "#/apply"; return; } @@ -2106,18 +2123,27 @@ screens.assistant = async (view) => { } } + // it ended (or never ran in that slot): back to Apply's Assistants tab, where the others are (2026-10-28, my request; + // it showed "No session there", or the next session started in the slot, as if it were this one) + function ended() { + toast(d ? `${d.company || "That assistant"}: the session has ended.` : `No assistant is running in slot ${slot}.`); + sessionStorage.setItem("applyTab", "assistants"); + location.replace("#/apply"); + } + async function load() { clearTimeout(timer); + let now; try { - d = await api.get(`${api_}/${encodeURIComponent(slot)}`); + now = await api.get(`${api_}/${encodeURIComponent(slot)}`); } catch (e) { if (!view.isConnected || S.gone) return; if (S.offline) { timer = setTimeout(load, 4000); return; } // the homelab can't be reached a moment: ask again - fill(view, el("div", { class: "card", testid: "as-gone" }, el("h2", { text: "No session there" }), - el("p", { class: "muted", text: "It has ended (or it never ran in that slot)." }), el("a", { class: "btn", href: "#/apply", text: "Apply", onclick: () => sessionStorage.setItem("applyTab", "assistants") }))); - return; + return ended(); } if (!view.isConnected) return; + if (who && now.key !== who) return ended(); + d = now; who = d.key; if (d.version !== version) { version = d.version; draw(); } else { term.textContent = d.terminal || "(not readable)"; } if (d.browser && ["working", "approve", "waiting"].includes(d.status)) live.src = `${api_}/${slot}/shot.png?live=${Date.now()}`; timer = setTimeout(load, 4000); diff --git a/src/nuauto/review.py b/src/nuauto/review.py index 2812491..45d8360 100644 --- a/src/nuauto/review.py +++ b/src/nuauto/review.py @@ -392,11 +392,17 @@ def read_answer(s): return assist.read_json(os.path.join(s["run"], assist.DECISION)) +def key(s): + """Which session this is (its run folder's, hashed): a slot is reused by the next session once one ends, so the + screen keeps this and a tap carrying another one is refused (assistant_post).""" + return hashlib.sha256(s["run"].encode()).hexdigest()[:16] + + def summary(s): """One running session, for the lists (Company sites, Today).""" word, text = status(s) st, info = s["state"], s["info"] - return {"slot": s["slot"], "tab": info.get("tab"), "row": info.get("row"), + return {"slot": s["slot"], "key": key(s), "tab": info.get("tab"), "row": info.get("row"), "company": st.get("company") or info.get("company") or "", "title": st.get("job_title") or "", "where": st.get("where") or "", "host": info.get("host") or "", "since": info.get("since"), "status": word, "text": text} diff --git a/src/nuauto/sync.py b/src/nuauto/sync.py index 49a29f6..21889ae 100644 --- a/src/nuauto/sync.py +++ b/src/nuauto/sync.py @@ -44,7 +44,8 @@ def watch_unit(unit="nuauto-daily.service"): PACKAGE = ["__init__.py", "__main__.py", "cli.py", "config.py", "sheet.py", "jobs.py", "fields.py", "daily.py", "browser.py", "apply.py", "answers.py", "inspect_form.py", "setup_sheet.py", "sync.py", "web.py", "doctor.py", "assist.py", "health.py", "demo.py", "gui.py", "onboard.py", "window.py", "window_gtk.py", "selftest.py", "deploy.py", "insights.py", - "manage.py", "intern.py", "postings.py", "accounts.py", "mail.py", "screen.py", "review.py", "remote.py", "link.py", "letter.py"] + "manage.py", "intern.py", "postings.py", "accounts.py", "mail.py", "screen.py", "review.py", "remote.py", "link.py", "letter.py", + "workday.py"] CODE = (["pyproject.toml", "README.md", "local_config.example.json", "CLAUDE.md", "CONTRIBUTING.md", "docs/DEPLOY.md", "docs/PIPELINE.md", "docs/ARCHITECTURE.md", "docs/SAFETY.md", "docs/GUI.md", "docs/REMOTE.md", "docs/ASSIST.md"] diff --git a/src/nuauto/workday.py b/src/nuauto/workday.py new file mode 100644 index 0000000..8d2fd59 --- /dev/null +++ b/src/nuauto/workday.py @@ -0,0 +1,345 @@ +"""Your application statuses on Workday, read from each company's Candidate Home (read-only). Many companies mark you +"Not Selected" there and never email. For every Workday login in local/accounts.json (the ones the assistant made and +your own you imported) it signs in, opens Candidate Home and reads My Applications (Active and Inactive), then lists +what each says. For a rejection it can tie to one sent row of the sheet, it asks y/n to mark that row Rejected. A +Rejected NUworks row still counts toward the total until you withdraw it on NUworks (My Job Applications). + + nuauto workday every Workday login, one site after another (a visible browser) + nuauto workday only the logins whose site or company contains + nuauto workday --hidden no window + +Read-only on Workday: it fills only the sign-in form, with that site's own saved login, on that site's own address +(may_sign_in; never on accounts.NEVER), clicks only Sign In and the Inactive tab, never Withdraw. One sign-in try per +site (no lockouts). A fresh private browser per site: nothing is kept on disk. Passwords are never printed or logged. +Log and screenshots: logs/_workday/. +""" +import re +import sys +from urllib.parse import urlparse + +from nuauto import accounts + +HOSTS = ("myworkdayjobs.com", "myworkdaysite.com") +# What a status says (Workday's words differ per company: "Not Selected", "No longer under consideration", "Inactive") +WITHDRAWN = re.compile(r"withdr[ae]wn?", re.I) +REJECTED = re.compile(r"not selected|no longer (being )?(under )?consider|not (been )?moving forward|not been selected|" + r"unsuccessful|\brejected\b", re.I) +CLOSED = re.compile(r"\binactive\b|\bclosed\b|\bfilled\b|cancel+ed|no longer available|expired", re.I) +ACTIVE = re.compile(r"submitted|in progress|under (review|consideration)|review|interview|screen|assessment|offer|" + r"\bactive\b|received|considered", re.I) +STOP_PARTS = {"job", "details", "apply", "userhome", "login", "jobs"} +LANG = re.compile(r"^[a-z]{2}(-[a-z]{2})?$", re.I) + + +def is_workday(host): + h = (host or "").lower() + return any(h == d or h.endswith("." + d) for d in HOSTS) + + +def kind(status): + """rejected | closed | withdrawn | active | unknown: what a Workday status means for you.""" + s = status or "" + for name, rx in (("withdrawn", WITHDRAWN), ("rejected", REJECTED), ("closed", CLOSED), ("active", ACTIVE)): + if rx.search(s): + return name + return "unknown" + + +def home_url(job_url): + """Candidate Home for the career site a posting is on (https:///[/]/userHome; on myworkdaysite.com + .../recruiting///userHome), or None.""" + u = urlparse(job_url or "") + h = (u.hostname or "").lower() + if u.scheme != "https" or not is_workday(h): + return None + keep = [] + for p in (p for p in u.path.split("/") if p): + if p.lower() in STOP_PARTS: + break + keep.append(p) + rest = keep[1:] if keep and LANG.match(keep[0]) else keep + if h.endswith("myworkdaysite.com"): + if len(rest) != 3 or rest[0].lower() != "recruiting": + return None + elif len(rest) != 1: + return None + return f"https://{h}/{'/'.join(keep)}/userHome" + + +def posting(job_url): + """(title words, requisition id) from a posting's address (.../job//_).""" + last = urlparse(job_url or "").path.rstrip("/").split("/")[-1] + title, _, req = last.rpartition("_") + if not title or not re.search(r"\d", req): + title, req = last, "" + return norm(title.replace("-", " ")), req.lower() + + +def norm(text): + return " ".join(re.sub(r"[^a-z0-9]+", " ", (text or "").lower()).split()) + + +def logins(only=""): + """The Workday logins in accounts.json (filtered by `only`), each with the Candidate Home to open.""" + try: + import json + with open(accounts.PATH) as f: + data = json.load(f) + except (OSError, ValueError): + return [] + out = [] + for a in data.values(): + host = accounts.host_of(a.get("site", "")) + if not is_workday(host) or accounts.never(host) or not a.get("password"): + continue + if only and only.lower() not in f"{a['site']} {a.get('company', '')}".lower(): + continue + homes = [home_url(j.get("url")) for j in a.get("jobs") or []] + out.append({**a, "home": next((h for h in homes if h and may_sign_in(h, a)), None)}) + return sorted(out, key=lambda a: a["site"]) + + +def may_sign_in(url, a): + """Only this login's own site gets its password: the same host (and company, on Workday's shared hosts).""" + u = urlparse(url or "") + if u.scheme != "https" or not is_workday(u.hostname) or accounts.never(u.hostname or ""): + return False + s = accounts.site(url) + return s == a["site"] or accounts.host_of(s) == a["site"] # a login saved before sites knew Workday's companies + + +def entry(heads, cells): + """One application row of Candidate Home's table: {"title", "status", "text"} (columns by their headers, else the + first cell is the title and the status is the cell that reads like one).""" + cells = [c.strip() for c in cells] + heads = [h.strip().lower() for h in heads] if len(heads) == len(cells) else [] + pick = lambda rx: next((cells[i] for i, h in enumerate(heads) if re.search(rx, h)), None) # noqa: E731 + title = pick(r"title|job|position") or (cells[0] if cells else "") + status = pick(r"status") or next((c for c in cells[1:] if kind(c) != "unknown"), "") + return {"title": title, "status": status, "text": " | ".join(cells)} + + +def which_job(e, a, entries): + """The saved job (accounts.json's jobs) this application is, or None when it is not certain: its requisition id + is in the row, or its title words are the row's; else the login's only job when the site lists only one.""" + jobs = a.get("jobs") or [] + found = [] + for j in jobs: + words, req = posting(j.get("url")) + if (req and f" {norm(req)} " in f" {norm(e['text'])} ") or (words and words == norm(e["title"])): + found.append(j) + if len(found) == 1: + return found[0] + if not found and len(jobs) == 1 and len(entries) == 1: + return jobs[0] + return None + + +def which_row(j, a, rows): + """The one sent row (sheet.SENT) of the sheet for this job: its company-site link is in the row's Notes, else the + row's company and title are the ones the assistant saved with the login. None if not exactly one.""" + from nuauto import sheet + sent = [r for r in rows if r.status in sheet.SENT] + by_link = [r for r in sent if j.get("url") and j["url"] in r.notes] + if len(by_link) == 1: + return by_link[0] + by_name = [r for r in sent if r.title == j.get("title") and (not a.get("company") or r.company == a["company"])] + return by_name[0] if len(by_name) == 1 else None + + +# ---------- the browser (one fresh private Firefox per site) ---------- + +def _visible(loc): + try: + return loc.count() > 0 and loc.first.is_visible() + except Exception: + return False + + +def sign_in(page, a, log): + """Sign in with this site's saved login (once). Returns None, or why not.""" + from nuauto import browser + email = page.locator('input[data-automation-id="email"]') + if not _visible(email): + for sel in ('[data-automation-id="signInLink"]', '[data-automation-id="utilityButtonSignIn"]'): + if _visible(page.locator(sel)): + page.locator(sel).first.click() + browser.pause(page, 1.5, 3) + break + if _visible(page.locator('input[data-automation-id="verifyPassword"]')) and \ + _visible(page.locator('[data-automation-id="signInLink"]')): # the create-account form: go to sign-in + page.locator('[data-automation-id="signInLink"]').first.click() + browser.pause(page, 1.5, 3) + password = page.locator('input[data-automation-id="password"]') + if not (_visible(email) and _visible(password)): + return "no sign-in form found" + if not may_sign_in(page.url, a): + return f"the sign-in is on {urlparse(page.url).hostname}, not this login's site: not signing in" + email.first.fill(a["email"]) + browser.pause(page, 0.5, 1.2) + password.first.fill(a["password"]) + browser.pause(page, 0.5, 1.2) + # Workday lays a click catcher (click_filter) over its buttons: a forced click lands on it, as a person's does + page.locator('[data-automation-id="signInSubmitButton"]').first.click(force=True) + try: + page.wait_for_load_state("networkidle", timeout=20000) + except Exception: + pass + browser.pause(page, 2, 4) + if _visible(page.locator('input[data-automation-id="password"]')): + return "sign-in didn't work (wrong password, or the site wants something else)" + return None + + +def read_table(page): + """Every application row on the page: [entry()].""" + out = [] + for t in page.locator("table").all(): + heads = [h.inner_text() for h in t.locator("thead th, thead td").all()] + for tr in t.locator("tbody tr").all(): + cells = [c.inner_text() for c in tr.locator("td, th").all()] + if len(cells) >= 2: + out.append(entry(heads, cells)) + return out + + +def check_site(p, a, log, headless): + """{"site", "company", "error" | "apps": [entry + {"kind", "tab"}]}.""" + from nuauto import browser + res = {"site": a["site"], "company": a.get("company", ""), "apps": []} + if not a.get("home"): + res["error"] = "no saved job link to find its Candidate Home" + return res + b = p.firefox.launch(headless=headless) + try: + ctx = b.new_context() + + def lock(route): # the main page stays on Workday: no other site's sign-in ever gets this login + req = route.request + if req.is_navigation_request() and req.frame.parent_frame is None and not is_workday(urlparse(req.url).hostname): + log.write(f"{a['site']}: blocked a move to {urlparse(req.url).hostname}") + return route.abort() + return route.continue_() + ctx.route("**/*", lock) + page = ctx.new_page() + page.goto(a["home"], wait_until="domcontentloaded", timeout=45000) + try: + page.wait_for_load_state("networkidle", timeout=20000) + except Exception: + pass + browser.pause(page, 1.5, 3) + why = sign_in(page, a, log) + if why: + res["error"] = why + log.screenshot(page, f"{norm(a['site']).replace(' ', '_')}_stopped") + return res + name = norm(a["site"]).replace(" ", "_") + if "userhome" not in page.url.lower(): + page.goto(a["home"], wait_until="domcontentloaded", timeout=45000) + browser.pause(page, 2, 4) + log.screenshot(page, f"{name}_active") + res["apps"] = [{**e, "tab": "Active"} for e in read_table(page)] + tab = page.get_by_role("tab", name=re.compile(r"^\s*inactive", re.I)) + if _visible(tab): + tab.first.click() + browser.pause(page, 1.5, 3) + log.screenshot(page, f"{name}_inactive") + seen = {e["text"] for e in res["apps"]} + res["apps"] += [{**e, "tab": "Inactive"} for e in read_table(page) if e["text"] not in seen] + for e in res["apps"]: + e["kind"] = kind(e["status"]) + if e["tab"] == "Inactive" and e["kind"] in ("active", "unknown"): + e["kind"] = "closed" # on the Inactive tab, whatever its status still says + if not res["apps"]: + with open(f"{log.dir}/{name}.txt", "w") as f: # what the page said, to see why nothing was read + f.write(page.locator("body").inner_text()) + res["error"] = "signed in, but read no applications (see the screenshot)" + return res + except Exception as e: + res["error"] = f"stopped: {type(e).__name__}: {str(e).splitlines()[0][:120] if str(e) else ''}" + return res + finally: + b.close() + + +# ---------- the command ---------- + +def offers(results, rows_by_tab): + """[(tab, row, entry)]: rejections (and closed ones) tied to exactly one sent row that isn't Rejected yet.""" + out = [] + by_site = {a["site"]: a for a in logins()} + for res in results: + a = by_site.get(res["site"]) + apps = res.get("apps") or [] + for e in apps: + if e.get("kind") not in ("rejected", "closed") or a is None: + continue + j = which_job(e, a, apps) + for tab, rows in rows_by_tab.items(): + r = which_row(j, a, rows) if j else None + if r and r.status != "Rejected": + out.append((tab, r, e)) + return out + + +def main(argv): + from nuauto import browser + from nuauto import sheet + headless = "--hidden" in argv or not browser.has_display() + rest = [x for x in argv if x != "--hidden"] + if rest and rest[0].startswith("-"): + sys.exit(__doc__) + sites = logins(" ".join(rest)) + if not sites: + sys.exit("No Workday logins" + (f" matching {' '.join(rest)!r}" if rest else "") + " in accounts.json.") + from playwright.sync_api import sync_playwright + log = browser.RunLog("workday") + results = [] + try: + with sync_playwright() as p: + for i, a in enumerate(sites, 1): + print(f"\n[{i}/{len(sites)}] {a.get('company') or a['site']}") + res = check_site(p, a, log, headless) + results.append(res) + log.write(f"{a['site']}: " + (res.get("error") or f"{len(res['apps'])} application(s)")) + except KeyboardInterrupt: + print("\nStopped. What was read so far:") + print("\nWORKDAY STATUSES") + for res in results: + print(f"\n{res['company'] or res['site']} ({res['site']})") + if res.get("error"): + print(f" ! {res['error']}") + for e in res.get("apps") or []: + print(f" {e['kind']:9} {e['status'][:40]:40} {e['title'][:60]}") + ws_main = sheet.open_worksheet() + tabs = {"NUworks": (ws_main, sheet.read_rows(ws_main))} + try: + ws_other = sheet.open_other() + tabs["Other jobs"] = (ws_other, sheet.read_rows(ws_other)) + except sheet.SheetError: + pass + todo = offers(results, {t: rows for t, (_, rows) in tabs.items()}) + if not todo: + return print("\nNothing to mark: no rejection here is tied to one sent row that isn't Rejected yet.") + if not sys.stdin.isatty(): + print("\nRejections tied to a row (mark them Rejected in the GUI, a row's More menu):") + for tab, r, e in todo: + print(f" {tab} row {r.number}: {r.company}, {r.title} (Workday: {e['status']})") + return + marked = [] + for tab, r, e in todo: + ans = input(f"\n{tab} row {r.number}: {r.company}, {r.title}\n Workday says {e['status']!r} " + f"(as {e['title']!r}). Mark it Rejected? [y/N] ").strip().lower() + if ans == "y": + sheet.set_outcome(tabs[tab][0], r.number, r.url, "Rejected") + log.write(f"marked {tab} row {r.number} Rejected (Workday: {e['status']})") + marked.append((tab, r)) + owed = [r for tab, r in marked if tab == "NUworks"] + if owed: + print("\nWithdraw these on NUworks (My Job Applications) so they stop counting toward the total:") + for r in owed: + print(f" row {r.number}: {r.company}, {r.title}") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/tests/test_gui.py b/tests/test_gui.py index 2c17df9..cd08424 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -194,7 +194,9 @@ def eventually(check, what, seconds=20): irate = get("/api/review?mode=rate&source=intern") assert irate["source"] == "intern" and irate["jobs"] and "Robotics Software Intern" not in [j["title"] for j in irate["jobs"]], irate assert get("/api/review?mode=approve")["source"] == "nuworks" # no source = the co-ops (their list is the server's again) - post("/api/decide", {"id": irev["jobs"][0]["id"], "decision": "approve", "mode": "approve"}, expect=409) # not in this list + # a job no review list offered is refused (one from an earlier list stays decidable: a decision sent behind the + # screen may arrive after the page asked for another list) + post("/api/decide", {"id": "s0notalisting", "decision": "approve", "mode": "approve"}, expect=409) bank = get("/api/answers") entries = bank["entries"] @@ -1235,8 +1237,11 @@ def review_again(): # a new Review screen: its cards are fetched again, its lis assert not any(r["url"] == HARBOR and r["row"] != harbor_row for r in get("/api/other")["ready"]) # the one whose posting could not be read: no match to show, no margin math, nothing about it throws; it is last + asked = [] # a kind of work is picked from the list the page has: no trip to the server (2026-10-28) + page.on("request", lambda r: "/api/review?" in r.url and asked.append(r.url)) page.select_option("[data-testid=review-category]", "uncategorized") page.wait_for_function("() => document.querySelector('[data-testid=job-title]')?.innerText === 'Robotics Software Intern'") + assert not asked, asked facts = page.inner_text("[data-testid=job-card] .facts") assert "not scored (the posting could not be read)" in facts and "Score" not in facts and "needs" not in facts, facts assert "Could not read it:" in page.inner_text("[data-testid=job-card] .why") and no_junk(page.inner_text("[data-testid=job-card]")) diff --git a/tests/test_homelab_app.py b/tests/test_homelab_app.py index bd85f30..04c302e 100644 --- a/tests/test_homelab_app.py +++ b/tests/test_homelab_app.py @@ -129,6 +129,11 @@ def eventually(check, what, seconds=20): assert request("GET", "/api/assistant/1/shot.png", cookie=KEY)[0] == 404 # no screen in the demo r = post("/api/assistant", {"slot": 2, "do": "reply", "text": "OK"}) assert "Could not do that" in r["message"] # (no tmux in the demo) + # a tap carries the screen's session (key): one meant for a session that ended, its slot now another's, is refused + a2 = get("/api/assistant/2") + assert a2["key"] and a2["key"] != a1["key"] + assert "Could not do that" in post("/api/assistant", {"slot": 2, "key": a2["key"], "do": "reply", "text": "OK"})["message"] + assert "ended" in post("/api/assistant", {"slot": 2, "key": a1["key"], "do": "reply", "text": "OK"}, expect=409)["error"] assert "ended" in post("/api/assistant", {"slot": 4, "do": "exit"}, expect=409)["error"] # ---- its queue (Company sites): every slot is taken by the made-up sessions, so rows wait; Start now says why diff --git a/tests/test_review.py b/tests/test_review.py index 39bd2d0..9a5272e 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -754,7 +754,7 @@ def raw_post(path, headers, cookie=None, body=b"", timeout=5): page.goto(f"{ORIGIN}/#/assistant/1") # opened fresh: the same page.wait_for_selector("[data-testid=gone]") page.wait_for_timeout(1000) - assert "not paired" in page.inner_text("#view") and page.locator("[data-testid=as-gone]").count() == 0 + assert "not paired" in page.inner_text("#view") and page.url.endswith("/#/assistant/1") # (not sent on to Apply) # pairing through the form: GET shows it, the button pairs this browser before = device_count() page.goto(review.make_pairing("tablet")) @@ -816,13 +816,29 @@ def raw_post(path, headers, cookie=None, body=b"", timeout=5): page.reload() page.wait_for_selector("[data-testid=as-exit]") assert page.locator("[data-testid=as-laptop]").count() == 1 and page.locator("[data-testid=as-interrupt]").count() == 1 - # a slot nobody runs in + # a slot nobody runs in (or a session that just ended): back to Apply's Assistants tab, with a toast page.goto(f"{ORIGIN}/#/assistant/3") - page.wait_for_selector("[data-testid=as-gone]") + page.wait_for_selector("[data-testid=tab-assistants][aria-selected=true]") + assert page.url.endswith("/#/apply") and "No assistant is running in slot 3" in page.inner_text("[data-testid=toasts]") # an old Discord link lands on the screen page.goto(f"{ORIGIN}/s/1") page.wait_for_selector("[data-testid=as-status]") assert page.url.endswith("/#/assistant/1") + # the session ends and the next one starts in its slot: the screen does not follow it, it goes back to + # Assistants (it had switched to the new one, as if it were the same; 2026-10-28) + run2 = os.path.join(config.LOGS_DIR, "20261011-130000_assist_row9") + os.makedirs(run2) + assist.write_private(os.path.join(run2, "state.json"), {**state, "company": "Next Co"}) + held1 = holders.pop(0) + held1.kill(); held1.wait() + hold_slot(1, run2) + page.wait_for_selector("[data-testid=tab-assistants][aria-selected=true]", timeout=15000) + assert "the session has ended" in page.inner_text("[data-testid=toasts]") and "Next Co" not in page.inner_text("body") + held2 = holders.pop() + held2.kill(); held2.wait() + hold_slot(1, run) + page.goto(f"{ORIGIN}/s/1") + page.wait_for_selector("[data-testid=as-status]") # a page on another origin cannot press the buttons, even with this browser's cookie (the same site to SameSite): # a form can't send JSON, and a fetch with the page's headers is stopped by the browser (no CORS answer) assist.write_private(os.path.join(run, assist.APPROVAL), approval)