Skip to content

Commit 2f2512c

Browse files
raphaeltmclaude
andcommitted
feat(self-updating-mastra): history offers per-commit undo and reset-to-state
The console's single "Revert" button did `git revert <sha>` (unpick one commit's diff) while its dialog said "restoring the previous state", which reads as reset-to-state — clicking Revert on an old commit left newer changes in place, and "reverting back to" a wanted commit removed it instead. Hit live on the beta stack 2026-07-18. Every history entry now offers both mental models, with copy that says exactly what will happen: - "Undo change" (existing /revert): unpick just this commit; everything after it stays. Now offered on any todo-app-scoped commit — including admin undo/reset commits, so an undo can itself be undone — instead of only agent-run commits. - "Reset to here" (new /restore): append a commit returning todo-app/ to exactly its state at that point (git rm + checkout <sha> -- todo-app, so files added later are removed too). Any older entry is a valid target, including baseline and publish markers; no-op when already in that state; on failure the live tree is put back to HEAD. Both endpoints now also refuse while a publish is in flight (mutating the tree mid-packaging would corrupt the upload), and share one typecheck report helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent bc32459 commit 2f2512c

2 files changed

Lines changed: 120 additions & 26 deletions

File tree

samples/self-updating-mastra/agent/src/admin.ts

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { adminTokenConfigured, getAdminIdentity } from "./auth.js";
77
import { pool } from "./db.js";
88
import { getDeployment, listDeployments } from "./deployments.js";
99
import { executeRun, getActiveRun } from "./execute.js";
10-
import { headSha, history, isRevertable, REPO_DIR, revertCommit } from "./git.js";
10+
import { headSha, history, isRevertable, REPO_DIR, restoreToCommit, revertCommit } from "./git.js";
1111
import { getModel } from "./model.js";
1212
import {
1313
cancelPublish,
@@ -272,22 +272,41 @@ const CONSOLE_SCRIPT = `
272272
if (e.runId) subject.addEventListener("click", () => viewRun(e.runId));
273273
row.append(badge, sha, subject);
274274
if (e.revertable) {
275-
const btn = document.createElement("button"); btn.className = "mini"; btn.type = "button"; btn.textContent = "Revert";
276-
btn.addEventListener("click", () => revert(e.sha, btn));
275+
const btn = document.createElement("button"); btn.className = "mini"; btn.type = "button"; btn.textContent = "Undo change";
276+
btn.title = "Create a new commit that unpicks just this change. Everything after it stays.";
277+
btn.addEventListener("click", () => undoCommit(e.sha, btn));
278+
row.append(btn);
279+
}
280+
if (e.restorable) {
281+
const btn = document.createElement("button"); btn.className = "mini"; btn.type = "button"; btn.textContent = "Reset to here";
282+
btn.title = "Create a new commit that takes the app back to exactly how it was at this point. Changes made after it are removed (history is kept).";
283+
btn.addEventListener("click", () => resetToCommit(e.sha, btn));
277284
row.append(btn);
278285
}
279286
box.append(row);
280287
}
281288
}
282289
283-
async function revert(sha, btn) {
284-
if (!confirm("Revert commit " + sha.slice(0, 8) + "? This creates a new commit restoring the previous state, and the live dev app updates immediately.")) return;
290+
async function undoCommit(sha, btn) {
291+
if (!confirm("Undo the changes from commit " + sha.slice(0, 8) + "? Only this one change is unpicked — everything made after it stays. This adds a new commit, and the live dev app updates immediately.")) return;
285292
btn.disabled = true;
286293
try {
287294
const res = await fetch("/admin/history/" + encodeURIComponent(sha) + "/revert", { method: "POST" });
288295
const data = await res.json().catch(() => null);
289-
if (!res.ok || !data || !data.revertSha) { alert((data && data.error) || "Revert failed."); return; }
290-
if (!data.typecheckOk) alert("Reverted, but the app no longer typechecks — later changes may depend on this commit. Consider reverting the revert, or dispatch a repair run.\\n\\n" + (data.typecheckOutput || ""));
296+
if (!res.ok || !data || !data.revertSha) { alert((data && data.error) || "Undo failed."); return; }
297+
if (!data.typecheckOk) alert("Undone, but the app no longer typechecks — later changes may depend on this commit. Consider undoing the undo, or dispatch a repair run.\\n\\n" + (data.typecheckOutput || ""));
298+
loadData();
299+
} finally { btn.disabled = false; }
300+
}
301+
302+
async function resetToCommit(sha, btn) {
303+
if (!confirm("Reset the app back to how it was at commit " + sha.slice(0, 8) + "? Every change made after this point is removed from the app. History is kept, so the reset itself can be undone. This adds a new commit, and the live dev app updates immediately.")) return;
304+
btn.disabled = true;
305+
try {
306+
const res = await fetch("/admin/history/" + encodeURIComponent(sha) + "/restore", { method: "POST" });
307+
const data = await res.json().catch(() => null);
308+
if (!res.ok || !data || !data.restoreSha) { alert((data && data.error) || "Reset failed."); return; }
309+
if (!data.typecheckOk) alert("Reset done, but the app no longer typechecks.\\n\\n" + (data.typecheckOutput || ""));
291310
loadData();
292311
} finally { btn.disabled = false; }
293312
}
@@ -649,18 +668,45 @@ export function registerAdminRoutes(app: Hono): void {
649668
app.get("/admin/history", async (c) => {
650669
if (!(await getAdminIdentity(c))) return c.json({ error: "Not found." }, 404);
651670
const entries = await history(50);
652-
for (const entry of entries) {
653-
if (entry.runId) entry.revertable = await isRevertable(entry.sha);
654-
}
671+
await Promise.all(
672+
entries.map(async (entry, i) => {
673+
// Undoable: any commit whose diff stays inside todo-app/ — agent runs,
674+
// but also admin undos and resets, so an undo can itself be undone.
675+
entry.revertable = await isRevertable(entry.sha);
676+
// Resettable: anywhere back in time; HEAD would be a no-op.
677+
entry.restorable = i > 0;
678+
}),
679+
);
655680
return c.json({ history: entries });
656681
});
657682

658-
// Admin-only revert of an agent commit (as a new commit, authored by the
659-
// admin). The dev server hot-reloads the restored files immediately.
683+
// Report whether the app still compiles after a history operation; an undo
684+
// can break the build when later commits depend on the undone one.
685+
async function typecheckTodoApp(): Promise<{ typecheckOk: boolean; typecheckOutput: string }> {
686+
try {
687+
await exec("npx", ["tsc", "--noEmit"], {
688+
cwd: `${REPO_DIR}/todo-app`,
689+
timeout: 180_000,
690+
maxBuffer: 10 * 1024 * 1024,
691+
});
692+
return { typecheckOk: true, typecheckOutput: "" };
693+
} catch (err) {
694+
const e = err as { stdout?: string; stderr?: string; message?: string };
695+
return {
696+
typecheckOk: false,
697+
typecheckOutput: (e.stdout || e.stderr || e.message || "unknown error").slice(0, 4000),
698+
};
699+
}
700+
}
701+
702+
// Admin-only undo of a single commit's changes (git revert, as a new commit
703+
// authored by the admin). Everything after the undone commit stays. The dev
704+
// server hot-reloads the restored files immediately.
660705
app.post("/admin/history/:sha/revert", async (c) => {
661706
const identity = await getAdminIdentity(c);
662707
if (!identity) return c.json({ error: "Not found." }, 404);
663708
if (getActiveRun()) return c.json({ error: "A run is in progress; wait for it to finish." }, 409);
709+
if (isPublishActive()) return c.json({ error: "A publish is in progress; wait for it to finish." }, 409);
664710

665711
const sha = c.req.param("sha");
666712
if (!/^[0-9a-f]{7,40}$/i.test(sha)) return c.json({ error: "Invalid commit." }, 400);
@@ -672,22 +718,30 @@ export function registerAdminRoutes(app: Hono): void {
672718
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
673719
}
674720

675-
// Report whether the app still compiles after the revert; a revert can
676-
// break the build when later commits depend on the reverted one.
677-
let typecheckOk = true;
678-
let typecheckOutput = "";
721+
return c.json({ revertSha, ...(await typecheckTodoApp()) });
722+
});
723+
724+
// Admin-only reset of the app back to its state at a commit (as a new commit,
725+
// authored by the admin — history stays append-only). The "scroll back in
726+
// time" companion to the per-commit undo above.
727+
app.post("/admin/history/:sha/restore", async (c) => {
728+
const identity = await getAdminIdentity(c);
729+
if (!identity) return c.json({ error: "Not found." }, 404);
730+
if (getActiveRun()) return c.json({ error: "A run is in progress; wait for it to finish." }, 409);
731+
if (isPublishActive()) return c.json({ error: "A publish is in progress; wait for it to finish." }, 409);
732+
733+
const sha = c.req.param("sha");
734+
if (!/^[0-9a-f]{7,40}$/i.test(sha)) return c.json({ error: "Invalid commit." }, 400);
735+
736+
let restoreSha: string | null;
679737
try {
680-
await exec("npx", ["tsc", "--noEmit"], {
681-
cwd: `${REPO_DIR}/todo-app`,
682-
timeout: 180_000,
683-
maxBuffer: 10 * 1024 * 1024,
684-
});
738+
restoreSha = await restoreToCommit(sha, identity.email);
685739
} catch (err) {
686-
const e = err as { stdout?: string; stderr?: string; message?: string };
687-
typecheckOk = false;
688-
typecheckOutput = (e.stdout || e.stderr || e.message || "unknown error").slice(0, 4000);
740+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
689741
}
690-
return c.json({ revertSha, typecheckOk, typecheckOutput });
742+
if (!restoreSha) return c.json({ error: "The app is already in this state." }, 400);
743+
744+
return c.json({ restoreSha, ...(await typecheckTodoApp()) });
691745
});
692746

693747
// ---- Publish (self-redeploy) -------------------------------------------

samples/self-updating-mastra/agent/src/git.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export interface HistoryEntry {
135135
feedbackIds: string[];
136136
deploymentId: string | null;
137137
revertable: boolean;
138+
restorable: boolean;
138139
}
139140

140141
const FIELD_SEP = "\x1f";
@@ -171,6 +172,7 @@ export async function history(limit = 50): Promise<HistoryEntry[]> {
171172
feedbackIds: feedbackIds?.trim() ? feedbackIds.split(",").map((s) => s.trim()) : [],
172173
deploymentId: deploymentId?.trim() ? deploymentId.trim() : null,
173174
revertable: false, // filled in by the caller for the entries it exposes
175+
restorable: false, // likewise
174176
});
175177
}
176178
return entries;
@@ -201,7 +203,7 @@ export async function isRevertable(sha: string): Promise<boolean> {
201203
*/
202204
export async function revertCommit(sha: string, adminEmail: string): Promise<string> {
203205
if (!(await isRevertable(sha))) {
204-
throw new Error("only agent commits scoped to todo-app can be reverted");
206+
throw new Error("only commits scoped to todo-app can be undone");
205207
}
206208
const identity = ["-c", `user.name=${adminEmail}`, "-c", `user.email=${adminEmail}`];
207209
try {
@@ -213,3 +215,41 @@ export async function revertCommit(sha: string, adminEmail: string): Promise<str
213215
}
214216
return (await git(["rev-parse", "HEAD"])).trim();
215217
}
218+
219+
/**
220+
* Restore todo-app/ to its exact state at `sha`, as a new commit authored by
221+
* the admin — "reset back to this point in time" without rewriting history.
222+
* Unlike revertCommit (which unpicks one commit's diff and keeps everything
223+
* after it), this removes the effect of every commit after `sha`. Scoped to
224+
* todo-app/ (see the safety rule above), which is what makes any history entry
225+
* a valid target, including baseline and publish markers. Returns the new
226+
* commit sha, or null when the live tree already matches that state.
227+
*/
228+
export async function restoreToCommit(sha: string, adminEmail: string): Promise<string | null> {
229+
const target = (await git(["rev-parse", "--verify", `${sha}^{commit}`])).trim();
230+
const appTree = (await git(["ls-tree", "-d", target, "--", "todo-app"])).trim();
231+
if (!appTree) throw new Error("that commit has no todo-app tree to restore");
232+
const identity = ["-c", `user.name=${adminEmail}`, "-c", `user.email=${adminEmail}`];
233+
try {
234+
// rm + checkout (rather than checkout alone) so files added after `target`
235+
// are deleted too; the checkout rematerializes index and worktree at it.
236+
await git(["rm", "-rq", "--ignore-unmatch", "--", "todo-app"], { identity });
237+
await git(["checkout", target, "--", "todo-app"], { identity });
238+
} catch (err) {
239+
// Put the live tree back to HEAD before surfacing the error.
240+
await git(["reset", "-q", "HEAD", "--", "todo-app"]).catch(() => {});
241+
await git(["checkout", "-q", "HEAD", "--", "todo-app"]).catch(() => {});
242+
await git(["clean", "-qfd", "--", "todo-app"]).catch(() => {});
243+
const e = err as { stderr?: string; message?: string };
244+
throw new Error(`restore failed: ${(e.stderr || e.message || "unknown error").slice(0, 500)}`);
245+
}
246+
try {
247+
await git(["diff", "--cached", "--quiet"]);
248+
return null; // already in this state
249+
} catch {
250+
// non-zero exit: there are staged changes to commit
251+
}
252+
const message = `restore: todo-app back to ${target.slice(0, 8)}\n\nRestore-To: ${target}`;
253+
await git(["commit", "-q", "-m", message], { identity });
254+
return (await git(["rev-parse", "HEAD"])).trim();
255+
}

0 commit comments

Comments
 (0)