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
21 changes: 14 additions & 7 deletions src/main/scala/ccas/analysis/apps/recruitment/RecruitmentApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ object RecruitmentApp extends ZIOAppDefault {
if (parsed.cumulative) RecruitmentCandidate.selectInvitedToday(run.clubId, parsed.alias)
else RecruitmentCandidate.selectInvitedByRun(run.runId)
resolvedMap <- Player.resolveUsernames(candidates.map(_.playerId))
usernames = candidates.map(c => resolvedMap.getOrElse(c.playerId, Username.wrap(s"[pid=${c.playerId}]")))
// Drop any player_id that doesn't resolve to a handle — these lists are paste-ready invite targets, and a
// `[pid=N]` placeholder isn't invitable. Matches the server's `usernamesFor` so the file and the
// --report/clipboard output are identical.
usernames = candidates.flatMap(c => resolvedMap.get(c.playerId))
evaluatedCount <- RecruitmentCandidate.selectCountByRun(run.runId)
now <- Clock.instant
output = formatRecruitmentOutput(
Expand Down Expand Up @@ -162,7 +165,7 @@ object RecruitmentApp extends ZIOAppDefault {
else ZIO.succeed(0)
effectiveTarget = (resolvedTarget - alreadyFound) max 0
now <- Clock.instant
runId <- RecruitmentRun.insert(clubId, criteria.criteriaId, trigger, now, jobRunId)
runId <- RecruitmentRun.insert(clubId, criteria.criteriaId, trigger, now, Some(effectiveTarget), jobRunId)

// --- Shared setup ---
targetMembers <- ApiClubMembers.get(client, effectiveSlug)
Expand Down Expand Up @@ -401,6 +404,7 @@ object RecruitmentApp extends ZIOAppDefault {
startedAt,
Some(completedAt),
confirmed.size,
Some(ctx.target),
jobRunId
)
_ <- RecruitmentRun.update(finalRun)
Expand Down Expand Up @@ -484,12 +488,15 @@ object RecruitmentApp extends ZIOAppDefault {
}
invited <- RecruitmentCandidate.selectInvitedByRun(run.runId)
evaluatedCount <- RecruitmentCandidate.selectCountByRun(run.runId)
_ <- ZIO.logInfo(s"=== Recruitment Report for $clubSlug (run ${run.runId}) ===")
_ <- ZIO.logInfo(s"Started: ${run.startedAt}")
_ <- ZIO.logInfo(s"Completed: ${run.completedAt.getOrElse("in progress")}")
_ <- ZIO.logInfo(s"Evaluated: $evaluatedCount | Invited: ${invited.size}")
resolvedMap <- Player.resolveUsernames(invited.map(_.playerId))
usernames = invited.map(c => resolvedMap.getOrElse(c.playerId, Username.wrap(s"[pid=${c.playerId}]")))
// Drop unresolved player_ids (see the scout path): paste-ready invite list, identical to the server's
// `usernamesFor`, so `report`'s file matches `--report`/clipboard.
usernames = invited.flatMap(c => resolvedMap.get(c.playerId))
_ <- ZIO.logInfo(s"=== Recruitment Report for $clubSlug (run ${run.runId}) ===")
_ <- ZIO.logInfo(s"Started: ${run.startedAt}")
_ <- ZIO.logInfo(s"Completed: ${run.completedAt.getOrElse("in progress")}")
// Report the paste-ready count (post-drop), matching the file's stat line and the --report/clipboard payload.
_ <- ZIO.logInfo(s"Evaluated: $evaluatedCount | Invited: ${usernames.size}")
_ <- ZIO.logInfo(usernames.mkString(" "))
_ <- ZIO.logInfo("")
_ <- ZIO.foreachDiscard(usernames) { name =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,10 @@ private[recruitment] object RecruitmentExplore {
)
} yield ()

/** When found count exceeds the target, trim the newest excess from invitedRef. All candidates are already Deferred
* in the DB; no status flip needed here.
/** When found count exceeds the target, trim the newest excess from invitedRef so the loop stops and the auto-confirm
* `found` list is capped. The excess candidates stay Deferred in the DB (carried to the next run via
* `selectDeferredByClub`); the interactive confirm path caps its flip at the run's target, so a chunk overshoot never
* invites beyond target. No DB write is needed here.
*/
def reclassifyExcessInvited(ctx: ExploreContext): RIO[PostgresClient, Unit] =
// invitedRef is prepend-ordered (newest first), so drop excess from the head
Expand Down
36 changes: 29 additions & 7 deletions src/main/scala/ccas/analysis/tables/RecruitmentCandidate.scala
Original file line number Diff line number Diff line change
Expand Up @@ -118,25 +118,47 @@ object RecruitmentCandidate {
.query[Int].run().headOption
}.someOrFail(new SQLException("COUNT query produced no rows"))

// The run's still-deferred candidates, for the interactive `ccas recruit` confirm prompt (a deferred-confirm run
// leaves everything Deferred; the operator reviews these before any are marked Invited). Ordered by player_id to
// match `selectInvitedByRun`'s deterministic output.
// The run's still-deferred candidates the operator will confirm, for the interactive `ccas recruit` confirm prompt (a
// deferred-confirm run leaves everything Deferred; the operator reviews these before any are marked Invited). Capped
// at the run's remaining budget — `target` minus those already Invited this run — so a chunk overshoot isn't
// shown/confirmed above target and a re-fetch after a partial confirm shows only what's still confirmable. The excess
// stays Deferred and carries to the next run. Legacy runs (NULL target) fall through to `LIMIT NULL` (no cap). Ordered
// by player_id to match `selectInvitedByRun` and `confirmDeferredByRun`.
def selectDeferredByRun(runId: RecruitmentRunId): ZIO[PostgresClient, SQLException, List[RecruitmentCandidate]] =
connectZIO {
val deferred: CandidateOutcome = CandidateOutcome.Deferred
val invited: CandidateOutcome = CandidateOutcome.Invited
sql"""SELECT $selectCols FROM recruitment_candidate WHERE run_id = $runId AND outcome = $deferred
ORDER BY player_id"""
ORDER BY player_id
LIMIT (SELECT CASE WHEN rr.target IS NULL THEN NULL
ELSE GREATEST(rr.target - (SELECT COUNT(*) FROM recruitment_candidate
WHERE run_id = $runId AND outcome = $invited), 0)
END
FROM recruitment_run rr WHERE rr.run_id = $runId)"""
.query[RecruitmentCandidate].run().toList
}

// Confirm a deferred-confirm run: flip its Deferred candidates to Invited in one statement. Returns rows affected
// (0 if already confirmed or nothing found), so the caller can update `recruitment_run.candidates_found`.
// Confirm a deferred-confirm run: flip its remaining-budget Deferred candidates (lowest player_ids first) to Invited.
// Matches `selectDeferredByRun`'s cap+order so the operator invites exactly what the prompt showed. The cap is
// `target - already-invited-this-run`, so a re-POST after a full confirm flips 0 (idempotent, per the endpoint's
// contract) rather than sweeping up the still-Deferred overshoot; that excess carries to the next run. Legacy runs
// (NULL target) flip all Deferred. Returns rows affected (0 if already confirmed or nothing found), so the caller can
// update `recruitment_run.candidates_found`.
def confirmDeferredByRun(runId: RecruitmentRunId): ZIO[PostgresClient, SQLException, Int] =
connectZIO {
val invited: CandidateOutcome = CandidateOutcome.Invited
val deferred: CandidateOutcome = CandidateOutcome.Deferred
sql"""UPDATE recruitment_candidate SET outcome = $invited
WHERE run_id = $runId AND outcome = $deferred""".update.run()
WHERE run_id = $runId AND player_id IN (
SELECT player_id FROM recruitment_candidate
WHERE run_id = $runId AND outcome = $deferred
ORDER BY player_id
LIMIT (SELECT CASE WHEN rr.target IS NULL THEN NULL
ELSE GREATEST(rr.target - (SELECT COUNT(*) FROM recruitment_candidate
WHERE run_id = $runId AND outcome = $invited), 0)
END
FROM recruitment_run rr WHERE rr.run_id = $runId)
) AND outcome = $deferred""".update.run()
}

/** Returns deferred candidates for a club that have not been resolved (Invited/Rejected) in a later run. */
Expand Down
12 changes: 9 additions & 3 deletions src/main/scala/ccas/analysis/tables/RecruitmentRun.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,16 @@ final case class RecruitmentRun(
startedAt: Instant,
completedAt: Option[Instant],
candidatesFound: Int,
// The run's effective target (invite budget). Read by `selectDeferredByRun` / `confirmDeferredByRun` to cap the
// interactive confirm at target, leaving any chunk-overshoot excess Deferred to carry forward. NULL only for legacy
// rows created before this column existed (treated as "no cap").
target: Option[Int],
jobRunId: Option[JobRunId]
) derives DbCodec

object RecruitmentRun {
private val selectCols = SqlLiteral(
"run_id, club_id, criteria_id, trigger, started_at, completed_at, candidates_found, job_run_id"
"run_id, club_id, criteria_id, trigger, started_at, completed_at, candidates_found, target, job_run_id"
)

def createTable: ZIO[PostgresClient, SQLException, Int] =
Expand All @@ -39,6 +43,7 @@ object RecruitmentRun {
started_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
candidates_found INT NOT NULL,
target INT,
job_run_id TEXT,
FOREIGN KEY (club_id) REFERENCES club (club_id) ON DELETE RESTRICT,
FOREIGN KEY (criteria_id) REFERENCES recruitment_criteria (criteria_id) ON DELETE RESTRICT
Expand Down Expand Up @@ -82,11 +87,12 @@ object RecruitmentRun {
criteriaId: Long,
trigger: RunTrigger,
startedAt: Instant,
target: Option[Int],
jobRunId: Option[JobRunId]
): ZIO[PostgresClient, SQLException, RecruitmentRunId] =
connectZIO {
sql"""INSERT INTO recruitment_run (club_id, criteria_id, trigger, started_at, candidates_found, job_run_id)
VALUES ($clubId, $criteriaId, $trigger, $startedAt, 0, $jobRunId)
sql"""INSERT INTO recruitment_run (club_id, criteria_id, trigger, started_at, candidates_found, target, job_run_id)
VALUES ($clubId, $criteriaId, $trigger, $startedAt, 0, $target, $jobRunId)
RETURNING run_id""".query[RecruitmentRunId].run().headOption
}.someOrFail(new SQLException("INSERT RETURNING produced no rows"))

Expand Down
69 changes: 59 additions & 10 deletions src/main/scala/ccas/cli/Dispatcher.scala
Original file line number Diff line number Diff line change
Expand Up @@ -330,20 +330,69 @@ object Dispatcher {
"they are listed in the job log above."
).orDie

// OSC 52 clipboard write: `ESC ] 52 ; c ; <base64 payload> BEL`. The terminal itself owns the clipboard, so this
// works over SSH (tmux needs `set -g set-clipboard on`). Payloads are a handful of usernames, far under any
// terminal's OSC 52 size cap. ESC/BEL as \u escapes per the repo's no-raw-control-bytes convention.
// Copy the usernames to the system clipboard. A native clipboard tool is preferred: its forked daemon keeps owning
// the selection after this short-lived CLI exits, whereas an in-process JVM clipboard (AWT) would lose the contents
// the moment we exit. The OSC 52 terminal escape is only a fallback for a remote/SSH session whose *local* terminal
// implements it — many terminals (notably GNOME Terminal / VTE) silently ignore OSC 52 clipboard writes, which is
// why the old escape-only path copied nothing. The "Copied" line prints only when the copy actually succeeds.
private def copyToClipboard(names: List[String]): Task[Unit] =
if (names.isEmpty) { Console.printLineError("no invited usernames to copy").orDie }
// Callers only reach here on a TTY, but guard defensively: an OSC 52 escape to a non-terminal would corrupt the
// stream and no terminal is there to honour it, so fall back to just printing the usernames.
else if (!hasTty) {
Console.printLineError("clipboard unavailable (not an interactive terminal); printing usernames instead").orDie *>
printBare(names)
} else {
else {
clipboardCommand match {
case Some(cmd) =>
copyViaTool(cmd, names).flatMap { copied =>
if (copied) { Console.printLine(s"Copied ${names.size} usernames.").orDie }
else { copyFallback(names) }
}
case None => copyFallback(names)
}
}

// The native clipboard command for this environment, if any: macOS -> pbcopy; Wayland -> wl-copy; X11 -> xclip.
// None on a headless / unknown session, which routes to the OSC 52 fallback.
private def clipboardCommand: Option[List[String]] =
if (isMac) { Some(List("pbcopy")) }
else if (envSet("WAYLAND_DISPLAY")) { Some(List("wl-copy")) }
else if (envSet("DISPLAY")) { Some(List("xclip", "-selection", "clipboard")) }
else { None }

private def isMac: Boolean = sys.props.getOrElse("os.name", "").toLowerCase.contains("mac")
private def envSet(name: String): Boolean = Option(java.lang.System.getenv(name)).exists(_.nonEmpty)

// Feed the newline-joined usernames to the clipboard tool's stdin, then wait. Returns true only if the process
// launched and exited 0; a launch failure (tool not installed -> IOException) or non-zero exit yields false so the
// caller falls back. Closing stdin sends EOF, which wl-copy / xclip / pbcopy wait for before taking ownership.
private def copyViaTool(cmd: List[String], names: List[String]): UIO[Boolean] =
ZIO.attemptBlocking {
val pb = new ProcessBuilder(cmd*)
// Discard the tool's stdout/stderr rather than leaving an un-drained pipe that could wedge waitFor if the tool
// ever got chatty (wl-copy / xclip / pbcopy are silent on success, but this is robust regardless).
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD)
pb.redirectError(ProcessBuilder.Redirect.DISCARD)
val process = pb.start()
val stdin = process.getOutputStream
try { stdin.write(names.mkString("\n").getBytes(StandardCharsets.UTF_8)) }
finally { stdin.close() }
process.waitFor() == 0
}.orElseSucceed(false)

// No working native clipboard tool. The usernames were already printed by the caller (renderReport / confirmPrompt),
// so don't reprint them — on a TTY emit the OSC 52 escape as a best-effort copy (works only if the terminal honours
// it, e.g. an SSH session into kitty / tmux with `set -g set-clipboard on`) and note the caveat on stderr. ESC/BEL as
// \u escapes per the repo's no-raw-control-bytes convention.
private def copyFallback(names: List[String]): Task[Unit] =
if (hasTty) {
val b64 = Base64.getEncoder.encodeToString(names.mkString("\n").getBytes(StandardCharsets.UTF_8))
val osc52 = "\u001b]52;c;" + b64 + "\u0007"
Console.print(osc52).orDie *> Console.printLine(s"Copied ${names.size} usernames.").orDie
Console.print(osc52).orDie *>
Console.printLineError(
"no clipboard tool found (install wl-copy or xclip); sent an OSC 52 copy escape as a fallback — it only " +
"works if your terminal supports it. The usernames are listed above."
).orDie
} else {
Console.printLineError(
"clipboard unavailable (no clipboard tool and not an interactive terminal); the usernames are listed above."
).orDie
}

private def parseMisfire(s: String): IO[CliError, MisfirePolicy] =
Expand Down
Loading