diff --git a/CLAUDE.md b/CLAUDE.md index af2b9d9..3ab21a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -242,7 +242,7 @@ app's device key for the homelab's page) COMMANDS (installed in .venv; no activation needed) nuauto gui # the window (setup, review, apply, health); --demo: everything fake; --view: my real data, read-only (agents: only these two) nuauto approve # viewer: my Proposed rows first, then best unrated jobs: y = Approved in the sheet, n = no, s skip -nuauto apply [-n 3] [--order match] # submit every Approved row (30-60s between); real terminal (or the GUI) only +nuauto apply [-n 3] [--order match] # submit every Approved row (10-20s between); real terminal (or the GUI) only # --order: default (closing within a week, then score) | score | match | closes | pay nuauto rate # taste training viewer (keys in docs/PIPELINE.md) nuauto update # check NUworks for new jobs now (runs on the homelab, then syncs) diff --git a/docs/GUI.md b/docs/GUI.md index 5a0050b..3bdd819 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -111,7 +111,10 @@ SCREENS (NUworks: when it closes; the other tab: when it was posted). Every row: who and what, its score / match % and pay ("score 105 = 75% match + 10 Boston + 20 security" when bonuses add to it: Start goes by score), a line about it (its site, its notes), its chips (when it closes: amber within a week, red once past; "may also want the company's site"; the queue's place), what to - do next (Apply: after a confirm just that row, `nuauto apply --row N`; on an assistant's row Apply starts the + do next (Apply: after a confirm just that row, `nuauto apply --row N`; while an Apply run goes it is Add to queue + instead: the row joins that run's queue (`--queue FILE`, gui.queue_row; done next in the order clicked, + the button shows Queued #k and takes it out again; Stop empties it; rows queued just as the run ended + get a run of their own, gui.apply_done); on an assistant's row Apply starts the assistant, where Run on says; Mark done; Submit on NUworks too), Open, and More (⋯; on a phone in the row's top right corner): on a sent row Mark accepted / Mark rejected (or Back to Applied: what you heard back, sheet.set_outcome), I applied myself, Drop (not @@ -383,7 +386,7 @@ API (JSON; all need the cookie; POSTs need X-NUauto: 1) or null); sessions, slots, homelab, linked, queue; nuworks also week, why_not, history, order; other also exists GET /api/apply Approved rows in apply order, week, why_not, recent runs - POST /api/action {"kind": apply ({"n", "order"} or {"row"})|update|login_google|login_nuworks|check_nuworks|login_claude| + POST /api/action {"kind": apply ({"n", "order"} or {"row"}; a row during a run: queued, reply {"queue"})|apply_unqueue ({"row"})|update|login_google|login_nuworks|check_nuworks|login_claude| install_firefox|install_claude|nuworks_side|assist|fix_permissions|review_pair|bitwarden_add| bitwarden_import ({"only": [words]})|bitwarden_login|bitwarden_install, "args"} (the bitwarden_ ones open a terminal window: Bitwarden asks for your master password there, never this page) diff --git a/docs/SAFETY.md b/docs/SAFETY.md index cd05e2f..13dfe5a 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -18,7 +18,7 @@ APPLYING (apply.py, sheet.py) sheet.not_on_nuworks, fingerprint; never asked = counts) | test_sheet (the counts, the refusal, the NUworks answers) - Weekly cap counts every sent row dated this week, Accepted / Rejected too (they went out) | sheet.applied_dates, SENT | test_sheet - Applied row with no/odd date is an error, never guessed | sheet.applied_dates | test_sheet -- One application at a time, 30-60 s apart | apply.run_rows loop, time.sleep(random.uniform(30, 60)) | no test +- One application at a time, 10-20 s apart (30-60 until 2026-10-23); rows queued from the GUI go through the same loop and limit checks | apply.run_rows loop, time.sleep(random.uniform(*apply.BETWEEN)) | test_apply (the queue) - One machine submits on NUworks at a time (with NUauto's page on the homelab, both can run Apply; each counts the weekly cap before each submit): the homelab's runs take the turn there (it lasts while that process lives), the laptop's ask for it over ssh (it lasts until given back, at most 3 h); refused = not started ("Not now: ..."). The diff --git a/src/nuauto/apply.py b/src/nuauto/apply.py index bc0b26a..58c90be 100644 --- a/src/nuauto/apply.py +++ b/src/nuauto/apply.py @@ -34,6 +34,7 @@ APPLY_NAME = re.compile(r"^\s*apply\s*$", re.IGNORECASE) APPLY_WAIT_MS = 15000 # NUworks draws the job page with JavaScript after it loads; a slow one showed no button yet +BETWEEN = (10, 20) # seconds between two applications, at random (was 30-60 until 2026-10-23, my call) def apply_button(page): @@ -231,6 +232,23 @@ def key(r): return sorted(rows, key=key) +def queued(path): + """The rows nuauto gui queued behind this run (each row's Apply while it runs), in the order you clicked them. + The GUI owns the file; it takes a row out once the run starts it.""" + if not path: + return [] + try: + return [int(n) for n in json.loads(open(path).read())] + except (OSError, ValueError, TypeError): + return [] + + +def more_queued(args, rows, seen): + """The queued rows still Approved and not handled in this run.""" + approved = {r.number for r in sheet.approved(rows)} + return [n for n in queued(args.queue) if n in approved and n not in seen] + + def pick_row(rows, row_number, order="default"): candidates = apply_order(sheet.approved(rows), order) if row_number is not None: @@ -470,6 +488,7 @@ def main(argv=None): ap.add_argument("--row", type=int, help=argparse.SUPPRESS) ap.add_argument("--dry-run", action="store_true", help=argparse.SUPPRESS) ap.add_argument("--ui", choices=["json"], help=argparse.SUPPRESS) # questions as JSON lines, for nuauto gui + ap.add_argument("--queue", help=argparse.SUPPRESS) # (nuauto gui) a file of rows to do next, added to while this runs args = ap.parse_args(argv) args.submit = not args.dry_run if args.submit: @@ -508,7 +527,11 @@ def run_rows(args, ws, resume_label, entries, io): if first: row = pick_row(rows, args.row, args.order) else: - left = apply_order([r for r in sheet.approved(rows) if r.number not in seen], args.order) + approved = {r.number: r for r in sheet.approved(rows) if r.number not in seen} + ahead = more_queued(args, rows, seen) # queued rows first, in the order you queued them + rest = [] if args.row is not None or (args.n and len(seen) >= args.n) else apply_order( + [r for n, r in approved.items() if n not in ahead], args.order) + left = [approved[n] for n in ahead] + rest if not left: print("No more Approved rows.") break @@ -520,16 +543,17 @@ def run_rows(args, ws, resume_label, entries, io): print(f"--- Row {row.number}: {row.company} | {row.title}: deadline passed ({day}). Needs Human.") io.event("row_done", row=row.number, outcome="deadline") sheet.set_status(ws, row.number, "Needs Human", notes=f"deadline passed ({day}); not attempted") - if args.row is not None: + if args.row is not None and not more_queued(args, rows, seen): break continue print(f"--- Row {row.number}: {row.company} | {row.title} | closes {jobs.closes_text(day)}") io.event("row", row=row.number, company=row.company, title=row.title) outcome = apply_one(row, ws, args, resume_label, entries, io) io.event("row_done", row=row.number, outcome=outcome) - if outcome == "stop" or args.row is not None or (args.n and len(seen) >= args.n): + last = args.row is not None or (args.n and len(seen) >= args.n) + if outcome == "stop" or last and not more_queued(args, rows, seen): break - wait = random.uniform(30, 60) * config.DEMO_PACE # DEMO_PACE is 1 outside demo mode + wait = random.uniform(*BETWEEN) * config.DEMO_PACE # DEMO_PACE is 1 outside demo mode io.event("wait", seconds=round(wait)) time.sleep(wait) # slow, human-like pacing between applications diff --git a/src/nuauto/gui.py b/src/nuauto/gui.py index 80d151c..021c1a1 100644 --- a/src/nuauto/gui.py +++ b/src/nuauto/gui.py @@ -76,6 +76,7 @@ def __init__(self, kind, label, cmd, browser, background=False, env=None, on_don self.log, self.events, self.question, self.dropped = [], [], None, 0 self.row = self.screenshot = self.last_shot = self.wait_until = None self.done_rows = [] + self.queue, self.queue_file = [], None # (Apply) rows to do next, in order, and the file the run reads them from self.state, self.code, self.stopping = "running", None, False self.settling = False # ended, its on_done (the sheet read again) not finished yet: the page still sees running self.started, self.ended = now(), None @@ -135,6 +136,8 @@ def _event(self, msg): kind = msg.get("kind") if kind == "row": self.row, self.screenshot, self.wait_until = msg, None, None + if msg.get("row") in self.queue: # started: out of the queue + self.set_queue([n for n in self.queue if n != msg.get("row")]) elif kind == "row_done": self.done_rows.append(msg) if self.on_row: @@ -144,6 +147,14 @@ def _event(self, msg): elif kind == "wait": self.wait_until = now() + float(msg.get("seconds") or 0) + def set_queue(self, rows): + """(Apply) the rows to do next, in order: written where the run reads them (apply.queued).""" + self.queue = rows + tmp = self.queue_file + ".tmp" + with open(tmp, "w") as f: + json.dump(rows, f) + os.replace(tmp, self.queue_file) + def answer(self, qid, reply): q = self.question if q is None or q.get("id") != qid or self.state != "running": @@ -179,7 +190,7 @@ def view(self, after=0): "code": self.code, "started": self.started, "ended": self.ended, "log_total": self.total(), "log": self.log[max(0, after - self.dropped):], "question": self.question, - "row": self.row, "done_rows": self.done_rows, "wait_until": self.wait_until, + "row": self.row, "done_rows": self.done_rows, "wait_until": self.wait_until, "queue": self.queue, "screenshot": shot_url(self.screenshot if self.state == "running" else self.last_shot), "browser": self.browser, "background": self.background, # (homelab) its browser on the virtual screen, to watch and use from your phone: the view's link and the @@ -1117,10 +1128,15 @@ def act(body): args = body.get("args") or {} if REMOTE and kind in LAPTOP_ONLY: raise Refused(LAPTOP_ONLY[kind]) - if kind == "apply" and args.get("on") == "homelab": # (laptop) Run on the homelab: its page runs it, this app follows - r = homelab("POST", "/api/action", {"kind": "apply", "args": {k: args[k] for k in ("n", "row", "order") if k in args}}) + if kind in ("apply", "apply_unqueue") and args.get("on") == "homelab": # (laptop) Run on the homelab: its page runs it + r = homelab("POST", "/api/action", {"kind": kind, "args": {k: args[k] for k in ("n", "row", "order") if k in args}}) homelab_soon() return homelab_task_view(r) + if kind == "apply_unqueue": # a queued row's button again: out of the run's queue + cur = apply_run() + if cur and str(args.get("row")).isdigit(): + cur.set_queue([n for n in cur.queue if n != int(args["row"])]) + return {"queue": cur.queue if cur else [], "message": "Taken out of the queue."} if kind == "apply": from nuauto import apply why = apply_list()["why_not"] @@ -1133,12 +1149,20 @@ def act(body): if row not in (None, ""): # just this one Approved row (its Apply button) if not str(row).isdigit() or int(row) not in {r["row"] for r in apply_list()["rows"]}: raise Refused("That row is not Approved (reload the list).") + cur = apply_run() + if cur: # a run is going: this row waits in its queue (done next, in the order you clicked) + return queue_row(cur, int(row)) cmd += ["--row", str(int(row))] elif n not in (None, ""): if not str(n).isdigit() or int(n) < 1: raise Refused("At most how many? A whole number, 1 or more.") cmd += ["-n", str(int(n))] - task = APP.start("apply", "Applying", cmd, env=VISIBLE, on_done=lambda t: APP.run_now(["quick"]), watch=True) + os.makedirs(config.WORK_DIR, exist_ok=True) + qfile = os.path.join(config.WORK_DIR, f"apply_queue_{secrets.token_hex(4)}.json") + with open(qfile, "w") as f: + json.dump([int(n) for n in args.get("queue") or []], f) + task = APP.start("apply", "Applying", cmd + ["--queue", qfile], env=VISIBLE, on_done=apply_done, watch=True) + task.queue_file, task.queue = qfile, [int(n) for n in args.get("queue") or []] elif kind == "update": from nuauto import sync # the homelab's update is its own service (its timer runs it too): started there, never a second copy beside @@ -1279,6 +1303,41 @@ def act(body): return {"task": task.view()} +def apply_run(): + """The Apply run going now (that takes a queue), or None.""" + cur = APP.task + return cur if cur and cur.kind == "apply" and cur.state == "running" and cur.queue_file else None + + +def queue_row(cur, row): + if cur.stopping: + raise Refused("The run is stopping: Apply again once it has.") + if (cur.row or {}).get("row") == row: + raise Refused("That one is being applied to now.") + if row in {d.get("row") for d in cur.done_rows}: + raise Refused("That one was already tried in this run.") + if row not in cur.queue: + cur.set_queue(cur.queue + [row]) + return {"queue": cur.queue, "message": f"Queued: #{cur.queue.index(row) + 1} in line."} + + +def apply_done(task): + """An Apply run ended. Rows queued just as it was ending (it had already found the queue empty) get a run of + their own; a stopped or failed run drops its queue.""" + APP.run_now(["quick"]) + try: + os.remove(task.queue_file) + except OSError: + pass + approved = {r["row"] for r in apply_list()["rows"]} + left = [n for n in task.queue if n in approved] + if left and task.state == "done": + try: + act({"kind": "apply", "args": {"row": left[0], "queue": left[1:]}}) + except (Refused, Busy): + pass + + def answer(body): task = APP.task if task is None or task.id != body.get("task"): diff --git a/src/nuauto/gui_static/app.js b/src/nuauto/gui_static/app.js index 5aeac80..c712a4b 100644 --- a/src/nuauto/gui_static/app.js +++ b/src/nuauto/gui_static/app.js @@ -1106,7 +1106,7 @@ screens.apply = async (view) => { let words = []; // the search box's words: a row shows when it has every one let folded = {}; // the sections you opened or folded, "tab:section" -> open try { folded = JSON.parse(sessionStorage.getItem("applyOpen") || "{}"); } catch (e) { folded = {}; } - let drawnRunning = null, drawnHomelab = ""; + let drawnRunning = null, drawnHomelab = "", drawnQueue = ""; // Made once and moved into each redraw, so what you typed (and the focus) is never lost. const atMost = el("input", { type: "number", min: "1", id: "apply-n", testid: "apply-n", "aria-label": "At most this many", placeholder: "all" }); const find = el("input", { type: "search", class: "grow", testid: "apply-search", "data-find": true, @@ -1182,6 +1182,7 @@ screens.apply = async (view) => { function drawPane() { drawnRunning = running(); + drawnQueue = queueKey(); drawBar(); const d = data[tab]; fill(headBox, !ok(d) ? null : tab === "nuworks" ? weekCard(d) : tab === "other" ? otherCard(d) : null); @@ -1232,8 +1233,7 @@ screens.apply = async (view) => { let chips = [], next = null, open = linkOut(r.url, "Open"), about = null; if (key === "approved") { chips = [r.company_site ? el("span", { class: "chip", text: "may also want the company's site", title: r.company_site }) : null, dueChip(r)]; - next = el("button", { class: "btn small", testid: `btn-apply-row-${r.row}`, disabled: !!d.why_not || !!(S.task && S.task.state === "running"), - text: "Apply", title: "Apply to just this job now", onclick: () => applyRow(r) }); + next = applyButton(d, r); } else if (key === "agent") { chips = [dueChip(r)]; about = r.host; @@ -1304,7 +1304,7 @@ screens.apply = async (view) => { followNote(), d.why_not ? el("div", { class: "note warn", testid: "apply-why", text: d.why_not }) : el("details", { class: "how" }, el("summary", { text: "How Start works" }), - el("p", { class: "small muted", text: (onHomelab() ? "Approved in the sheet is your go-ahead. Each job is filled and submitted in a browser on the homelab, which you watch and use from here (its link shows when it starts), one at a time, 30–60 s apart. Only one machine applies at a time." : "Approved in the sheet is your go-ahead. Each job is filled and submitted in a visible Firefox window, one at a time, 30–60 s apart.") + " Every form is screenshotted before Submit. Questions without a saved answer come up here. A job that sends you to the company's own site, or needs something only you can give (a cover letter, a transcript), stops (nothing submitted) and moves to Company site needed or By hand on NUworks below. Stop works like Ctrl+C." }))); + el("p", { class: "small muted", text: (onHomelab() ? "Approved in the sheet is your go-ahead. Each job is filled and submitted in a browser on the homelab, which you watch and use from here (its link shows when it starts), one at a time, 10–20 s apart. Only one machine applies at a time." : "Approved in the sheet is your go-ahead. Each job is filled and submitted in a visible Firefox window, one at a time, 10–20 s apart.") + " Every form is screenshotted before Submit. Questions without a saved answer come up here. A job that sends you to the company's own site, or needs something only you can give (a cover letter, a transcript), stops (nothing submitted) and moves to Company site needed or By hand on NUworks below. While a run goes, a row's Apply adds it to the run's queue: queued rows are done next, in the order you clicked. Stop works like Ctrl+C (and empties the queue)." }))); } // Run on: this computer / the homelab (the laptop linked to it): where Start, and each row's Apply, run. Both tabs. @@ -1373,6 +1373,25 @@ screens.apply = async (view) => { pre.scrollTop = pre.scrollHeight; } + // A row's Apply: just this job now. While an Apply run goes, it joins that run's queue instead (done next, in the + // order you clicked; the button again takes it out). + function applyButton(d, r) { + const t = S.task, live = running(), q = (live && t.queue) || [], at = q.indexOf(r.row), testid = `btn-apply-row-${r.row}`; + if (live && t.row && t.row.row === r.row && !t.done_rows.some((x) => x.row === r.row)) return el("button", { class: "btn small", testid, disabled: true, text: "Applying…" }); + if (at >= 0) return el("button", { class: "btn small", testid, text: `Queued #${at + 1}`, title: "Take it out of the queue", + onclick: () => queueRow(r, false) }); + return el("button", { class: "btn small", testid, disabled: !!d.why_not || !!(S.task && S.task.state === "running" && !live), + text: live ? "Add to queue" : "Apply", title: live ? "Apply to it next in this run" : "Apply to just this job now", + onclick: () => live ? queueRow(r, true) : applyRow(r) }); + } + + function queueKey() { return running() ? `${(S.task.row || {}).row}:${(S.task.queue || []).join(",")}` : ""; } + + async function queueRow(r, add) { // the run's own machine (a run followed from the homelab: there) + const res = await action(add ? "apply" : "apply_unqueue", S.taskWhere === "homelab" ? { row: r.row, on: "homelab" } : { row: r.row }); + if (res && res.queue && running()) { S.task.queue = res.queue; drawPane(); } + } + function applyRow(r) { confirmBox(`Apply to ${r.company}?`, `${r.title} is filled and submitted now in ${onHomelab() || runOn() === "homelab" ? "a browser on the homelab you can watch from here" : "a visible Firefox window"}, the same way Start does it. Only this job.`, "Apply now", async () => { if (await action("apply", runArgs({ row: r.row }))) drawPane(); }); @@ -1394,7 +1413,7 @@ screens.apply = async (view) => { await reload(true); // refresh: the sheet changed (a run finished, a row was applied, something was done elsewhere): read the lists again - return { task: () => { drawRun(); if (S.task && S.task.state !== "running") reload(); }, + return { task: () => { drawRun(); if (S.task && S.task.state !== "running") reload(); else if (queueKey() !== drawnQueue) drawPane(); }, update: () => { // redraw when a run starts or ends here, or one starts or ends on the homelab (its "Follow it here") const ht = S.state && S.state.homelab_link && S.state.homelab_link.task, key = ht ? `${ht.id}:${ht.state}` : ""; if (running() !== drawnRunning || key !== drawnHomelab) { drawnHomelab = key; drawPane(); } diff --git a/tests/test_apply.py b/tests/test_apply.py index fd699d0..c00e8cf 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -1,4 +1,4 @@ -"""Offline checks for apply.check_popup. Run: python test_apply.py""" +"""Offline checks for apply.check_popup and the GUI's queue (apply.run_rows --queue). Run: python test_apply.py""" from nuauto.apply import NeedsHuman, check_popup LABEL = "Doe, Jane | Spring 2027" @@ -51,4 +51,45 @@ def expect_stop(text, links, fields, needle): # no dropdown stops expect_stop("Apply to X", [], [], "exactly one Resume") +# the GUI's queue (apply.run_rows --queue): rows queued while a run goes are done next, in the order queued; a row's +# own Apply then ends when the queue is empty; rows no longer Approved are skipped; -n is extended by queued rows only +import json, os, tempfile, types +from nuauto import apply, sheet + +def run(queue, row=None, n=None, add_during=None): + rows = [sheet.Row(k, f"u{k}", f"C{k}", "T", "Approved", "", "") for k in range(2, 8)] + rows[3].status = "Applied" # row 5: sent already + qfile = os.path.join(tempfile.mkdtemp(), "queue.json") + open(qfile, "w").write(json.dumps(queue)) + done = [] + + def one(r, *a): + done.append(r.number) + r.status = "Applied" + if add_during and r.number == add_during[0]: # you click another row's Apply while this one runs + now = json.loads(open(qfile).read()) + open(qfile, "w").write(json.dumps(now + [add_during[1]])) + return "next" + patch = {"read_rows": lambda ws: rows, "check_limits": lambda rs: (0, 0), "week_window": lambda: (None, "this week")} + saved = {k: getattr(sheet, k) for k in patch} | {"apply_one": apply.apply_one, "sleep": apply.time.sleep} + for k, v in patch.items(): + setattr(sheet, k, v) + apply.apply_one, apply.time.sleep = one, lambda s: None + try: + io = types.SimpleNamespace(event=lambda *a, **k: None) + apply.run_rows(types.SimpleNamespace(row=row, n=n, order="closes", queue=qfile), None, "", [], io) + finally: + apply.apply_one, apply.time.sleep = saved.pop("apply_one"), saved.pop("sleep") + for k, v in saved.items(): + setattr(sheet, k, v) + return done + +assert run([], row=3) == [3] # one row's Apply, nothing queued: only it +assert run([6, 2], row=3) == [3, 6, 2] # then the queue, in the order queued +assert run([5, 6], row=3) == [3, 6] # row 5 is not Approved: skipped +assert run([], row=3, add_during=(3, 7)) == [3, 7] # queued while the run goes +assert run([7], n=1) == [2, 7] # Start (-n 1): its row, then the queued one +assert run([], n=2) == [2, 3] +assert apply.BETWEEN == (10, 20) + print("All checks passed.")