Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ The common ones:
| Variable | Default | Purpose |
|---|---|---|
| `CODETRIAL_WEB_ADDR` | `127.0.0.1:3000` | Listen address |
| `CODETRIAL_DURATION_MIN` | `45` | Interview length preselected in the lobby (10–90); see [interview length](docs/interview-length.md) |
| `CODETRIAL_DURATION_MIN` | `45` | Interview length preselected in the lobby, snapped to the nearest enabled button, and granted unsnapped (75 stays 75) by `/api/token` to any request that names none, such as a link that skips the lobby; clamped to 10–90; where recording is on, startup refuses a `CODETRIAL_RECORDING_MAX_MINUTES` below the clamped value, see [interview length](docs/interview-length.md) |
| `GEMINI_LIVE_MODEL` | `gemini-3.1-flash-live-preview` | Realtime interviewer model |
| `GEMINI_REPORT_MODEL` | `gemini-3.1-flash-lite` | Report model |
| `CODETRIAL_MAX_INTERIM_REVIEWS` | `6` | Quiet-pause report-model reviews per interview; `0` disables them and `72` is the maximum |
Expand Down
29 changes: 21 additions & 8 deletions docs/interview-length.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,21 @@ both. One rule matters more than the rest: the length offered and the length
enforced come from one function, so they cannot drift apart.

The lobby offers 30, 45, and 60 minutes, and the candidate picks one before
starting. `CODETRIAL_DURATION_MIN` is only the length preselected for a
candidate who does not choose; the endpoint accepts 10 to 90 either way, so a
deployment that lowers the default has not lowered the ceiling.
starting. `CODETRIAL_DURATION_MIN` is the length preselected for a candidate who
does not choose; the endpoint accepts 10 to 90 either way, so a deployment that
lowers the default has not lowered the ceiling.

`/api/session` reports the default as `defaultDurationMin`, clamped into 10 to
Comment thread
RWong04 marked this conversation as resolved.
Resolved
90; where recording is on, startup refuses a cap below that clamped length, as
the last paragraph says. The lobby preselects it for Medium and Hard, and
for Easy at most 30. A default that is not one of the three buttons selects the
nearest enabled one, and a length the candidate chose is never replaced by the
default. The Easy cap and the snapping are the lobby's suggestion only.
`/api/token` does not look at the problem: a request that names no length, such
as a link that skips the lobby, is granted `defaultDurationMin` itself, not
snapped, whatever the difficulty. A `duration` in the interview URL that is not
a positive number (`0`, `-5`, `30abc`, `abc` or empty) names no length, so the
interview page sends none and gets the same default.

Recording lowers it. `CODETRIAL_RECORDING_MAX_MINUTES` bounds a single
recording, and the sweeper fails one still running five minutes past it, so an
Expand All @@ -24,8 +36,9 @@ a request that skips the lobby is bounded too, and both endpoints read it from
one function so the length that is offered and the length that is enforced
cannot drift apart.

Startup refuses a cap below `CODETRIAL_DURATION_MIN`, so the length this
deployment preselects is always one it can record; the pairing is a
configuration error rather than a quietly shortened interview. Should a cap ever
sit below every length the row offers, the lobby leaves the row alone rather
than rendering a page with nothing to press.
Where recording is on, startup refuses a cap below `CODETRIAL_DURATION_MIN`,
held to 10 to 90 like the default itself, so the length this deployment
preselects is always one it can record; the pairing is a configuration error
rather than a quietly shortened interview. Should a cap ever sit below every
length the row offers, the lobby leaves the row alone rather than rendering a
page with nothing to press.
51 changes: 50 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,10 @@ pub fn load_from_pairs(
DEFAULT_GEMINI_END_SENSITIVITY,
),
room_prefix: optional(&values, "CODETRIAL_ROOM_PREFIX", DEFAULT_ROOM_PREFIX),
default_duration_min: optional_u32(&values, "CODETRIAL_DURATION_MIN", DEFAULT_DURATION_MIN),
default_duration_min: configured_duration(
values.get("CODETRIAL_DURATION_MIN").map(String::as_str),
)
.0,
gemini_candidate_video_enabled: gemini_candidate_video_enabled(
values
.get("CODETRIAL_GEMINI_CANDIDATE_VIDEO_ENABLED")
Expand Down Expand Up @@ -638,6 +641,52 @@ pub fn max_concurrent_interviews(
}
}

/// The length `CODETRIAL_DURATION_MIN` stands for, held to the range the
/// endpoints offer, and what to say when it is not used as written: unreadable,
/// so the built-in length applies, or outside the range, so it is clamped.
/// One function for both, so the number in the message is the one the server
/// runs on and the operator who mistyped is not left guessing.
pub fn configured_duration(value: Option<&str>) -> (u32, Option<String>) {
Comment thread
RWong04 marked this conversation as resolved.
Resolved
let value = value.map(str::trim).unwrap_or_default();
if value.is_empty() {
return (DEFAULT_DURATION_MIN, None);
}

// A number past `i64` is still a number, only a very large or small one, so
// it is clamped like the rest and only text that is no number falls back.
let digits = value.strip_prefix(['+', '-']).unwrap_or(value);
let number = value.parse::<i64>().ok().or_else(|| {
(!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())).then_some(
if value.starts_with('-') {
i64::MIN
} else {
i64::MAX
},
)
});
let (reason, used) = match number {
Some(minutes) => {
let used = u32::try_from(
minutes.clamp(i64::from(MIN_DURATION_MIN), i64::from(MAX_DURATION_MIN)),
)
.unwrap_or(DEFAULT_DURATION_MIN);
if i64::from(used) == minutes {
return (used, None);
}
(
format!("is outside {MIN_DURATION_MIN} to {MAX_DURATION_MIN}"),
used,
)
}
None => (
"is not a usable number of minutes".to_string(),
DEFAULT_DURATION_MIN,
),
};
let warning = format!("codetrial: CODETRIAL_DURATION_MIN {value:?} {reason}; read as {used}");
(used, Some(warning))
}

fn optional_u32(values: &BTreeMap<String, String>, key: &str, default: u32) -> u32 {
values
.get(key)
Expand Down
23 changes: 14 additions & 9 deletions src/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ mod peer;

pub use peer::{PeerServices, peer_services};

use crate::config::{DEFAULT_DURATION_MIN, DEFAULT_ROOM_PREFIX, DEFAULT_WEB_DIR};
use crate::config::{DEFAULT_ROOM_PREFIX, DEFAULT_WEB_DIR};
use crate::web::WebServerConfig;

/// One installation keeps its config file and its account database together in
Expand Down Expand Up @@ -62,7 +62,7 @@ pub fn web_server_config(
// does any work: a half-configured recording block is a startup failure,
// never a surprise at the moment a candidate's interview ends.
let recording =
crate::config::load_recording(values, &pool, interview_duration_min(values), production)
crate::config::load_recording(values, &pool, duration_setting(values).0, production)
.map_err(|error| error.to_string())?;
recording_needs_verified_identity(
recording.is_some(),
Expand Down Expand Up @@ -104,6 +104,7 @@ pub fn web_server_config(
allowed_hosts: allowed_hosts(values)?,
compiler_explorer_enabled: compiler_explorer_enabled(values),
guest_mode_enabled: guest_mode_enabled(values),
default_duration_min: duration_setting(values).0,
recording,
peer_observer,
peer,
Expand Down Expand Up @@ -567,13 +568,13 @@ pub fn recording_needs_verified_identity(recording: bool, oauth: bool) -> Result
Ok(())
}

/// The configured interview length, which is what a recording's maximum
/// duration has to clear. Read here rather than through `load_from_pairs`
/// because `codetrial web` deliberately runs without a full agent config.
fn interview_duration_min(values: &BTreeMap<String, String>) -> u32 {
nonempty(values, "CODETRIAL_DURATION_MIN")
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_DURATION_MIN)
/// The configured interview length, held to the range the endpoints offer,
/// which is what a recording's maximum duration has to clear, and what to say
/// when it is not used as written. Read here rather than through
/// `load_from_pairs`, because `codetrial web` deliberately runs without a full
/// agent config.
fn duration_setting(values: &BTreeMap<String, String>) -> (u32, Option<String>) {
crate::config::configured_duration(values.get("CODETRIAL_DURATION_MIN").map(String::as_str))
}

/// `CODETRIAL_ALLOWED_HOSTS`, a comma-separated list of `Host` values.
Expand Down Expand Up @@ -838,6 +839,10 @@ pub fn prepare(
let max_concurrent = read_max_concurrent(values);

let production = crate::config::is_production(values);

if let Some(warning) = duration_setting(values).1 {
eprintln!("{warning}");
}
let pool = web_provider_pool(values, options, production)?;

let config = web_server_config(values, pool, production, options)?;
Expand Down
8 changes: 6 additions & 2 deletions src/web/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,8 +551,10 @@ pub(crate) async fn session_handler(
// The lobby offers lengths this deployment may not be able to record, and
// only the server knows the recording cap. Answered on both branches
// because the duration row is live before anyone signs in.
let max_duration_min =
super::token::duration_ceiling(state.config.recording.as_ref().map(|it| it.max_minutes));
let recording_max_minutes = state.config.recording.as_ref().map(|it| it.max_minutes);
let max_duration_min = super::token::duration_ceiling(recording_max_minutes);
let default_duration_min =
super::token::default_duration(state.config.default_duration_min, recording_max_minutes);

// Null on the web half of a split deployment, where the lobby keeps its
// billing notice hidden rather than describe an agent it cannot see.
Expand All @@ -578,6 +580,7 @@ pub(crate) async fn session_handler(
"guestModeAvailable": state.guest_mode_available(),
"deviceLogin": device_client_id(&state.config).is_some(),
"maxDurationMin": max_duration_min,
"defaultDurationMin": default_duration_min,
"candidateVideo": candidate_video,
"user": {
"login": user.login,
Expand All @@ -599,6 +602,7 @@ pub(crate) async fn session_handler(
"guestModeAvailable": state.guest_mode_available(),
"deviceLogin": device_client_id(&state.config).is_some(),
"maxDurationMin": max_duration_min,
"defaultDurationMin": default_duration_min,
"candidateVideo": candidate_video,
}),
),
Expand Down
5 changes: 5 additions & 0 deletions src/web/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ pub struct WebServerConfig {
pub compiler_explorer_enabled: bool,
/// Guest interviews require explicit operator opt-in.
pub guest_mode_enabled: bool,
/// `CODETRIAL_DURATION_MIN`: the lobby's preselected length, and the one
/// a token request that names none gets.
pub default_duration_min: u32,
/// Number of trusted reverse proxies in front of this server. Zero, the
/// default, keys rate limiting on the TCP peer. Anything higher reads the
/// client from `X-Forwarded-For`, which is only safe once every request is
Expand Down Expand Up @@ -180,6 +183,7 @@ impl fmt::Debug for WebServerConfig {
production,
compiler_explorer_enabled,
guest_mode_enabled,
default_duration_min,
trusted_proxy_hops,
allowed_hosts,
recording,
Expand Down Expand Up @@ -212,6 +216,7 @@ impl fmt::Debug for WebServerConfig {
.field("production", production)
.field("compiler_explorer_enabled", compiler_explorer_enabled)
.field("guest_mode_enabled", guest_mode_enabled)
.field("default_duration_min", default_duration_min)
.field("trusted_proxy_hops", trusted_proxy_hops)
.field("allowed_hosts", allowed_hosts)
.field("recording", recording)
Expand Down
29 changes: 25 additions & 4 deletions src/web/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use axum::response::{IntoResponse, Response};
use ring::rand::SecureRandom;
use serde_json::{Value, json};

use crate::config::{DEFAULT_DURATION_MIN, MAX_DURATION_MIN, MIN_DURATION_MIN};
use crate::config::{MAX_DURATION_MIN, MIN_DURATION_MIN};
use crate::current_epoch_seconds;
use crate::token::{LivekitTokenInput, TOKEN_TTL_SECONDS, livekit_observer_token, livekit_token};

Expand All @@ -39,6 +39,8 @@ pub struct TokenConfig<'a> {
/// recording/sweeper.rs reaps one still running past this many minutes, so
/// a longer interview loses exactly the stretch it was recorded for.
pub recording_max_min: Option<u32>,
/// `CODETRIAL_DURATION_MIN`: the length of a request that names none.
pub default_duration_min: u32,
}

/// Same reason, and the sharpest case of it: `token` is a minted LiveKit
Expand Down Expand Up @@ -300,7 +302,11 @@ pub fn token_response(
.get("problemId")
.and_then(Value::as_str)
.unwrap_or(crate::agent::DEFAULT_PROBLEM_ID);
let duration_min = token_duration_min(request.get("durationMin"), config.recording_max_min);
let duration_min = token_duration_min(
request.get("durationMin"),
config.default_duration_min,
config.recording_max_min,
);
let interview_loop =
crate::agent::InterviewLoop::parse(request.get("interviewLoop").and_then(Value::as_str));
let interview_mode =
Expand Down Expand Up @@ -422,14 +428,28 @@ pub(crate) fn duration_ceiling(recording_max_min: Option<u32>) -> u32 {
})
}

pub(crate) fn token_duration_min(value: Option<&Value>, recording_max_min: Option<u32>) -> Value {
/// The operator's `CODETRIAL_DURATION_MIN` as the lobby and `/api/token` both
/// use it: inside the range the endpoint offers and no longer than the
/// deployment can record. One function for the same reason as
/// [`duration_ceiling`].
pub(crate) fn default_duration(configured_min: u32, recording_max_min: Option<u32>) -> u32 {
configured_min
.clamp(MIN_DURATION_MIN, MAX_DURATION_MIN)
.min(duration_ceiling(recording_max_min))
}

pub(crate) fn token_duration_min(
value: Option<&Value>,
default_duration_min: u32,
recording_max_min: Option<u32>,
) -> Value {
let ceiling = duration_ceiling(recording_max_min);
let duration = value.and_then(crate::agent::json_number).unwrap_or(0.0);
if duration == 0.0 || duration.is_nan() {
// The default is a request like any other where the cap is shorter than
// it. Handing back a length this server cannot record, for a caller who
// named no length at all, is the same lost stretch of interview.
json!(DEFAULT_DURATION_MIN.min(ceiling))
json!(default_duration(default_duration_min, recording_max_min))
Comment thread
RWong04 marked this conversation as resolved.
Resolved
} else {
// Fractional minutes stay fractional: the metadata is echoed back to
// the frontend, which renders whatever the lobby asked for.
Expand Down Expand Up @@ -717,6 +737,7 @@ fn minted_token(
api_secret: &provider.api_secret,
server_url: &provider.url,
recording_max_min: state.config.recording.as_ref().map(|it| it.max_minutes),
default_duration_min: state.config.default_duration_min,
Comment thread
RWong04 marked this conversation as resolved.
Resolved
},
body,
room_name,
Expand Down
2 changes: 1 addition & 1 deletion tests/browser/account.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ test("the lobby offers an editor or a whiteboard and carries the choice into the
);
assertIncludesCompact(
interview,
"durationMin, interviewId, interviewLoop, interviewMode: mode, ...(!whiteboard ? { language: state.language } : {}), interviewProfile, ...(interviewGrounding",
"...(namedDuration ? { durationMin } : {}), interviewId, interviewLoop, interviewMode: mode, ...(!whiteboard ? { language: state.language } : {}), interviewProfile, ...(interviewGrounding",
);
assertIncludesCompact(
interview,
Expand Down
Loading
Loading