Restart the clock: ./ksim restart and a top-bar button

Puts the session's full time back on the clock and starts it running,
keeping the hosts, the work on them, results, flags and notes. Like
pause it is recorded nowhere: the attempt's time used counts from the
restart. Refused once a session has been submitted. POST /api/restart
backs the button, which asks before it resets; a ./ksim web started
before this says so instead of failing silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZPd3GGWyxTgAkL6a4gAJz
This commit is contained in:
vumas3r 2026-10-15 15:36:09 -06:00
parent c266eeb4b5
commit c244bd9c75
3 changed files with 32 additions and 3 deletions

View File

@ -92,7 +92,8 @@ In the browser: the task on the left names its host; click `ssh cka0037` (or any
value) to put it on the desktop's clipboard and paste in the terminal with **Ctrl+Shift+V**.
Firefox in the desktop opens only the allowed documentation for the track (`KSIM_OPEN_WEB=1`
lifts that). Submit grades every task on its own host; the hosts stay up for review until the next
session starts.
session starts. **Restart clock** in the top bar (`./ksim restart`) puts the session's full time
back on the clock without touching its hosts or your work; like Pause, it is recorded nowhere.
Everything is also available from a terminal. A command is for the track named with `--track` or
`KSIM_TRACK`; with neither, for the track whose session is under way, and for CKA when there is
@ -110,6 +111,7 @@ host name such as `ckad0013` only resolves in the desktop's terminal: from your
./ksim skip DESIGN | unskip DESIGN # "I am good with this domain (or objective 2.3)": off the path, and back
./ksim drill OBS:3 | next OBS:3 # three tasks of a domain, each from another objective - alone, or added
./ksim list | show N | grade N | solution N | timer | end
./ksim pause | resume | restart # stop the clock, or put the session's full time back (hosts and work kept)
./ksim hosts [start] # the session's hosts; `start` brings them back after a reboot
./ksim ssh N # a shell on the host of task N from your own terminal
./ksim reset N # rebuild the host of task N from scratch

24
ksim
View File

@ -22,7 +22,7 @@ from collections.abc import Callable
from datetime import datetime
from typing import Any
VERSION = "2026.10.15.1"
VERSION = "2026.10.15.2"
ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT)
from sim import desktop, envs # noqa: E402
@ -299,6 +299,12 @@ def set_paused(st: dict[str, Any], on: bool) -> None:
st["paused_total"] = float(st.get("paused_total", 0)) + (time.time() - float(st["paused_at"])); st["paused_at"] = None
save_state(st)
def restart_clock(st: dict[str, Any]) -> None:
"""The session's full time again, from now and running. Hosts, the work on them, results,
flags and notes stay as they are."""
st["started_at"] = time.time(); st["paused_total"] = 0.0; st["paused_at"] = None
save_state(st)
def fmt(secs: float | int) -> str:
secs_int = max(0, int(secs))
return f"{secs_int // 3600:02d}:{(secs_int % 3600) // 60:02d}:{secs_int % 60:02d}"
@ -1643,6 +1649,15 @@ def cmd_resume(args):
st = require_started()
set_paused(st, False); print(status_line(st))
def cmd_restart(args):
"""Start the clock again from the session's full time, keeping its hosts and your work. Like
pause, not recorded anywhere: the attempt's time used counts from the restart."""
st = require_started()
if st.get("ended_at"):
die("Session already ended - nothing to restart. Start a new one: ./ksim drill, ./ksim start, ./ksim next")
restart_clock(st)
print(status_line(st) + dim(" clock restarted - the hosts and your work are as they were"))
def cmd_end(args):
st = require_started()
if st.get("ended_at"):
@ -1843,6 +1858,10 @@ class Api(http.server.BaseHTTPRequestHandler):
flags.symmetric_difference_update({qid}); st["flags"] = sorted(flags); save_state(st)
elif p.path == "/api/pause":
set_paused(st, bool(body.get("paused", True)))
elif p.path == "/api/restart":
if st.get("ended_at"):
return self._json({"error": "This session has ended - there is no clock to restart."}, 400)
restart_clock(st)
elif p.path == "/api/notes":
st["notes"] = body.get("notes", ""); save_state(st)
elif p.path == "/api/grade":
@ -2215,6 +2234,7 @@ def cmd_help(args):
grade N|all grade one task (or all) per check - drills only (--force to peek in an exam)
status / timer time remaining (timer = live countdown)
pause / resume stop the clock (not recorded anywhere - but the real exam has no pause)
restart the clock from the top: the session's full time again, hosts and work kept (not recorded)
end submit: grade per check, score, log attempt, update weak-spots
weak what you keep getting wrong, with the failing checks
history every attempt, scores, and per-domain trend
@ -2228,7 +2248,7 @@ def cmd_help(args):
""")
COMMANDS = {"start": cmd_start, "drill": cmd_drill, "list": cmd_list, "show": cmd_show, "grade": cmd_grade,
"status": cmd_status, "timer": cmd_timer, "pause": cmd_pause, "resume": cmd_resume, "end": cmd_end, "weak": cmd_weak, "history": cmd_history,
"status": cmd_status, "timer": cmd_timer, "pause": cmd_pause, "resume": cmd_resume, "restart": cmd_restart, "end": cmd_end, "weak": cmd_weak, "history": cmd_history,
"reset": cmd_reset, "web": cmd_web, "abort": cmd_abort, "progress": cmd_progress, "solution": cmd_solution, "sim": cmd_sim, "version": cmd_version, "help": cmd_help,
"build": cmd_build, "hosts": cmd_hosts, "ssh": cmd_ssh, "down": cmd_down, "audit": cmd_audit, "coverage": cmd_coverage,
"objectives": cmd_objectives, "curriculum": cmd_objectives, "notes": cmd_notes, "next": cmd_next, "focus": cmd_focus,

View File

@ -170,6 +170,7 @@
<span class="spacer"></span>
<span id="clock">--:--:--</span>
<button class="btn ghost" id="btnPause">Pause</button>
<button class="btn ghost" id="btnRestart" title="Back to the session's full time - the hosts and your work stay as they are">Restart clock</button>
<button class="btn ghost" id="btnHistory">History</button>
<button class="btn danger" id="btnEnd">Submit exam</button>
</header>
@ -365,6 +366,7 @@
+ (sess.objective ? ' · objective ' + sess.objective : '') : '';
$('#btnEnd').classList.toggle('hidden', !live);
$('#btnPause').classList.toggle('hidden', !live);
$('#btnRestart').classList.toggle('hidden', !live);
if (live) $('#btnPause').textContent = sess.paused ? 'Resume' : 'Pause';
$('#btnCheck').classList.toggle('hidden', !(live && sess.mode === 'drill'));
$('#lobby').classList.toggle('hidden', !!live || (sess && sess.ended_at && !$('#results').classList.contains('hidden')));
@ -598,6 +600,11 @@
$('#btnNewExam').onclick = () => { $('#results').classList.add('hidden'); $('#lobby').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden'); };
$('#btnHistory').onclick = showHistory;
$('#btnPause').onclick = async () => { S = await api('/api/pause', {paused: !S.session.paused}); skew = Date.now()/1000 - S.session.server_time; paint(); };
$('#btnRestart').onclick = async () => {
if (!confirm(`Restart the clock at ${fmt(S.session.duration)}? The hosts and your work stay as they are.`)) return;
try { S = await api('/api/restart', {}); skew = Date.now()/1000 - S.session.server_time; autoSubmitted = false; paint(); }
catch (e) { alert(e.message === 'not found' ? 'This ./ksim web was started before "restart" existed. Stop it (Ctrl-C) and start it again.' : e.message); }
};
$('#btnCloseHist').onclick = () => $('#histOv').classList.add('hidden');
// draggable split
const handle = $('#handle'), main = document.querySelector('main');