backstep 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ // Supabase: its MCP server (and the claude.ai connector) runs SQL through
2
+ // Supabase's API, which gives Backstep nothing to snapshot with. So the person
3
+ // connects the project's database once (`backstep connect supabase`), and from
4
+ // then on Backstep snapshots tables over that connection before each
5
+ // execute_sql or apply_migration, the same way it does for any Postgres.
6
+ import { spawnSync } from "node:child_process";
7
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import pg from "pg";
11
+ import { homeDir } from "./store.js";
12
+ // The key for a database with no project ref in its URL, like `supabase start`'s local one.
13
+ export const LOCAL = "local";
14
+ const SQL_TOOLS = new Set(["execute_sql", "apply_migration"]);
15
+ function file() {
16
+ return join(homeDir(), "connections.json");
17
+ }
18
+ function load() {
19
+ if (!existsSync(file()))
20
+ return { supabase: {} };
21
+ const c = JSON.parse(readFileSync(file(), "utf8"));
22
+ return { ...c, supabase: c.supabase ?? {} };
23
+ }
24
+ function save(c) {
25
+ mkdirSync(homeDir(), { recursive: true, mode: 0o700 });
26
+ // Connection strings carry the database password.
27
+ writeFileSync(file(), JSON.stringify(c, null, 2) + "\n", { mode: 0o600 });
28
+ chmodSync(file(), 0o600);
29
+ }
30
+ // The project ref is in the host of a direct connection (db.<ref>.supabase.co)
31
+ // and in the user name of a pooler one (postgres.<ref>@…pooler.supabase.com).
32
+ export function projectRef(url) {
33
+ let u;
34
+ try {
35
+ u = new URL(url);
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ const host = u.hostname.match(/^db\.([a-z0-9]{20})\.supabase\.co$/);
41
+ if (host)
42
+ return host[1];
43
+ const user = decodeURIComponent(u.username).match(/^[^.]+\.([a-z0-9]{20})$/);
44
+ if (user && u.hostname.endsWith(".supabase.com"))
45
+ return user[1];
46
+ return null;
47
+ }
48
+ // Checks the connection works before keeping it. Returns the ref it's saved under.
49
+ export async function connectSupabase(url, ref) {
50
+ if (!/^postgres(ql)?:\/\//.test(url)) {
51
+ throw new Error("that isn't a Postgres connection string. In Supabase: Connect (top of the project page) → Direct, or Session pooler → copy the URI");
52
+ }
53
+ if (url.includes("[YOUR-PASSWORD]"))
54
+ throw new Error("put your database password in place of [YOUR-PASSWORD] first");
55
+ const key = ref ?? projectRef(url) ?? LOCAL;
56
+ const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 10_000 });
57
+ try {
58
+ await client.connect();
59
+ await client.query("SELECT 1");
60
+ }
61
+ catch (err) {
62
+ throw new Error(`couldn't connect to that database: ${err instanceof Error ? err.message : String(err)}`);
63
+ }
64
+ finally {
65
+ await client.end().catch(() => { });
66
+ }
67
+ const c = load();
68
+ c.supabase[key] = url;
69
+ if (c.expires)
70
+ delete c.expires[key];
71
+ save(c);
72
+ return key;
73
+ }
74
+ export function disconnectSupabase(ref) {
75
+ const c = load();
76
+ if (!(ref in c.supabase))
77
+ return false;
78
+ delete c.supabase[ref];
79
+ if (c.expires)
80
+ delete c.expires[ref];
81
+ save(c);
82
+ return true;
83
+ }
84
+ export function supabaseProjects() {
85
+ return Object.keys(load().supabase);
86
+ }
87
+ // The database for a call. Hosted calls name their project, which has to match;
88
+ // a call that names none (a local server) uses the only database connected.
89
+ export function supabaseDb(ref) {
90
+ const all = load().supabase;
91
+ if (ref)
92
+ return all[ref] ?? null;
93
+ const urls = Object.values(all);
94
+ return all[LOCAL] ?? (urls.length === 1 ? urls[0] : null);
95
+ }
96
+ export function isSupabase(server, command = "") {
97
+ return /supabase/i.test(server) || /supabase/i.test(command);
98
+ }
99
+ export function isSupabaseSql(server, tool, command = "") {
100
+ return isSupabase(server, command) && SQL_TOOLS.has(tool);
101
+ }
102
+ // A local server started with --project-ref doesn't take project_id on each call.
103
+ export function refFromArgs(callArgs, serverArgs = []) {
104
+ if (typeof callArgs.project_id === "string")
105
+ return callArgs.project_id;
106
+ for (let i = 0; i < serverArgs.length; i++) {
107
+ const m = serverArgs[i].match(/^--project-ref(?:=(.+))?$/);
108
+ if (m)
109
+ return m[1] ?? serverArgs[i + 1];
110
+ }
111
+ return undefined;
112
+ }
113
+ // Supabase runs the SQL itself, so a DROP is rewritten rather than run here.
114
+ // apply_migration also records the migration, which undo takes back too.
115
+ export function supabasePlanOptions(tool) {
116
+ return { rewrite: true, alsoSnapshot: tool === "apply_migration" ? ["supabase_migrations.schema_migrations"] : [] };
117
+ }
118
+ export const CONNECT_HINT = "connect the database to undo Supabase changes: run `backstep connect supabase`";
119
+ // With an access token, a project connects in one step: Supabase hands out a
120
+ // temporary database login (the one its own CLI uses), and Backstep makes a
121
+ // new one whenever the last runs out. The token comes from `backstep connect
122
+ // supabase --token`, SUPABASE_ACCESS_TOKEN, or `supabase login`.
123
+ const API = process.env.BACKSTEP_SUPABASE_API ?? "https://api.supabase.com";
124
+ // Renewed this long before it runs out, so a snapshot never starts on a login about to expire.
125
+ const MARGIN_MS = 5 * 60 * 1000;
126
+ // The session pooler's port; the API names the transaction pooler's, which can't hold a snapshot's transaction.
127
+ const POOLER_PORT = process.env.BACKSTEP_SUPABASE_POOLER_PORT ?? "5432";
128
+ // `interactive` may read the macOS keychain, where `supabase login` keeps its
129
+ // token, which can ask the person for permission: never as a session starts.
130
+ export function supabaseToken(interactive = false) {
131
+ const saved = load().token ?? process.env.SUPABASE_ACCESS_TOKEN;
132
+ if (saved)
133
+ return saved;
134
+ try {
135
+ const t = readFileSync(join(homedir(), ".supabase", "access-token"), "utf8").trim();
136
+ if (t)
137
+ return t;
138
+ }
139
+ catch {
140
+ // not logged in through the file
141
+ }
142
+ if (!interactive || process.platform !== "darwin")
143
+ return null;
144
+ const r = spawnSync("security", ["find-generic-password", "-s", "Supabase CLI", "-w"], { encoding: "utf8" });
145
+ const v = r.status === 0 ? r.stdout.trim() : "";
146
+ if (!v)
147
+ return null;
148
+ return v.startsWith("go-keyring-base64:") ? Buffer.from(v.slice(18), "base64").toString("utf8") : v;
149
+ }
150
+ async function api(token, method, path, body) {
151
+ const r = await fetch(`${API}${path}`, {
152
+ method,
153
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
154
+ body: body === undefined ? undefined : JSON.stringify(body),
155
+ signal: AbortSignal.timeout(20_000),
156
+ });
157
+ if (!r.ok)
158
+ throw new Error(`Supabase said ${r.status}${r.status === 401 ? ": the access token isn't valid" : ""}`);
159
+ return (await r.json());
160
+ }
161
+ export const hasSavedToken = () => Boolean(load().token);
162
+ export function dismissSupabase(dir) {
163
+ const c = load();
164
+ c.dismissed = [...new Set([...(c.dismissed ?? []), dir])];
165
+ save(c);
166
+ }
167
+ export const supabaseDismissed = (dir) => (load().dismissed ?? []).includes(dir);
168
+ // Checks the token works before keeping it.
169
+ export async function saveSupabaseToken(token) {
170
+ const projects = await api(token, "GET", "/v1/projects");
171
+ const c = load();
172
+ c.token = token;
173
+ save(c);
174
+ return projects.map((p) => p.ref ?? p.id ?? "").filter(Boolean);
175
+ }
176
+ async function tryUrl(url) {
177
+ const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 10_000 });
178
+ try {
179
+ await client.connect();
180
+ await client.query("SELECT 1");
181
+ return true;
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ finally {
187
+ await client.end().catch(() => { });
188
+ }
189
+ }
190
+ // A new temporary login for the project's database: direct where the network
191
+ // reaches it, else through Supabase's session pooler.
192
+ export async function mintSupabase(ref, token) {
193
+ const login = await api(token, "POST", `/v1/projects/${ref}/cli/login-role`, { read_only: false });
194
+ const pw = encodeURIComponent(login.password);
195
+ let url = `postgresql://${encodeURIComponent(login.role)}:${pw}@db.${ref}.supabase.co:5432/postgres`;
196
+ if (!(await tryUrl(url))) {
197
+ const poolers = await api(token, "GET", `/v1/projects/${ref}/config/database/pooler`);
198
+ const p = poolers.find((x) => x.database_type !== "READ_REPLICA" && x.db_host);
199
+ if (!p)
200
+ throw new Error(`couldn't reach project ${ref}'s database`);
201
+ url = `postgresql://${encodeURIComponent(`${login.role}.${ref}`)}:${pw}@${p.db_host}:${POOLER_PORT}/${p.db_name ?? "postgres"}`;
202
+ if (!(await tryUrl(url)))
203
+ throw new Error(`couldn't reach project ${ref}'s database`);
204
+ }
205
+ const c = load();
206
+ c.supabase[ref] = url;
207
+ c.expires = { ...c.expires, [ref]: Date.now() + login.ttl_seconds * 1000 };
208
+ save(c);
209
+ return url;
210
+ }
211
+ // The database for a call, renewed with the access token when Backstep made
212
+ // it and it has run out. A project is only ever connected when the person says so.
213
+ export async function supabaseUrl(ref) {
214
+ const saved = supabaseDb(ref);
215
+ const key = ref ?? (saved ? Object.entries(load().supabase).find(([, u]) => u === saved)?.[0] : undefined);
216
+ if (!key || key === LOCAL)
217
+ return saved;
218
+ const expires = load().expires?.[key];
219
+ if (saved === null || expires === undefined || expires - MARGIN_MS >= Date.now())
220
+ return saved;
221
+ const token = supabaseToken();
222
+ if (!token)
223
+ return saved;
224
+ try {
225
+ return await mintSupabase(key, token);
226
+ }
227
+ catch {
228
+ return saved;
229
+ }
230
+ }
231
+ // A saved database URL, renewed if Backstep made it and it has run out.
232
+ export async function freshUrl(url) {
233
+ const ref = projectRef(url);
234
+ return ref && load().expires?.[ref] !== undefined ? ((await supabaseUrl(ref)) ?? url) : url;
235
+ }
236
+ // The same database whatever login reaches it: for telling two changes to it apart.
237
+ export function dbKey(url) {
238
+ try {
239
+ const u = new URL(url);
240
+ return `${projectRef(url) ?? u.host}${u.pathname}`;
241
+ }
242
+ catch {
243
+ return url;
244
+ }
245
+ }
@@ -0,0 +1,77 @@
1
+ // An anonymous count of installs and of installs still in use, so we know how
2
+ // many people use Backstep. Each event carries a random id made on this
3
+ // machine, the Backstep version, the OS and the Node version. Never paths,
4
+ // commands, server names or anything from the journal.
5
+ //
6
+ // Off with BACKSTEP_TELEMETRY=0 or DO_NOT_TRACK=1.
7
+ import { randomUUID } from "node:crypto";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { homeDir } from "./store.js";
12
+ // PostHog's project key only lets events be sent, never read, so it's safe to ship.
13
+ const POSTHOG_KEY = "phc_kkZCQxYJhCSZwqNiYYKW8Yz4x3MwpKCiDX7SBQ29S7w7";
14
+ const POSTHOG_URL = "https://us.i.posthog.com/i/v0/e/";
15
+ const DAY_MS = 24 * 60 * 60 * 1000;
16
+ export function telemetryOn() {
17
+ const off = (v) => v !== undefined && /^(0|false|off|no)$/i.test(v);
18
+ const on = (v) => v !== undefined && v !== "" && !off(v);
19
+ if (off(process.env.BACKSTEP_TELEMETRY) || on(process.env.DO_NOT_TRACK))
20
+ return false;
21
+ return Boolean(process.env.BACKSTEP_TELEMETRY_URL || POSTHOG_KEY);
22
+ }
23
+ function installId() {
24
+ const file = join(homeDir(), "install-id");
25
+ if (existsSync(file))
26
+ return readFileSync(file, "utf8").trim();
27
+ const id = randomUUID();
28
+ mkdirSync(homeDir(), { recursive: true });
29
+ writeFileSync(file, id + "\n", { mode: 0o600 });
30
+ return id;
31
+ }
32
+ function version() {
33
+ const pkg = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
34
+ return JSON.parse(readFileSync(pkg, "utf8")).version;
35
+ }
36
+ // Never throws and never waits long: a count is not worth slowing anyone down.
37
+ async function send(event) {
38
+ if (!telemetryOn())
39
+ return;
40
+ try {
41
+ await fetch(process.env.BACKSTEP_TELEMETRY_URL ?? POSTHOG_URL, {
42
+ method: "POST",
43
+ headers: { "content-type": "application/json" },
44
+ body: JSON.stringify({
45
+ api_key: POSTHOG_KEY,
46
+ event,
47
+ distinct_id: installId(),
48
+ properties: {
49
+ version: version(),
50
+ os: process.platform,
51
+ arch: process.arch,
52
+ node: process.versions.node.split(".")[0],
53
+ // No profile and no location: just the count.
54
+ $process_person_profile: false,
55
+ $geoip_disable: true,
56
+ },
57
+ }),
58
+ signal: AbortSignal.timeout(3000),
59
+ });
60
+ }
61
+ catch {
62
+ // Offline, blocked, or slow: skip it.
63
+ }
64
+ }
65
+ export const sendInstall = () => send("install");
66
+ // At most once a day, as Claude Code starts with Backstep's mod.
67
+ export async function sendActive() {
68
+ if (!telemetryOn())
69
+ return;
70
+ const stamp = join(homeDir(), "last-active-ping");
71
+ const last = existsSync(stamp) ? Number(readFileSync(stamp, "utf8")) : 0;
72
+ if (Date.now() - last < DAY_MS)
73
+ return;
74
+ mkdirSync(homeDir(), { recursive: true });
75
+ writeFileSync(stamp, String(Date.now()));
76
+ await send("active");
77
+ }
@@ -0,0 +1,246 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Backstep</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f6f4ef;
10
+ --panel: #fffdf8;
11
+ --ink: #1d1b16;
12
+ --muted: #6f6a5f;
13
+ --line: #e4dfd3;
14
+ --soft: #efebe1;
15
+ --undo: #1f6f4a;
16
+ --undo-bg: #e3f1e8;
17
+ --hold: #a35a00;
18
+ --hold-bg: #fbeedb;
19
+ --bad: #a3302a;
20
+ --bad-bg: #f8e3e1;
21
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
22
+ --sans: ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
23
+ }
24
+ @media (prefers-color-scheme: dark) {
25
+ :root {
26
+ --bg: #15140f;
27
+ --panel: #1d1c16;
28
+ --ink: #ece7da;
29
+ --muted: #9a9384;
30
+ --line: #2f2d24;
31
+ --soft: #26241d;
32
+ --undo: #6fcf9a;
33
+ --undo-bg: #1b2e23;
34
+ --hold: #f0a845;
35
+ --hold-bg: #33260f;
36
+ --bad: #f08a80;
37
+ --bad-bg: #3a1c19;
38
+ }
39
+ }
40
+ * { box-sizing: border-box; }
41
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.45 var(--sans); }
42
+ header {
43
+ display: flex; align-items: baseline; gap: 14px; padding: 18px 28px;
44
+ border-bottom: 1px solid var(--line);
45
+ }
46
+ header h1 { margin: 0; font-size: 18px; letter-spacing: -0.01em; }
47
+ header h1 span { color: var(--undo); }
48
+ header p { margin: 0; color: var(--muted); }
49
+ header label { margin-left: auto; color: var(--muted); display: flex; gap: 6px; align-items: center; cursor: pointer; }
50
+ main { display: grid; grid-template-columns: 280px 1fr; min-height: calc(100vh - 60px); }
51
+ aside { border-right: 1px solid var(--line); padding: 18px 14px; }
52
+ aside h2, section h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 0 10px 10px; font-weight: 600; }
53
+ .run {
54
+ display: block; width: 100%; text-align: left; border: 0; background: none; color: inherit;
55
+ padding: 9px 10px; border-radius: 8px; cursor: pointer; font: inherit;
56
+ }
57
+ .run:hover { background: var(--soft); }
58
+ .run.sel { background: var(--panel); box-shadow: inset 0 0 0 1px var(--line); }
59
+ .run b { display: block; font-weight: 600; }
60
+ .run small { color: var(--muted); }
61
+ .live { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--undo); margin-right: 6px; vertical-align: 1px; }
62
+ section { padding: 22px 28px 60px; max-width: 980px; }
63
+ .bar { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
64
+ .bar h3 { margin: 0; font-size: 20px; letter-spacing: -0.01em; }
65
+ .bar .cmd { font: 12px var(--mono); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
66
+ .bar .grow { flex: 1; min-width: 0; }
67
+ button.act {
68
+ font: 600 13px var(--sans); border-radius: 7px; padding: 6px 12px; cursor: pointer;
69
+ border: 1px solid var(--line); background: var(--panel); color: var(--ink); white-space: nowrap;
70
+ }
71
+ button.act:hover { border-color: var(--muted); }
72
+ button.act.undo { color: var(--undo); }
73
+ button.act.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
74
+ button.act:disabled { opacity: .45; cursor: default; }
75
+ ol { list-style: none; margin: 0; padding: 0; border-left: 2px solid var(--line); margin-left: 7px; }
76
+ li.a { position: relative; padding: 10px 0 10px 22px; }
77
+ li.a::before {
78
+ content: ""; position: absolute; left: -7px; top: 16px; width: 12px; height: 12px; border-radius: 50%;
79
+ background: var(--panel); border: 2px solid var(--line);
80
+ }
81
+ li.a.reversible::before { border-color: var(--undo); }
82
+ li.a.irreversible::before { border-color: var(--hold); }
83
+ li.a.undone { opacity: .55; }
84
+ li.a.undone .tool { text-decoration: line-through; }
85
+ .row { display: flex; gap: 10px; align-items: center; }
86
+ .tool { font: 600 13px var(--mono); }
87
+ .chip { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 99px; background: var(--soft); color: var(--muted); }
88
+ .chip.reversible { background: var(--undo-bg); color: var(--undo); }
89
+ .chip.irreversible, .chip.held { background: var(--hold-bg); color: var(--hold); }
90
+ .chip.error, .chip.undo_failed, .chip.cancelled { background: var(--bad-bg); color: var(--bad); }
91
+ .time { color: var(--muted); font-size: 12px; margin-left: auto; }
92
+ .args { font: 12px/1.5 var(--mono); color: var(--muted); margin-top: 4px; white-space: pre-wrap; word-break: break-all; max-height: 4.5em; overflow: hidden; }
93
+ .note { font-size: 12px; color: var(--muted); margin-top: 3px; }
94
+ .note.bad { color: var(--bad); }
95
+ .confirm {
96
+ margin-top: 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px;
97
+ }
98
+ .confirm ul { margin: 6px 0 10px; padding-left: 18px; font: 12px var(--mono); }
99
+ .empty { color: var(--muted); padding: 60px 0; text-align: center; }
100
+ .empty code { font: 12px var(--mono); background: var(--soft); padding: 2px 6px; border-radius: 5px; }
101
+ .toast {
102
+ position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: var(--ink); color: var(--bg);
103
+ padding: 10px 16px; border-radius: 9px; font-size: 13px; max-width: 90vw; white-space: pre-wrap;
104
+ }
105
+ @media (max-width: 760px) {
106
+ main { grid-template-columns: 1fr; }
107
+ aside { border-right: 0; border-bottom: 1px solid var(--line); }
108
+ header, section { padding-left: 16px; padding-right: 16px; }
109
+ }
110
+ </style>
111
+ </head>
112
+ <body>
113
+ <header>
114
+ <h1>Back<span>step</span></h1>
115
+ <p>Every action your agents take, and the way back.</p>
116
+ <label><input type="checkbox" id="reads"> Show reads</label>
117
+ </header>
118
+ <main>
119
+ <aside><h2>Runs</h2><div id="runs"></div></aside>
120
+ <section id="main"></section>
121
+ </main>
122
+ <div id="toast" class="toast" hidden></div>
123
+ <script>
124
+ const state = { runs: [], actions: [], sel: null, confirm: null, busy: false };
125
+ const $ = (s) => document.querySelector(s);
126
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
127
+ const ago = (t) => {
128
+ const s = Math.round((Date.now() - t) / 1000);
129
+ if (s < 60) return s + "s ago";
130
+ if (s < 3600) return Math.round(s / 60) + "m ago";
131
+ if (s < 86400) return Math.round(s / 3600) + "h ago";
132
+ return new Date(t).toLocaleDateString();
133
+ };
134
+ try { $("#reads").checked = localStorage.getItem("tb-reads") === "1"; } catch {}
135
+ $("#reads").onchange = (e) => { try { localStorage.setItem("tb-reads", e.target.checked ? "1" : "0"); } catch {} render(); };
136
+
137
+ function toast(msg) {
138
+ const t = $("#toast"); t.textContent = msg; t.hidden = false;
139
+ clearTimeout(toast.t); toast.t = setTimeout(() => (t.hidden = true), 4500);
140
+ }
141
+
142
+ async function post(path) {
143
+ const r = await fetch(path, { method: "POST", headers: { "x-backstep": "1" } });
144
+ const body = await r.json();
145
+ if (!r.ok) throw new Error(body.error || "request failed");
146
+ return body;
147
+ }
148
+
149
+ async function load() {
150
+ try {
151
+ const r = await fetch("/api/feed");
152
+ const data = await r.json();
153
+ state.runs = data.runs; state.actions = data.actions;
154
+ if (!state.sel || !state.runs.some((x) => x.id === state.sel)) state.sel = state.runs[0]?.id ?? null;
155
+ if (!state.busy) render();
156
+ } catch {}
157
+ }
158
+
159
+ const KIND_LABEL = { read: "read", reversible: "undoable", irreversible: "can't undo", write: "no undo" };
160
+ const STATUS_LABEL = { held: "held", cancelled: "cancelled", undone: "undone", undo_failed: "undo failed", error: "error", running: "running", undo_requested: "undoing…", undoing: "undoing…" };
161
+
162
+ function actionHtml(a) {
163
+ const argText = Object.entries(a.args).map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`).join("\n");
164
+ const status = STATUS_LABEL[a.status];
165
+ const confirming = state.confirm && state.confirm.id === a.id;
166
+ return `<li class="a ${a.kind} ${a.status}">
167
+ <div class="row">
168
+ <span class="tool">${esc(a.tool)}</span>
169
+ <span class="chip ${a.kind}">${KIND_LABEL[a.kind]}</span>
170
+ ${status ? `<span class="chip ${a.status}">${status}</span>` : ""}
171
+ <span class="time">${ago(a.created_at)}</span>
172
+ ${a.undoable ? `<button class="act undo" data-undo="${a.id}">Undo</button>` : ""}
173
+ </div>
174
+ ${argText ? `<div class="args">${esc(argText)}</div>` : ""}
175
+ ${a.reason && a.kind !== "read" ? `<div class="note">${esc(a.reason)}</div>` : ""}
176
+ ${a.error ? `<div class="note bad">${esc(a.error)}</div>` : ""}
177
+ ${confirming ? confirmHtml() : ""}
178
+ </li>`;
179
+ }
180
+
181
+ function confirmHtml() {
182
+ const c = state.confirm;
183
+ const extra = c.chain.filter((x) => x.id !== c.id);
184
+ return `<div class="confirm">
185
+ ${extra.length
186
+ ? `Later actions changed the same things, so they'll be undone first:<ul>${extra.map((x) => `<li>${esc(x.tool)}</li>`).join("")}</ul>`
187
+ : `<div style="margin-bottom:10px">Put everything this action touched back the way it was?</div>`}
188
+ <button class="act primary" data-go="${c.id}">Undo ${extra.length ? extra.length + 1 + " actions" : "it"}</button>
189
+ <button class="act" data-nevermind>Keep</button>
190
+ </div>`;
191
+ }
192
+
193
+ function render() {
194
+ $("#runs").innerHTML = state.runs.length
195
+ ? state.runs.map((r) => `<button class="run ${r.id === state.sel ? "sel" : ""}" data-run="${r.id}">
196
+ <b>${r.ended_at ? "" : '<span class="live"></span>'}${esc(r.server)}</b>
197
+ <small>${ago(r.started_at)} · ${r.actions} actions · ${r.undoable} undoable</small>
198
+ </button>`).join("")
199
+ : `<div class="note" style="padding:0 10px">No runs yet.</div>`;
200
+
201
+ const run = state.runs.find((r) => r.id === state.sel);
202
+ const main = $("#main");
203
+ if (!run) {
204
+ main.innerHTML = `<div class="empty">Wrap an MCP server to start recording.<br><br>
205
+ <code>backstep wrap --name files -- npx -y @modelcontextprotocol/server-filesystem .</code></div>`;
206
+ return;
207
+ }
208
+ const all = state.actions.filter((a) => a.run_id === run.id);
209
+ const shown = all.filter((a) => $("#reads").checked || a.kind !== "read");
210
+ main.innerHTML = `
211
+ <div class="bar">
212
+ <div class="grow"><h3>${esc(run.server)}</h3><div class="cmd">${esc(run.command)}</div></div>
213
+ <button class="act undo" data-undorun="${run.id}" ${run.undoable ? "" : "disabled"}>Undo entire run</button>
214
+ </div>
215
+ ${shown.length ? `<ol>${shown.map(actionHtml).join("")}</ol>` : `<div class="empty">Nothing recorded yet${$("#reads").checked ? "" : " (reads hidden)"}.</div>`}`;
216
+ }
217
+
218
+ document.addEventListener("click", async (e) => {
219
+ const el = e.target.closest("button");
220
+ if (!el) return;
221
+ const d = el.dataset;
222
+ try {
223
+ if (d.run) { state.sel = d.run; state.confirm = null; render(); }
224
+ else if (d.undo) {
225
+ const r = await fetch(`/api/actions/${d.undo}/chain`).then((x) => x.json());
226
+ state.confirm = { id: d.undo, chain: r.chain }; state.busy = true; render();
227
+ }
228
+ else if (d.nevermind !== undefined) { state.confirm = null; state.busy = false; render(); }
229
+ else if (d.go) { report(await post(`/api/actions/${d.go}/undo`)); state.confirm = null; state.busy = false; }
230
+ else if (d.undorun) { report(await post(`/api/runs/${d.undorun}/undo`)); }
231
+ } catch (err) { toast(err.message); state.busy = false; }
232
+ load();
233
+ });
234
+
235
+ function report(body) {
236
+ const o = body.outcomes || [];
237
+ if (!o.length) return toast("Nothing to undo.");
238
+ const failed = o.find((x) => !x.ok);
239
+ toast(failed ? `Undo stopped at ${failed.tool}: ${failed.detail}` : `Undid ${o.length} action${o.length > 1 ? "s" : ""}.\n${o.map((x) => x.detail).join("\n")}`);
240
+ }
241
+
242
+ load();
243
+ setInterval(load, 1000);
244
+ </script>
245
+ </body>
246
+ </html>
@@ -0,0 +1,70 @@
1
+ // Local dashboard: a timeline of agent actions with undo.
2
+ import { createServer } from "node:http";
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Store } from "../store.js";
6
+ import { undoAction, undoChain, undoRun } from "../undo.js";
7
+ const page = readFileSync(fileURLToPath(new URL("./index.html", import.meta.url)), "utf8");
8
+ function json(res, status, body) {
9
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
10
+ res.end(JSON.stringify(body));
11
+ }
12
+ // The journal can hold database URLs and every undo is destructive, so only
13
+ // accept same-machine requests: a Host check stops DNS rebinding, and the
14
+ // custom header on writes forces a CORS preflight that we never approve.
15
+ function trusted(req, port) {
16
+ const host = req.headers.host ?? "";
17
+ return host === `127.0.0.1:${port}` || host === `localhost:${port}`;
18
+ }
19
+ function publicAction(a) {
20
+ const { undo, ...rest } = a;
21
+ return { ...rest, args: JSON.parse(a.args), undoable: undo !== null && (a.status === "ok" || a.status === "error") };
22
+ }
23
+ export function startUi(port) {
24
+ const store = new Store();
25
+ const server = createServer(async (req, res) => {
26
+ try {
27
+ if (!trusted(req, port))
28
+ return json(res, 403, { error: "forbidden" });
29
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
30
+ const parts = url.pathname.split("/").filter(Boolean);
31
+ if (req.method === "GET" && url.pathname === "/") {
32
+ res.writeHead(200, {
33
+ "content-type": "text/html; charset=utf-8",
34
+ "content-security-policy": "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'",
35
+ });
36
+ return res.end(page);
37
+ }
38
+ if (req.method === "GET" && url.pathname === "/api/feed") {
39
+ const runs = store.listRuns(30).map(({ pg_url, ...r }) => ({ ...r, has_db: pg_url !== null }));
40
+ return json(res, 200, { runs, actions: store.feed(300).map(publicAction) });
41
+ }
42
+ if (req.method === "GET" && parts[0] === "api" && parts[1] === "actions" && parts[3] === "chain") {
43
+ const a = store.getAction(parts[2]);
44
+ if (!a)
45
+ return json(res, 404, { error: "not found" });
46
+ return json(res, 200, { chain: undoChain(store, a).map((c) => ({ id: c.id, tool: c.tool })) });
47
+ }
48
+ if (req.method === "POST") {
49
+ if (req.headers["x-backstep"] !== "1")
50
+ return json(res, 403, { error: "missing header" });
51
+ const [, kind, id, verb] = parts;
52
+ if (kind === "actions" && verb === "undo")
53
+ return json(res, 200, { outcomes: await undoAction(store, id) });
54
+ if (kind === "runs" && verb === "undo")
55
+ return json(res, 200, { outcomes: await undoRun(store, id) });
56
+ }
57
+ json(res, 404, { error: "not found" });
58
+ }
59
+ catch (err) {
60
+ json(res, 400, { error: err instanceof Error ? err.message : String(err) });
61
+ }
62
+ });
63
+ return new Promise((resolve, reject) => {
64
+ server.once("error", reject);
65
+ server.listen(port, "127.0.0.1", () => {
66
+ console.log(`Backstep dashboard: http://localhost:${port}`);
67
+ resolve();
68
+ });
69
+ });
70
+ }