backstep 0.0.0-stage → 0.1.0

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.
package/dist/undo.js ADDED
@@ -0,0 +1,176 @@
1
+ // Undoing actions, shared by the CLI and the dashboard.
2
+ import { rmSync } from "node:fs";
3
+ import { undoPaths } from "./adapters/fs.js";
4
+ import { undoSql } from "./adapters/postgres.js";
5
+ import { undoTree } from "./adapters/tree.js";
6
+ import { snapshotDir } from "./store.js";
7
+ import { dbKey, freshUrl } from "./supabase.js";
8
+ const INVERSE_TIMEOUT_MS = 20_000;
9
+ function resources(store, a) {
10
+ const u = a.undo ? JSON.parse(a.undo) : null;
11
+ if (!u)
12
+ return new Set();
13
+ if (u.adapter === "fs")
14
+ return new Set(u.entries.map((e) => `fs:${e.path}`));
15
+ // A project snapshot rewinds every file under its folder.
16
+ if (u.adapter === "tree")
17
+ return new Set([`fs:${u.root}`]);
18
+ // An opposite tool call doesn't rewind shared state, so it never conflicts.
19
+ if (u.adapter === "inverse" || u.adapter === "engine")
20
+ return new Set();
21
+ // Keyed by database, not run, so two agents on the same database still conflict.
22
+ const url = u.url ?? store.getRun(a.run_id)?.pg_url;
23
+ const db = url ? dbKey(url) : a.run_id;
24
+ const pgKey = (t) => `pg:${db}:${t.schema}.${t.name}`;
25
+ return new Set([...u.snapshots, ...u.trashed, ...u.created].map(pgKey));
26
+ }
27
+ // Same resource, or a file inside a folder that's rewound whole.
28
+ function overlaps(a, b) {
29
+ if (a === b)
30
+ return true;
31
+ if (!a.startsWith("fs:") || !b.startsWith("fs:"))
32
+ return false;
33
+ return a.startsWith(b + "/") || b.startsWith(a + "/");
34
+ }
35
+ // Failed calls stay undoable: a tool can change things before it errors out.
36
+ function undoable(a) {
37
+ return (a.status === "ok" || a.status === "error") && a.undo !== null;
38
+ }
39
+ // Undoing an action rewinds what it touched to how it was before, which would
40
+ // silently throw away later changes to the same files or tables. So those later
41
+ // actions are undone first, newest to oldest.
42
+ export function undoChain(store, target) {
43
+ const later = store.db
44
+ .prepare("SELECT * FROM actions WHERE created_at >= ? AND id != ? AND status IN ('ok', 'error') AND undo IS NOT NULL ORDER BY created_at DESC, seq DESC")
45
+ .all(target.created_at, target.id);
46
+ const touched = resources(store, target);
47
+ const chain = [];
48
+ // Walk oldest-first so a conflict found later still pulls in its own dependents.
49
+ for (const a of [...later].reverse()) {
50
+ const r = resources(store, a);
51
+ if ([...r].some((x) => [...touched].some((t) => overlaps(x, t)))) {
52
+ chain.push(a);
53
+ for (const x of r)
54
+ touched.add(x);
55
+ }
56
+ }
57
+ return [...chain.reverse(), target];
58
+ }
59
+ // Inverse calls run inside the proxy that holds the connection to the server,
60
+ // so queue the request and wait for it to report back.
61
+ async function undoInverse(store, a, u) {
62
+ const live = store.db.prepare("SELECT 1 FROM runs WHERE server = ? AND ended_at IS NULL LIMIT 1").get(u.server);
63
+ if (!live) {
64
+ return { id: a.id, tool: a.tool, ok: false, detail: `start "${u.server}" again through Backstep to undo this (it runs ${u.tool})` };
65
+ }
66
+ const before = a.status;
67
+ if (!store.transition(a.id, before, "undo_requested")) {
68
+ return { id: a.id, tool: a.tool, ok: false, detail: "this action changed while undoing; try again" };
69
+ }
70
+ const deadline = Date.now() + INVERSE_TIMEOUT_MS;
71
+ while (Date.now() < deadline) {
72
+ const cur = store.getAction(a.id);
73
+ if (cur.status === "undone")
74
+ return { id: a.id, tool: a.tool, ok: true, detail: `ran ${u.tool}` };
75
+ if (cur.status === "undo_failed")
76
+ return { id: a.id, tool: a.tool, ok: false, detail: `${u.tool} failed: ${cur.error}` };
77
+ await new Promise((r) => setTimeout(r, 200));
78
+ }
79
+ if (store.transition(a.id, "undo_requested", before)) {
80
+ return { id: a.id, tool: a.tool, ok: false, detail: `no running "${u.server}" picked this up; is it still connected?` };
81
+ }
82
+ return { id: a.id, tool: a.tool, ok: false, detail: `${u.tool} is still running; check back in a moment` };
83
+ }
84
+ // Claude Code reports back how the call went with `backstep settle`.
85
+ function undoEngine(store, a, u, opts) {
86
+ if (!opts.viaClaudeCode) {
87
+ return { id: a.id, tool: a.tool, ok: false, detail: `undo this with ctrl+z in Claude Code: only it is connected to ${u.server}` };
88
+ }
89
+ if (!store.transition(a.id, a.status, "undo_requested")) {
90
+ return { id: a.id, tool: a.tool, ok: false, detail: "this action changed while undoing; try again" };
91
+ }
92
+ return { id: a.id, tool: a.tool, ok: true, detail: `runs ${u.tool}`, run: { server: u.server, tool: u.tool, args: u.args } };
93
+ }
94
+ async function undoOne(store, a, opts = {}) {
95
+ const u = JSON.parse(a.undo);
96
+ if (u.adapter === "inverse")
97
+ return undoInverse(store, a, u);
98
+ if (u.adapter === "engine")
99
+ return undoEngine(store, a, u, opts);
100
+ try {
101
+ let detail;
102
+ if (u.adapter === "fs") {
103
+ detail = undoPaths(u);
104
+ }
105
+ else if (u.adapter === "tree") {
106
+ detail = undoTree(u, a.id);
107
+ }
108
+ else {
109
+ const url = u.url ?? store.getRun(a.run_id)?.pg_url;
110
+ if (!url)
111
+ throw new Error("this run has no database connection recorded");
112
+ // A temporary Supabase login may have run out since the snapshot.
113
+ detail = await undoSql(await freshUrl(url), u);
114
+ }
115
+ store.updateAction(a.id, { status: "undone", undone_at: Date.now() });
116
+ if (u.adapter === "fs" || u.adapter === "tree")
117
+ rmSync(snapshotDir(a.id), { recursive: true, force: true });
118
+ const pushed = u.adapter === "tree" && u.pushed?.length ? { root: u.root, pushes: u.pushed } : undefined;
119
+ return { id: a.id, tool: a.tool, ok: true, detail, ...(pushed ? { pushed } : {}) };
120
+ }
121
+ catch (err) {
122
+ const detail = err instanceof Error ? err.message : String(err);
123
+ store.updateAction(a.id, { status: "undo_failed", error: detail });
124
+ return { id: a.id, tool: a.tool, ok: false, detail };
125
+ }
126
+ }
127
+ async function runChain(store, chain, opts = {}) {
128
+ const out = [];
129
+ for (const a of chain) {
130
+ const r = await undoOne(store, a, opts);
131
+ out.push(r);
132
+ // Stop at the first failure: undoing older actions on top of a half-undone
133
+ // newer one would leave things in a state nobody asked for.
134
+ if (!r.ok)
135
+ break;
136
+ }
137
+ return out;
138
+ }
139
+ export async function undoAction(store, id) {
140
+ const a = store.getAction(id);
141
+ if (!a)
142
+ throw new Error(`no action ${id}`);
143
+ if (!undoable(a))
144
+ throw new Error(`action ${id} can't be undone (status: ${a.status}, ${a.undo ? "has" : "no"} undo data)`);
145
+ return runChain(store, undoChain(store, a));
146
+ }
147
+ export async function undoRun(store, runId) {
148
+ if (!store.getRun(runId))
149
+ throw new Error(`no run ${runId}`);
150
+ return undoAll(store, store.listActions(runId).filter(undoable));
151
+ }
152
+ // Everything any agent did in a window of time: what one turn of a chat did,
153
+ // across Claude's own edits and every server wrapped by Backstep.
154
+ export async function undoSince(store, since, until = Date.now(), opts = {}) {
155
+ const inWindow = store.db
156
+ .prepare("SELECT * FROM actions WHERE created_at >= ? AND created_at <= ? ORDER BY created_at, seq")
157
+ .all(since, until);
158
+ return undoAll(store, inWindow.filter(undoable), opts);
159
+ }
160
+ async function undoAll(store, actions, opts = {}) {
161
+ if (actions.length === 0)
162
+ return [];
163
+ // Start from the oldest, which pulls in every later conflict across runs too.
164
+ const chain = [];
165
+ const seen = new Set();
166
+ for (const a of [...actions].reverse()) {
167
+ for (const c of undoChain(store, a)) {
168
+ if (!seen.has(c.id)) {
169
+ seen.add(c.id);
170
+ chain.push(c);
171
+ }
172
+ }
173
+ }
174
+ chain.sort((x, y) => y.created_at - x.created_at || y.seq - x.seq);
175
+ return runChain(store, chain, opts);
176
+ }
package/dist/watch.js ADDED
@@ -0,0 +1,138 @@
1
+ // Tool calls to MCP servers Backstep can't sit in front of: claude.ai
2
+ // connectors, and remote servers Claude Code signs in to. The Claude Code mod
3
+ // reports each call before and after it runs. Backstep records it, and where
4
+ // an undo rule matches, keeps the opposite call, which the mod runs through
5
+ // Claude Code's own connection. Supabase SQL is snapshotted over the
6
+ // database connection from `backstep connect supabase`.
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { discardSnapshots, planSql } from "./adapters/postgres.js";
10
+ import { planCall } from "./classify.js";
11
+ import { buildInverse, findRule, loadRules } from "./rules.js";
12
+ import { isWrappedServer } from "./servers.js";
13
+ import { homeDir } from "./store.js";
14
+ import { CONNECT_HINT, isSupabaseSql, supabaseDb, supabasePlanOptions, supabaseUrl } from "./supabase.js";
15
+ const DELETES = /(^|_|-)(delete|remove|trash|destroy|drop|purge|erase|archive)(_|-|$)/i;
16
+ const MAX_RESULT = 20_000;
17
+ // The engine spells MCP tools mcp__<server>__<tool>.
18
+ export function splitTool(name) {
19
+ if (!name.startsWith("mcp__"))
20
+ return null;
21
+ const rest = name.slice(5);
22
+ const i = rest.indexOf("__");
23
+ return i > 0 ? { server: rest.slice(0, i), tool: rest.slice(i + 2) } : null;
24
+ }
25
+ // Rules from BACKSTEP_RULES, else ~/.backstep/rules.json when there is one.
26
+ export function watchRules() {
27
+ const file = process.env.BACKSTEP_RULES ?? join(homeDir(), "rules.json");
28
+ return existsSync(file) ? loadRules(file) : [];
29
+ }
30
+ // A Supabase SQL write with no database connected to snapshot it.
31
+ function unconnected(server, tool, args) {
32
+ if (!isSupabaseSql(server, tool))
33
+ return false;
34
+ const plan = planCall(undefined, tool, args);
35
+ return plan.kind === "sql" && !supabaseDb(typeof args.project_id === "string" ? args.project_id : undefined);
36
+ }
37
+ export async function watchStart(store, session, cwd, name, args) {
38
+ const t = splitTool(name);
39
+ if (!t)
40
+ return { kind: "read" };
41
+ // Its own Backstep proxy records it.
42
+ if (isWrappedServer(t.server, cwd))
43
+ return unconnected(t.server, t.tool, args) ? { kind: "wrapped", hint: "supabase" } : { kind: "wrapped" };
44
+ const record = (kind, reason) => store.addAction({
45
+ run_id: store.sessionRun(t.server, `session:${session}`).id,
46
+ tool: t.tool,
47
+ args: JSON.stringify(args),
48
+ kind,
49
+ status: "running",
50
+ reason,
51
+ undo: null,
52
+ result: null,
53
+ error: null,
54
+ release_at: null,
55
+ });
56
+ const rule = findRule(watchRules(), t.server, t.tool);
57
+ const plan = rule ? null : planCall(undefined, t.tool, args);
58
+ if (plan?.kind === "read")
59
+ return { kind: "read" };
60
+ if (plan?.kind === "sql" && isSupabaseSql(t.server, t.tool)) {
61
+ const url = await supabaseUrl(typeof args.project_id === "string" ? args.project_id : undefined);
62
+ if (!url)
63
+ return { kind: "write", id: record("write", CONNECT_HINT).id, reason: CONNECT_HINT, hint: "supabase" };
64
+ const a = record("reversible", null);
65
+ try {
66
+ const p = await planSql(url, a.id, plan.sql, supabasePlanOptions(t.tool));
67
+ if (p.mode === "irreversible") {
68
+ store.updateAction(a.id, { kind: "irreversible", reason: p.reason });
69
+ return { kind: "irreversible", id: a.id, reason: p.reason };
70
+ }
71
+ // Kept now, so watchEnd can drop the snapshots if the call fails.
72
+ const undo = { ...p.undo, url };
73
+ store.updateAction(a.id, { undo: JSON.stringify(undo) });
74
+ return p.mode === "rewrite" ? { kind: "reversible", id: a.id, args: { [plan.argKey]: p.sql } } : { kind: "reversible", id: a.id };
75
+ }
76
+ catch (err) {
77
+ // Never block the call: it just won't be undoable.
78
+ const reason = `couldn't snapshot first: ${err instanceof Error ? err.message : String(err)}`;
79
+ store.updateAction(a.id, { kind: "write", reason });
80
+ return { kind: "write", id: a.id, reason };
81
+ }
82
+ }
83
+ let kind = "write";
84
+ let reason;
85
+ if (rule)
86
+ kind = "reversible";
87
+ else if (plan?.kind === "irreversible")
88
+ [kind, reason] = ["irreversible", `${plan.reason.replace(/ acts outside.*/, "")} can't be taken back`];
89
+ else if (DELETES.test(t.tool))
90
+ [kind, reason] = ["irreversible", "deletes something Backstep can't bring back"];
91
+ else
92
+ reason = "no undo rule for this tool";
93
+ return { kind, id: record(kind, reason ?? null).id, reason };
94
+ }
95
+ // The engine hands the mod the tool's record and the text the model read.
96
+ function asResult(r) {
97
+ const rec = r.result;
98
+ if (rec && typeof rec === "object" && rec.structuredContent)
99
+ return rec;
100
+ if (rec && typeof rec === "object" && Array.isArray(rec.content))
101
+ return rec;
102
+ return { content: [{ type: "text", text: r.text ?? (typeof rec === "string" ? rec : JSON.stringify(rec ?? "")) }] };
103
+ }
104
+ export async function watchEnd(store, id, r) {
105
+ const a = store.getAction(id);
106
+ if (!a)
107
+ throw new Error(`no action ${id}`);
108
+ const result = asResult(r);
109
+ const text = (r.text ?? "").slice(0, MAX_RESULT);
110
+ const server = store.getRun(a.run_id).server;
111
+ const args = JSON.parse(a.args);
112
+ const kept = a.undo ? JSON.parse(a.undo) : null;
113
+ if (kept?.adapter === "pg") {
114
+ // Postgres runs the string as one transaction, so a failed call changed nothing.
115
+ if (r.isError) {
116
+ await discardSnapshots(kept.url, kept).catch(() => { });
117
+ store.updateAction(id, { status: "error", error: text, undo: null });
118
+ return { undoable: false };
119
+ }
120
+ store.updateAction(id, { status: "ok", result: text });
121
+ return { undoable: true };
122
+ }
123
+ if (r.isError) {
124
+ store.updateAction(id, { status: "error", error: text });
125
+ return { undoable: false };
126
+ }
127
+ let undo = null;
128
+ const rule = a.kind === "reversible" ? findRule(watchRules(), server, a.tool) : undefined;
129
+ if (rule) {
130
+ const built = buildInverse(rule, server, args, result);
131
+ if ("undo" in built)
132
+ undo = { adapter: "engine", server, tool: built.undo.tool, args: built.undo.args };
133
+ else
134
+ store.updateAction(id, { kind: "write", reason: `the result had no ${built.missing} for the undo rule` });
135
+ }
136
+ store.updateAction(id, { status: "ok", result: text, undo: undo ? JSON.stringify(undo) : null });
137
+ return { undoable: undo !== null };
138
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "backstep",
3
+ "owner": { "name": "Backstep" },
4
+ "plugins": [{ "name": "backstep", "source": "./" }]
5
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "backstep",
3
+ "version": "0.1.0",
4
+ "description": "Press ctrl+z to rewind everything Claude did in its last turn: file edits, shell commands, and database and tool changes.",
5
+ "types": "./types/index.d.ts",
6
+ "userConfig": {
7
+ "cli": {
8
+ "type": "string",
9
+ "title": "Backstep CLI",
10
+ "description": "Path to Backstep's cli.js (run with node), or \"backstep\" if it is on your PATH. backstep setup fills this in.",
11
+ "default": "backstep"
12
+ }
13
+ }
14
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }