deno task reset-day <date> [--host] posts to /admin/reset/<date> with the push token; the running game ends under the write lock, its log moves to data/games/<game>.reset-<n>.jsonl (kept, not archived), the day's index row and archive go, and the day restarts at revision 0 from the same pushed field, seed, rings and honours; /status reports reset: n.
- commit
- f72a9e5cbc1b6184faac4dec7273e40bd2b1253b
- parent
- 074a42a750d19534b2d99642f254c5db93c9e49c
- author
- dami <gazey55@gmail.com>
| M | site/ | 3 | +- |
| M | site/ | 56 | +++++++++++++++++++++++++++++- |
| A | site/ | 32 | +++++++++++++++++ |
| M | site/ | 1 | + |
| A | site/ | 31 | +++++++++++++++++ |
| M | site/ | 53 | ++++++++++++++++++++++++++++ |
@@ -8,7 +8,8 @@
88 "start": "deno serve -A --host 127.0.0.1 --port 8000 _fresh/server.js",99 "engine": "cd ../engine && go build -o bin/mosaic-engine .",1010 "solve": "deno run -A scripts/solve.ts",11- "push-day": "deno run -A scripts/push-day.ts"11+ "push-day": "deno run -A scripts/push-day.ts",12+ "reset-day": "deno run -A scripts/reset-day.ts"1213 },1314 "lint": {1415 "rules": {@@ -24,7 +24,7 @@ import {
2424 unwrapField,2525 } from "./engine.ts";2626 import type { Judge } from "./referee.ts";27-import { dataDir, readLog } from "./log.ts";27+import { dataDir, gamesDir, logPath, readLog } from "./log.ts";2828 import { fold, type Honour, type ManifestEvent, parseLog } from "./fold.ts";2929 import {3030 type Boards,@@ -222,6 +222,9 @@ export class DaysIndex {
222222 row.winner_model,223223 );224224 }225+ delete(date: string): void {226+ this.db.prepare(`DELETE FROM days WHERE date = ?`).run(date);227+ }225228 get(date: string): DayRow | null {226229 const r = this.db.prepare(`SELECT * FROM days WHERE date = ?`).get(date) as227230 | Record<string, string>@@ -265,6 +268,8 @@ export type DailyStatus = {
265268 latest_archived: { date: string; game_id: string } | null;266269 /** tomorrow's field, pushed to this box by the nightly */267270 tomorrow: { date: string; pushed: boolean };271+ /** times today was reset (play wiped, same field restarted) */272+ reset: number;268273 };269274 270275 export type DailyState = {@@ -273,6 +278,8 @@ export type DailyState = {
273278 seed: string;274279 /** where the day's field came from; resume reads the same source */275280 source: "pushed" | "emergency";281+ /** times this day was reset (its earlier logs are kept as .reset-<n>) */282+ reset?: number;276283 };277284 278285 type Opts = {@@ -615,6 +622,52 @@ export class Daily {
615622 return row;616623 }617624 625+ /**626+ * Wipe a day's play and start it again from the same field: the627+ * running game ends without being archived (its log moves to628+ * `<game>.reset-<n>.jsonl`, kept but outside the calendar and the629+ * boards), the day's index row and archive go, and the day restarts at630+ * revision 0 from the same pushed field, seed, rings and honours, so631+ * agents can join again with clean health.632+ */633+ async reset(date: string): Promise<{ game_id: string; reset: number }> {634+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error("bad date");635+ const gameId = gameIdFor(date, this.cfg);636+ const cur = this.current?.date === date ? this.current : null;637+ const n = (cur?.reset ?? 0) + 1;638+ if (getGame(gameId)) {639+ // end it under the write lock (any growth in flight lands first),640+ // then forget it; the log is set aside as it stands, replayable641+ await endGame(gameId);642+ dropGame(gameId);643+ }644+ // set the log aside (nothing is lost, nothing is archived)645+ try {646+ await Deno.rename(647+ logPath(gameId),648+ `${gamesDir()}${gameId}.reset-${n}.jsonl`,649+ );650+ } catch (e) {651+ this.log(`reset ${date}: no log to set aside (${e})`);652+ }653+ this.index.delete(date);654+ try {655+ await Deno.remove(`${this.dir}days/${date}/`, { recursive: true });656+ } catch {657+ // not archived658+ }659+ this.current = null;660+ if (date === this.today()) {661+ await this.startDay(date);662+ if (this.current) {663+ (this.current as DailyState).reset = n;664+ await this.saveState();665+ }666+ }667+ this.log(`reset ${date} (${n}): ${gameId} restarted from its field`);668+ return { game_id: gameId, reset: n };669+ }670+618671 /** What `/` should show right now. */619672 status(): DailyStatus {620673 const cur = this.current;@@ -643,6 +696,7 @@ export class Daily {
643696 ? { date: latest.date, game_id: latest.game_id }644697 : null,645698 tomorrow: { date: tomorrow, pushed },699+ reset: cur?.reset ?? 0,646700 };647701 }648702 @@ -0,0 +1,32 @@
1+// Wipe a day's play and restart it from the same field (the push token2+// is the credential, as for /push). The old log is kept aside as3+// `<game>.reset-<n>.jsonl`; the day leaves the calendar and the boards4+// until it ends again. `deno task reset-day <date>` calls this.5+import { define } from "../../../utils.ts";6+import { daily } from "../../../lib/daily.ts";7+8+export const handler = define.handlers({9+ async POST(ctx) {10+ const token = Deno.env.get("MOSAIC_PUSH_TOKEN");11+ const auth = ctx.req.headers.get("Authorization") ?? "";12+ if (!token || auth !== `Bearer ${token}`) {13+ return Response.json({ error: "unauthorised" }, { status: 401 });14+ }15+ const date = ctx.params.date;16+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {17+ return Response.json({ error: "bad date" }, { status: 400 });18+ }19+ const d = daily();20+ if (!d) {21+ return Response.json({ error: "no daily on this server" }, {22+ status: 404,23+ });24+ }25+ try {26+ const r = await d.reset(date);27+ return Response.json({ ok: true, date, ...r });28+ } catch (e) {29+ return Response.json({ error: String(e) }, { status: 500 });30+ }31+ },32+});@@ -16,6 +16,7 @@ export const handler = define.handlers({
1616 emergency: false,1717 latest_archived: null,1818 tomorrow: { date: "", pushed: false },19+ reset: 0,1920 }, { headers: { "Cache-Control": "no-store" } });2021 }2122 return Response.json(d.status(), {@@ -0,0 +1,31 @@
1+// Wipe a day's play on a running server and restart it from the same2+// field: POST /admin/reset/<date> with the push token.3+//4+// MOSAIC_PUSH_TOKEN=... deno task reset-day 2026-08-27 [--host https://mosaic-daily.fly.dev]5+//6+// The default host is the local dev server. The old log is kept as7+// data/games/<game>.reset-<n>.jsonl; the day restarts at revision 0 with8+// clean health for everyone.9+const args = Object.fromEntries(10+ Deno.args.map((a, i, all) =>11+ a.startsWith("--") ? [a.slice(2), all[i + 1] ?? ""] : []12+ ).filter((p) => p.length),13+);14+const date = Deno.args.find((a) => /^\d{4}-\d{2}-\d{2}$/.test(a));15+if (!date) {16+ console.error("usage: deno task reset-day <YYYY-MM-DD> [--host <url>]");17+ Deno.exit(2);18+}19+const host = (args.host ?? "http://127.0.0.1:8000").replace(/\/$/, "");20+const token = Deno.env.get("MOSAIC_PUSH_TOKEN");21+if (!token) {22+ console.error("MOSAIC_PUSH_TOKEN is not set");23+ Deno.exit(2);24+}25+const res = await fetch(`${host}/admin/reset/${date}`, {26+ method: "POST",27+ headers: { Authorization: `Bearer ${token}` },28+});29+const body = await res.text();30+console.log(`${res.status} ${body}`);31+Deno.exit(res.ok ? 0 : 1);@@ -260,3 +260,56 @@ Deno.test("rollover with nothing pushed makes an emergency field and says so", a
260260 d.stop();261261 resetGames();262262 });263+264+Deno.test("reset wipes a day's play and restarts it from the same field", async () => {265+ resetGames();266+ let now = Date.parse("2026-10-01T10:00:00Z");267+ const clock = () => now;268+ const resetLogs: string[] = [];269+ const d = new Daily({270+ cfg,271+ dir: tmp + "/reset/",272+ now: clock,273+ generate,274+ judge: stubJudge,275+ log: (m) => resetLogs.push(m),276+ });277+ await d.boot();278+ const day = dateFor(now, cfg);279+ const g = getGame(d.current!.game_id)!;280+ const id = d.current!.game_id;281+ const manifestId = g.state.manifest!.id;282+ const a = await g.join({ name: "alice", model: "model-a" });283+ await play(g, a.agent, d.current!.seed, 2);284+ const rev = g.revision;285+ assert(rev > 3, `played to ${rev}`);286+ assertEquals(d.status().reset, 0);287+288+ const r = await d.reset(day);289+ assertEquals(r, { game_id: id, reset: 1 });290+ const g2 = getGame(id)!;291+ assert(g2 !== g, "a fresh referee");292+ assert(g2, "the day restarted");293+ assertEquals(g2.revision, 0);294+ assertEquals(g2.state.agents.length, 0);295+ assertEquals(g2.state.manifest!.id, manifestId);296+ assertEquals(d.status().reset, 1);297+ assertEquals(d.index.get(day), null);298+ // the old log is kept aside, the live one starts over299+ const moved = await Deno.readTextFile(300+ `${Deno.env.get("MOSAIC_GAMES")}${id}.reset-1.jsonl`,301+ ).catch(() => null);302+ assert(moved, `the old log was set aside: ${resetLogs.join("; ")}`);303+ const movedEvents = moved.split("\n").filter(Boolean);304+ assert(movedEvents.length >= rev + 1, "every event kept");305+ assertEquals(JSON.parse(movedEvents.at(-1)!).kind, "end");306+ const fresh = await readLog(id);307+ assert(fresh, "a fresh log");308+ assertEquals(fresh.split("\n").filter(Boolean).length, 1);309+ // play again from scratch with clean health310+ const b = await g2.join({ name: "alice", model: "model-a" });311+ assertEquals(g2.state.agents[0].health.state, "healthy");312+ await play(g2, b.agent, d.current!.seed, 1);313+ assert(g2.revision > 1);314+ now += 1;315+});