Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions docs/GUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/SAFETY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 28 additions & 4 deletions src/nuauto/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
67 changes: 63 additions & 4 deletions src/nuauto/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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":
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand All @@ -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
Expand Down Expand Up @@ -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"):
Expand Down
Loading
Loading