retrace-agent 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +165 -2
- package/dist/adapters/filesystem.js +23 -0
- package/dist/cli.js +163 -0
- package/dist/fs-state.js +53 -0
- package/dist/journal.js +104 -0
- package/dist/pg-server.js +158 -0
- package/dist/pg-undo.js +68 -0
- package/dist/pg-util.js +53 -0
- package/dist/preview.js +89 -0
- package/dist/proxy.js +73 -0
- package/dist/undo.js +54 -0
- package/dist/version.js +1 -0
- package/package.json +58 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sivaji Kandimalla
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,166 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Retrace
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Ctrl+Z for AI agents. Undo any file or database change your agent makes.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/kandimallasivaji9/retrace/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/retrace-agent)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://modelcontextprotocol.io)
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
*The agent asked first. The human clicked the wrong option. One command fixed it.*
|
|
13
|
+
|
|
14
|
+
## Try it in 30 seconds
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g retrace-agent
|
|
18
|
+
|
|
19
|
+
# Put Retrace in front of any MCP server
|
|
20
|
+
retrace wrap -- npx -y @modelcontextprotocol/server-filesystem ./project
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Your agent works exactly as before. Every change is recorded with its before-state, so when something goes wrong:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
retrace log # see what the agent did
|
|
27
|
+
retrace undo --run # roll back the whole run, newest step first
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Prefer not to install? Use `npx -y retrace-agent wrap -- ...` instead.
|
|
31
|
+
|
|
32
|
+
## Why this exists
|
|
33
|
+
|
|
34
|
+
- **Agents act on real systems now.** They edit files and run SQL. One bad step, or one wrong click on "approve", can wipe data you need.
|
|
35
|
+
- **Asking first isn't enough.** Good agents confirm before destructive actions. People still approve the wrong thing.
|
|
36
|
+
- **Checkpoints don't cover side effects.** Agent tools can rewind the chat or your code. Nothing rewinds the rows your agent deleted.
|
|
37
|
+
- **No framework to adopt.** Retrace is an MCP server, so it works with the agent you already use.
|
|
38
|
+
|
|
39
|
+
## What it can undo today
|
|
40
|
+
|
|
41
|
+
| Target | How | Status |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| Files (wraps `@modelcontextprotocol/server-filesystem`) | Exact restore of writes, edits, moves, new folders | ✅ |
|
|
44
|
+
| Postgres 18+ (`retrace postgres`) | Row-level undo of `INSERT`, `UPDATE`, `DELETE` | ✅ |
|
|
45
|
+
| GitHub, Slack, Stripe | Compensating actions (close, delete, refund) | Planned |
|
|
46
|
+
|
|
47
|
+
| Client | Status |
|
|
48
|
+
|---|---|
|
|
49
|
+
| Claude Desktop | ✅ Tested |
|
|
50
|
+
| Claude Code, Cursor, other stdio MCP clients | Should work (standard MCP over stdio). Reports welcome. |
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
- **Undo a step or a whole run** with `retrace undo <id>` or `retrace undo --run`.
|
|
55
|
+
- **Dry-run any agent** with `--dry-run`: real diffs and exact row counts, nothing changed.
|
|
56
|
+
- **Catch conflicts.** If a person changed the data after the agent, undo stops instead of overwriting their work.
|
|
57
|
+
- **Block what can't be undone.** `DROP`, `ALTER`, `TRUNCATE`, multi-statement SQL, and writes to tables without a primary key are refused.
|
|
58
|
+
- **Keep a tamper-evident audit log.** Every action is hash-chained in a local SQLite journal; `retrace verify` checks it.
|
|
59
|
+
|
|
60
|
+
## Postgres
|
|
61
|
+
|
|
62
|
+
Retrace ships its own Postgres MCP server. Reads work normally, and every write is recorded row by row so it can be undone exactly.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
retrace postgres --url postgres://localhost/mydb
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
No database handy? Clone the repo and run `npm run demo-db` for a throwaway Postgres 18 with 500 sample customers (it's the database in the GIF).
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
retrace log
|
|
72
|
+
# #1 execute applied DELETE 412 rows in public.customers
|
|
73
|
+
retrace undo --run
|
|
74
|
+
# ✓ #1 execute undone
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The journal never stores passwords. If your database needs one, set `RETRACE_PG_URL` (or pass `--url`) when you run `retrace undo`.
|
|
78
|
+
|
|
79
|
+
## Dry run
|
|
80
|
+
|
|
81
|
+
Not sure what an agent will do? Let it plan first:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
retrace wrap --dry-run -- npx -y @modelcontextprotocol/server-filesystem ./project
|
|
85
|
+
retrace postgres --dry-run --url postgres://localhost/mydb
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The agent gets a preview instead of a real change: a diff for every file write, or "Would DELETE 412 rows" with sample rows for SQL. Nothing is changed. `retrace log` shows the whole plan.
|
|
89
|
+
|
|
90
|
+
## Use with Claude Desktop
|
|
91
|
+
|
|
92
|
+
In Claude Desktop, open **Settings → Developer → Edit Config** and add:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"mcpServers": {
|
|
97
|
+
"files": {
|
|
98
|
+
"command": "npx",
|
|
99
|
+
"args": ["-y", "retrace-agent", "wrap", "--",
|
|
100
|
+
"npx", "-y", "@modelcontextprotocol/server-filesystem", "/Users/you/project"]
|
|
101
|
+
},
|
|
102
|
+
"db": {
|
|
103
|
+
"command": "npx",
|
|
104
|
+
"args": ["-y", "retrace-agent", "postgres", "--url", "postgres://localhost/mydb"]
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
If Claude Desktop can't find `npx`, use its full path (`which npx`).
|
|
111
|
+
|
|
112
|
+
## How it works
|
|
113
|
+
|
|
114
|
+
```mermaid
|
|
115
|
+
flowchart TD
|
|
116
|
+
A[Your agent] -->|MCP tool call| R
|
|
117
|
+
subgraph R[Retrace]
|
|
118
|
+
D[Dry-run / policy<br/>preview, block unsafe calls] --> S[Snapshot<br/>files or rows, before + after]
|
|
119
|
+
S --> J[Journal<br/>append-only, hash-chained]
|
|
120
|
+
J --> U[Undo engine<br/>newest first, conflict check]
|
|
121
|
+
end
|
|
122
|
+
R --> T[Your files / your Postgres]
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- **Files:** before each change, Retrace snapshots the paths the tool call touches. Undo restores those exact bytes.
|
|
126
|
+
- **Postgres:** every write runs with `RETURNING old.*, new.*` (new in Postgres 18), so Retrace records each affected row as it was and as it became, inside the same transaction. Undo rebuilds rows with their real column types and puts them back in one transaction.
|
|
127
|
+
|
|
128
|
+
## Safety and limits
|
|
129
|
+
|
|
130
|
+
Retrace is honest about what it can't do:
|
|
131
|
+
|
|
132
|
+
- **Postgres needs version 18+**, and tables need a primary key.
|
|
133
|
+
- **Cascades and triggers aren't reversed yet.** If a delete cascades to other tables, only the rows the agent deleted come back. `WITH` and `MERGE` statements are blocked for now.
|
|
134
|
+
- **Dry runs don't simulate state.** If the agent "writes" a file during a dry run and then reads it, it sees the original.
|
|
135
|
+
- **Only calls that go through Retrace are tracked.**
|
|
136
|
+
- **Files over 10 MB aren't snapshotted**, and `create_directory` only removes the last folder it created.
|
|
137
|
+
- **Your data stays local.** The journal at `~/.retrace/journal.db` holds before-state snapshots, so treat it like the data it protects.
|
|
138
|
+
|
|
139
|
+
Overhead: about 1.3 ms per file write at the median (2.3 ms vs 1.1 ms direct, 2 KB writes, 300 calls, Linux).
|
|
140
|
+
|
|
141
|
+
## Why not just…
|
|
142
|
+
|
|
143
|
+
| Alternative | Where it wins | Where Retrace helps |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| Git / file checkpoints | Great for code | Doesn't cover databases |
|
|
146
|
+
| Database backups / PITR | Full recovery | Rolls back *everything*, not just one agent's changes |
|
|
147
|
+
| Approval prompts | Stops many mistakes | People still approve the wrong thing; undo is the backstop |
|
|
148
|
+
|
|
149
|
+
## Roadmap
|
|
150
|
+
|
|
151
|
+
- [ ] Approval gate for actions that can't be undone (sending email, payments)
|
|
152
|
+
- [ ] GitHub and Slack adapters
|
|
153
|
+
- [ ] Pluggable adapter API
|
|
154
|
+
- [ ] Web UI for browsing runs
|
|
155
|
+
|
|
156
|
+
See [open issues](https://github.com/kandimallasivaji9/retrace/issues).
|
|
157
|
+
|
|
158
|
+
## Contributing
|
|
159
|
+
|
|
160
|
+
`npm test` runs everything end to end with a real MCP client and a throwaway Postgres; no setup needed. Start with [CONTRIBUTING.md](CONTRIBUTING.md) and the [`good first issue`](https://github.com/kandimallasivaji9/retrace/labels/good%20first%20issue) label.
|
|
161
|
+
|
|
162
|
+
If Retrace saved you from a bad agent run, a star helps others find it.
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { resolvePath } from "../fs-state.js";
|
|
2
|
+
const READ_ONLY = new Set([
|
|
3
|
+
"read_file", "read_text_file", "read_media_file", "read_multiple_files",
|
|
4
|
+
"list_directory", "list_directory_with_sizes", "directory_tree",
|
|
5
|
+
"search_files", "get_file_info", "list_allowed_directories",
|
|
6
|
+
]);
|
|
7
|
+
const MUTATING = {
|
|
8
|
+
write_file: (a) => [a.path],
|
|
9
|
+
edit_file: (a) => (a.dryRun ? [] : [a.path]),
|
|
10
|
+
create_directory: (a) => [a.path],
|
|
11
|
+
move_file: (a) => [a.source, a.destination],
|
|
12
|
+
};
|
|
13
|
+
export function classify(tool, args) {
|
|
14
|
+
if (READ_ONLY.has(tool))
|
|
15
|
+
return { kind: "read" };
|
|
16
|
+
const pick = MUTATING[tool];
|
|
17
|
+
if (!pick)
|
|
18
|
+
return { kind: "untracked" };
|
|
19
|
+
const paths = pick(args ?? {})
|
|
20
|
+
.filter((p) => typeof p === "string" && p.length > 0)
|
|
21
|
+
.map(resolvePath);
|
|
22
|
+
return paths.length ? { kind: "tracked", paths } : { kind: "read" };
|
|
23
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { Journal } from "./journal.js";
|
|
5
|
+
import { startPostgresServer } from "./pg-server.js";
|
|
6
|
+
import { startProxy } from "./proxy.js";
|
|
7
|
+
import { undoEntry, undoRun } from "./undo.js";
|
|
8
|
+
import { VERSION } from "./version.js";
|
|
9
|
+
const program = new Command()
|
|
10
|
+
.name("retrace")
|
|
11
|
+
.description("Ctrl+Z for AI agents. Undo any tool call your agent regrets.")
|
|
12
|
+
.version(VERSION)
|
|
13
|
+
.enablePositionalOptions();
|
|
14
|
+
const short = (id) => id.slice(0, 8);
|
|
15
|
+
/** Show paths relative to where you run the command, so the log stays readable. */
|
|
16
|
+
function rel(p) {
|
|
17
|
+
const r = path.relative(process.cwd(), p);
|
|
18
|
+
return r && !r.startsWith("..") && !path.isAbsolute(r) ? r : p;
|
|
19
|
+
}
|
|
20
|
+
function target(e) {
|
|
21
|
+
const a = JSON.parse(e.args);
|
|
22
|
+
if (e.before) {
|
|
23
|
+
const b = JSON.parse(e.before);
|
|
24
|
+
if (b?.kind === "postgres") {
|
|
25
|
+
const pg = b;
|
|
26
|
+
const tables = Object.values(pg.tables).map((t) => `${t.schema}.${t.name}`).join(", ");
|
|
27
|
+
return `${pg.command} ${pg.rows.length} row${pg.rows.length === 1 ? "" : "s"} in ${tables}`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (typeof a.sql === "string") {
|
|
31
|
+
const sql = a.sql.replace(/\s+/g, " ").trim();
|
|
32
|
+
return sql.length > 70 ? sql.slice(0, 67) + "..." : sql;
|
|
33
|
+
}
|
|
34
|
+
if (a.source && a.destination)
|
|
35
|
+
return `${rel(a.source)} → ${rel(a.destination)}`;
|
|
36
|
+
if (a.path)
|
|
37
|
+
return rel(a.path);
|
|
38
|
+
return e.args.length > 60 ? e.args.slice(0, 57) + "..." : e.args;
|
|
39
|
+
}
|
|
40
|
+
function printEntry(e) {
|
|
41
|
+
const time = e.ts.slice(11, 19);
|
|
42
|
+
console.log(` #${String(e.id).padEnd(5)}${time} ${e.tool.padEnd(18)}${e.status.padEnd(11)}${target(e)}`);
|
|
43
|
+
}
|
|
44
|
+
function printOutcomes(outcomes) {
|
|
45
|
+
if (outcomes.length === 0)
|
|
46
|
+
console.log("Nothing to undo.");
|
|
47
|
+
let ok = true;
|
|
48
|
+
for (const o of outcomes) {
|
|
49
|
+
const mark = { undone: "✓", skipped: "·", conflict: "!", error: "✗" }[o.result];
|
|
50
|
+
console.log(`${mark} #${o.id} ${o.tool} ${o.result}${o.reason ? `: ${o.reason}` : ""}`);
|
|
51
|
+
if (o.result === "conflict" || o.result === "error")
|
|
52
|
+
ok = false;
|
|
53
|
+
}
|
|
54
|
+
return ok;
|
|
55
|
+
}
|
|
56
|
+
function findRun(j, id) {
|
|
57
|
+
const runId = typeof id === "string" ? j.resolveRun(id) : j.lastRunId();
|
|
58
|
+
if (!runId) {
|
|
59
|
+
console.error(typeof id === "string" ? `No single run matches "${id}".` : "No runs recorded yet.");
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
return runId;
|
|
63
|
+
}
|
|
64
|
+
program
|
|
65
|
+
.command("wrap")
|
|
66
|
+
.description("run an MCP server behind Retrace, e.g. retrace wrap -- npx @modelcontextprotocol/server-filesystem ./project")
|
|
67
|
+
.option("--journal <path>", "journal file (default ~/.retrace/journal.db)")
|
|
68
|
+
.option("--dry-run", "preview every change; nothing is written (or set RETRACE_DRY_RUN=1)")
|
|
69
|
+
.argument("<command...>", "the MCP server command to wrap")
|
|
70
|
+
.passThroughOptions()
|
|
71
|
+
.action(async (command, opts) => {
|
|
72
|
+
const [cmd, ...args] = command;
|
|
73
|
+
const dryRun = !!opts.dryRun || process.env.RETRACE_DRY_RUN === "1";
|
|
74
|
+
await startProxy(cmd, args, { journal: opts.journal, dryRun });
|
|
75
|
+
});
|
|
76
|
+
program
|
|
77
|
+
.command("postgres")
|
|
78
|
+
.description("run Retrace's Postgres MCP server: every INSERT/UPDATE/DELETE is recorded and undoable (Postgres 18+)")
|
|
79
|
+
.option("--url <url>", "connection string (or set DATABASE_URL)")
|
|
80
|
+
.option("--dry-run", "run each write in a transaction, report what it would change, then roll back")
|
|
81
|
+
.option("--journal <path>")
|
|
82
|
+
.action(async (opts) => {
|
|
83
|
+
const url = opts.url ?? process.env.DATABASE_URL;
|
|
84
|
+
if (!url) {
|
|
85
|
+
console.error("Pass --url postgres://... or set DATABASE_URL.");
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
await startPostgresServer(url, { journal: opts.journal, dryRun: !!opts.dryRun || process.env.RETRACE_DRY_RUN === "1" });
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.error(`[retrace] ${err.message}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
program
|
|
97
|
+
.command("log")
|
|
98
|
+
.description("show what an agent did")
|
|
99
|
+
.option("--run <id>", "a specific run (default: latest)")
|
|
100
|
+
.option("--runs", "list recent runs instead")
|
|
101
|
+
.option("--journal <path>")
|
|
102
|
+
.action((opts) => {
|
|
103
|
+
const j = new Journal(opts.journal);
|
|
104
|
+
if (opts.runs) {
|
|
105
|
+
for (const r of j.runs()) {
|
|
106
|
+
console.log(`${short(r.run_id)} ${r.started.slice(0, 19).replace("T", " ")} ${r.actions} actions, ${r.undoable} undoable`);
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const runId = findRun(j, opts.run);
|
|
111
|
+
const entries = j.entries(runId);
|
|
112
|
+
const dry = entries.length > 0 && entries.every((e) => e.status === "planned");
|
|
113
|
+
console.log(`Run ${short(runId)} (${entries.length} actions${dry ? ", dry run" : ""})`);
|
|
114
|
+
entries.forEach(printEntry);
|
|
115
|
+
if (dry)
|
|
116
|
+
console.log("\n Dry run: nothing was changed. Run without --dry-run to apply.");
|
|
117
|
+
const untracked = entries.filter((e) => e.status === "untracked" || (e.status === "planned" && e.reversibility === "untracked")).length;
|
|
118
|
+
if (untracked)
|
|
119
|
+
console.log(`\n ${untracked} action(s) went to tools Retrace can't undo yet.`);
|
|
120
|
+
});
|
|
121
|
+
program
|
|
122
|
+
.command("undo")
|
|
123
|
+
.description("undo one action, or a whole run with --run")
|
|
124
|
+
.argument("[id]", "action id from `retrace log`")
|
|
125
|
+
.option("--run [runId]", "undo a whole run, newest step first (default: latest run)")
|
|
126
|
+
.option("--force", "undo even if something changed after the agent")
|
|
127
|
+
.option("--url <url>", "Postgres connection string, if the recorded one needs a password")
|
|
128
|
+
.option("--journal <path>")
|
|
129
|
+
.action(async (id, opts) => {
|
|
130
|
+
const j = new Journal(opts.journal);
|
|
131
|
+
let outcomes;
|
|
132
|
+
if (opts.run) {
|
|
133
|
+
outcomes = await undoRun(j, findRun(j, opts.run), { force: opts.force, pgUrl: opts.url });
|
|
134
|
+
}
|
|
135
|
+
else if (id) {
|
|
136
|
+
const e = j.get(Number(id.replace(/^#/, "")));
|
|
137
|
+
if (!e) {
|
|
138
|
+
console.error(`No action #${id}.`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
outcomes = [await undoEntry(j, e, { force: opts.force, pgUrl: opts.url })];
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
console.error("Pass an action id (retrace undo 12) or --run.");
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
if (!printOutcomes(outcomes))
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|
|
150
|
+
program
|
|
151
|
+
.command("verify")
|
|
152
|
+
.description("check the journal's hash chain for tampering")
|
|
153
|
+
.option("--journal <path>")
|
|
154
|
+
.action((opts) => {
|
|
155
|
+
const result = new Journal(opts.journal).verify();
|
|
156
|
+
if (result.ok)
|
|
157
|
+
console.log(`✓ Journal intact (${result.count} actions).`);
|
|
158
|
+
else {
|
|
159
|
+
console.error(`✗ Hash chain broken at action #${result.brokenAt}.`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
program.parseAsync();
|
package/dist/fs-state.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const MAX_SNAPSHOT_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
export const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
7
|
+
export function resolvePath(p) {
|
|
8
|
+
if (p === "~" || p.startsWith("~/"))
|
|
9
|
+
p = path.join(os.homedir(), p.slice(1));
|
|
10
|
+
return path.resolve(p);
|
|
11
|
+
}
|
|
12
|
+
export async function readState(p, withContent = true) {
|
|
13
|
+
try {
|
|
14
|
+
const st = await fs.stat(p);
|
|
15
|
+
if (st.isDirectory())
|
|
16
|
+
return { path: p, kind: "dir", hash: "dir" };
|
|
17
|
+
if (st.size > MAX_SNAPSHOT_BYTES) {
|
|
18
|
+
return { path: p, kind: "file", hash: `large:${st.size}:${st.mtimeMs}`, tooLarge: true };
|
|
19
|
+
}
|
|
20
|
+
const buf = await fs.readFile(p);
|
|
21
|
+
return {
|
|
22
|
+
path: p,
|
|
23
|
+
kind: "file",
|
|
24
|
+
hash: sha256(buf),
|
|
25
|
+
content: withContent ? buf.toString("base64") : undefined,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err.code === "ENOENT")
|
|
30
|
+
return { path: p, kind: "absent", hash: "absent" };
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Put a path back exactly the way a "before" snapshot recorded it. */
|
|
35
|
+
export async function restoreState(s) {
|
|
36
|
+
if (s.kind === "file") {
|
|
37
|
+
if (s.tooLarge || s.content === undefined) {
|
|
38
|
+
throw new Error(`${s.path} was too large to snapshot, can't restore it`);
|
|
39
|
+
}
|
|
40
|
+
await fs.mkdir(path.dirname(s.path), { recursive: true });
|
|
41
|
+
await fs.writeFile(s.path, Buffer.from(s.content, "base64"));
|
|
42
|
+
}
|
|
43
|
+
else if (s.kind === "absent") {
|
|
44
|
+
const current = await readState(s.path, false);
|
|
45
|
+
if (current.kind === "file")
|
|
46
|
+
await fs.unlink(s.path);
|
|
47
|
+
else if (current.kind === "dir")
|
|
48
|
+
await fs.rmdir(s.path); // only if empty, on purpose
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
await fs.mkdir(s.path, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/journal.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
const GENESIS = "0".repeat(64);
|
|
7
|
+
export const defaultJournalPath = () => process.env.RETRACE_JOURNAL ?? path.join(os.homedir(), ".retrace", "journal.db");
|
|
8
|
+
/** Status is left out on purpose: it changes on undo. Everything the agent did is covered. */
|
|
9
|
+
function entryHash(prev, e) {
|
|
10
|
+
return createHash("sha256")
|
|
11
|
+
.update(prev + JSON.stringify([e.run_id, e.step, e.ts, e.tool, e.args, e.reversibility, e.before, e.after, e.is_error]))
|
|
12
|
+
.digest("hex");
|
|
13
|
+
}
|
|
14
|
+
/** Append-only, hash-chained log of every action an agent took. */
|
|
15
|
+
export class Journal {
|
|
16
|
+
db;
|
|
17
|
+
constructor(file = defaultJournalPath()) {
|
|
18
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
19
|
+
this.db = new Database(file);
|
|
20
|
+
this.db.pragma("journal_mode = WAL");
|
|
21
|
+
this.db.exec(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS actions (
|
|
23
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
24
|
+
run_id TEXT NOT NULL, step INTEGER NOT NULL, ts TEXT NOT NULL,
|
|
25
|
+
tool TEXT NOT NULL, args TEXT NOT NULL, reversibility TEXT NOT NULL,
|
|
26
|
+
before TEXT, after TEXT, is_error INTEGER NOT NULL, status TEXT NOT NULL,
|
|
27
|
+
prev_hash TEXT NOT NULL, hash TEXT NOT NULL
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_actions_run ON actions(run_id, step);
|
|
30
|
+
`);
|
|
31
|
+
}
|
|
32
|
+
record(n) {
|
|
33
|
+
const insert = this.db.transaction(() => {
|
|
34
|
+
const last = this.db.prepare("SELECT hash FROM actions ORDER BY id DESC LIMIT 1").get();
|
|
35
|
+
const prev = last?.hash ?? GENESIS;
|
|
36
|
+
const { s: step } = this.db
|
|
37
|
+
.prepare("SELECT COALESCE(MAX(step), 0) + 1 AS s FROM actions WHERE run_id = ?")
|
|
38
|
+
.get(n.runId);
|
|
39
|
+
const row = {
|
|
40
|
+
run_id: n.runId,
|
|
41
|
+
step,
|
|
42
|
+
ts: new Date().toISOString(),
|
|
43
|
+
tool: n.tool,
|
|
44
|
+
args: JSON.stringify(n.args ?? {}),
|
|
45
|
+
reversibility: n.reversibility,
|
|
46
|
+
before: n.before == null ? null : JSON.stringify(n.before),
|
|
47
|
+
after: n.after == null ? null : JSON.stringify(n.after),
|
|
48
|
+
is_error: n.isError ? 1 : 0,
|
|
49
|
+
};
|
|
50
|
+
const status = n.planned
|
|
51
|
+
? "planned"
|
|
52
|
+
: n.reversibility === "untracked" ? "untracked" : n.isError ? "failed" : "applied";
|
|
53
|
+
const info = this.db
|
|
54
|
+
.prepare(`INSERT INTO actions (run_id, step, ts, tool, args, reversibility, before, after, is_error, status, prev_hash, hash)
|
|
55
|
+
VALUES (@run_id, @step, @ts, @tool, @args, @reversibility, @before, @after, @is_error, @status, @prev_hash, @hash)`)
|
|
56
|
+
.run({ ...row, status, prev_hash: prev, hash: entryHash(prev, row) });
|
|
57
|
+
return Number(info.lastInsertRowid);
|
|
58
|
+
});
|
|
59
|
+
return this.get(insert());
|
|
60
|
+
}
|
|
61
|
+
get(id) {
|
|
62
|
+
return this.db.prepare("SELECT * FROM actions WHERE id = ?").get(id);
|
|
63
|
+
}
|
|
64
|
+
entries(runId) {
|
|
65
|
+
return this.db.prepare("SELECT * FROM actions WHERE run_id = ? ORDER BY step").all(runId);
|
|
66
|
+
}
|
|
67
|
+
lastRunId() {
|
|
68
|
+
const r = this.db.prepare("SELECT run_id FROM actions ORDER BY id DESC LIMIT 1").get();
|
|
69
|
+
return r?.run_id;
|
|
70
|
+
}
|
|
71
|
+
/** Accepts a full run id or a unique prefix (like the 8 chars `retrace log` prints). */
|
|
72
|
+
resolveRun(prefix) {
|
|
73
|
+
const rows = this.db
|
|
74
|
+
.prepare("SELECT DISTINCT run_id FROM actions WHERE run_id LIKE ? LIMIT 2")
|
|
75
|
+
.all(`${prefix}%`);
|
|
76
|
+
return rows.length === 1 ? rows[0].run_id : undefined;
|
|
77
|
+
}
|
|
78
|
+
runs(limit = 10) {
|
|
79
|
+
return this.db
|
|
80
|
+
.prepare(`SELECT run_id, COUNT(*) AS actions,
|
|
81
|
+
SUM(status IN ('applied','failed')) AS undoable,
|
|
82
|
+
MIN(ts) AS started, MAX(ts) AS ended
|
|
83
|
+
FROM actions GROUP BY run_id ORDER BY MAX(id) DESC LIMIT ?`)
|
|
84
|
+
.all(limit);
|
|
85
|
+
}
|
|
86
|
+
setStatus(id, status) {
|
|
87
|
+
this.db.prepare("UPDATE actions SET status = ? WHERE id = ?").run(status, id);
|
|
88
|
+
}
|
|
89
|
+
/** Walks the hash chain. Any edited or deleted entry breaks it. */
|
|
90
|
+
verify() {
|
|
91
|
+
let prev = GENESIS;
|
|
92
|
+
let count = 0;
|
|
93
|
+
for (const row of this.db.prepare("SELECT * FROM actions ORDER BY id").iterate()) {
|
|
94
|
+
if (row.prev_hash !== prev || row.hash !== entryHash(prev, row))
|
|
95
|
+
return { ok: false, brokenAt: row.id };
|
|
96
|
+
prev = row.hash;
|
|
97
|
+
count++;
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, count };
|
|
100
|
+
}
|
|
101
|
+
close() {
|
|
102
|
+
this.db.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import pg from "pg";
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import { Journal } from "./journal.js";
|
|
7
|
+
import { displayName, inspectTable, sanitizeUrl, validateDml } from "./pg-util.js";
|
|
8
|
+
import { VERSION } from "./version.js";
|
|
9
|
+
const MAX_TRACKED_ROWS = 100_000;
|
|
10
|
+
const MAX_RESULT_ROWS = 100;
|
|
11
|
+
const DRY = "[retrace dry-run] Nothing was changed.";
|
|
12
|
+
const TOOLS = [
|
|
13
|
+
{
|
|
14
|
+
name: "list_tables",
|
|
15
|
+
description: "List the tables in the database with approximate row counts.",
|
|
16
|
+
inputSchema: { type: "object", properties: {} },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "describe_table",
|
|
20
|
+
description: "Show a table's columns, types and primary key.",
|
|
21
|
+
inputSchema: { type: "object", properties: { table: { type: "string", description: "Table name, optionally schema-qualified" } }, required: ["table"] },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "query",
|
|
25
|
+
description: `Run a read-only SQL query (SELECT). Returns up to ${MAX_RESULT_ROWS} rows as JSON.`,
|
|
26
|
+
inputSchema: { type: "object", properties: { sql: { type: "string" } }, required: ["sql"] },
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "execute",
|
|
30
|
+
description: "Run one INSERT, UPDATE or DELETE statement. Retrace records every change so it can be undone. Schema changes (DROP, ALTER, TRUNCATE) are blocked.",
|
|
31
|
+
inputSchema: { type: "object", properties: { sql: { type: "string" } }, required: ["sql"] },
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
35
|
+
const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
36
|
+
const plural = (n) => `${n} row${n === 1 ? "" : "s"}`;
|
|
37
|
+
/** Retrace's own Postgres MCP server: reads pass through, every write is journaled and undoable. Needs Postgres 18+. */
|
|
38
|
+
export async function startPostgresServer(url, opts = {}) {
|
|
39
|
+
const pool = new pg.Pool({ connectionString: url, max: 4 });
|
|
40
|
+
const { rows: [v] } = await pool.query("SELECT current_setting('server_version_num')::int AS n, current_setting('server_version') AS s");
|
|
41
|
+
if (v.n < 180000) {
|
|
42
|
+
throw new Error(`Retrace needs Postgres 18 or newer (this server is ${v.s}). It relies on RETURNING old/new to record changes.`);
|
|
43
|
+
}
|
|
44
|
+
const journal = new Journal(opts.journal);
|
|
45
|
+
const runId = randomUUID();
|
|
46
|
+
const safeUrl = sanitizeUrl(url);
|
|
47
|
+
async function execute(sql) {
|
|
48
|
+
const checked = validateDml(sql);
|
|
49
|
+
if ("error" in checked)
|
|
50
|
+
return fail(checked.error);
|
|
51
|
+
const client = await pool.connect();
|
|
52
|
+
try {
|
|
53
|
+
await client.query("BEGIN");
|
|
54
|
+
await client.query("SET LOCAL statement_timeout = '60s'");
|
|
55
|
+
const res = await client.query({
|
|
56
|
+
text: `${checked.sql}\nRETURNING COALESCE(old.tableoid, new.tableoid)::oid AS __t, to_jsonb(old)::text AS __old, to_jsonb(new)::text AS __new`,
|
|
57
|
+
queryMode: "extended", // one statement only: Postgres rejects "DELETE ...; DROP ..."
|
|
58
|
+
});
|
|
59
|
+
const command = res.command;
|
|
60
|
+
const n = res.rows.length;
|
|
61
|
+
const tables = {};
|
|
62
|
+
for (const oid of new Set(res.rows.map((r) => String(r.__t)))) {
|
|
63
|
+
const info = await inspectTable(client, oid);
|
|
64
|
+
if (info.pk.length === 0) {
|
|
65
|
+
await client.query("ROLLBACK");
|
|
66
|
+
return fail(`${displayName(info)} has no primary key, so Retrace can't undo changes to it. Nothing was changed.`);
|
|
67
|
+
}
|
|
68
|
+
tables[oid] = info;
|
|
69
|
+
}
|
|
70
|
+
if (n > MAX_TRACKED_ROWS) {
|
|
71
|
+
await client.query("ROLLBACK");
|
|
72
|
+
return fail(`This would change ${n} rows; Retrace tracks at most ${MAX_TRACKED_ROWS} per statement. Nothing was changed.`);
|
|
73
|
+
}
|
|
74
|
+
const where = Object.values(tables).map(displayName).join(", ") || "no table";
|
|
75
|
+
const label = `${command} ${plural(n)}${n ? ` in ${where}` : ""}`;
|
|
76
|
+
if (opts.dryRun) {
|
|
77
|
+
await client.query("ROLLBACK");
|
|
78
|
+
journal.record({ runId, tool: "execute", args: { sql }, reversibility: "reversible", before: null, after: null, isError: false, planned: true });
|
|
79
|
+
const sample = res.rows.slice(0, 3).map((r) => ` ${r.__old ?? r.__new}`).join("\n");
|
|
80
|
+
return ok(`${DRY} Would ${label}.${n ? `\nSample of affected rows:\n${sample}` : ""}`);
|
|
81
|
+
}
|
|
82
|
+
await client.query("COMMIT");
|
|
83
|
+
if (n > 0) {
|
|
84
|
+
const before = {
|
|
85
|
+
kind: "postgres", url: safeUrl, command, tables,
|
|
86
|
+
rows: res.rows.map((r) => ({ t: String(r.__t), old: r.__old, new: r.__new })),
|
|
87
|
+
};
|
|
88
|
+
journal.record({ runId, tool: "execute", args: { sql }, reversibility: "reversible", before, after: { kind: "postgres", rowCount: n }, isError: false });
|
|
89
|
+
}
|
|
90
|
+
return ok(`${label}.`);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
await client.query("ROLLBACK").catch(() => { });
|
|
94
|
+
return fail(`SQL error: ${err.message}. Nothing was changed.`);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
client.release();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function readOnly(sql, params = []) {
|
|
101
|
+
const client = await pool.connect();
|
|
102
|
+
try {
|
|
103
|
+
await client.query("BEGIN READ ONLY");
|
|
104
|
+
await client.query("SET LOCAL statement_timeout = '30s'");
|
|
105
|
+
return (await client.query({ text: sql, values: params, queryMode: "extended" }));
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await client.query("ROLLBACK").catch(() => { });
|
|
109
|
+
client.release();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const server = new Server({ name: "retrace-postgres", version: VERSION }, { capabilities: { tools: {} } });
|
|
113
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
114
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
115
|
+
const args = (req.params.arguments ?? {});
|
|
116
|
+
try {
|
|
117
|
+
switch (req.params.name) {
|
|
118
|
+
case "list_tables": {
|
|
119
|
+
const r = await readOnly(`SELECT n.nspname || '.' || c.relname AS "table", GREATEST(c.reltuples, 0)::bigint AS approx_rows
|
|
120
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
121
|
+
WHERE c.relkind IN ('r', 'p') AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_toast%'
|
|
122
|
+
ORDER BY 1`);
|
|
123
|
+
return ok(JSON.stringify(r.rows, null, 2));
|
|
124
|
+
}
|
|
125
|
+
case "describe_table": {
|
|
126
|
+
const r = await readOnly(`SELECT a.attname AS column, format_type(a.atttypid, a.atttypmod) AS type, NOT a.attnotnull AS nullable,
|
|
127
|
+
COALESCE(a.attnum = ANY(i.indkey), false) AS primary_key
|
|
128
|
+
FROM pg_attribute a LEFT JOIN pg_index i ON i.indrelid = a.attrelid AND i.indisprimary
|
|
129
|
+
WHERE a.attrelid = $1::regclass AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, [args.table]);
|
|
130
|
+
return ok(JSON.stringify(r.rows, null, 2));
|
|
131
|
+
}
|
|
132
|
+
case "query": {
|
|
133
|
+
const r = await readOnly(args.sql);
|
|
134
|
+
const shown = r.rows.slice(0, MAX_RESULT_ROWS);
|
|
135
|
+
const more = r.rows.length > MAX_RESULT_ROWS ? `\n(${r.rows.length - MAX_RESULT_ROWS} more rows not shown; add a LIMIT or filter)` : "";
|
|
136
|
+
return ok(JSON.stringify(shown, null, 2) + more);
|
|
137
|
+
}
|
|
138
|
+
case "execute":
|
|
139
|
+
return await execute(args.sql);
|
|
140
|
+
default:
|
|
141
|
+
return fail(`Unknown tool ${req.params.name}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
return fail(`Error: ${err.message}`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
const shutdown = async () => {
|
|
149
|
+
await pool.end().catch(() => { });
|
|
150
|
+
journal.close();
|
|
151
|
+
process.exit(0);
|
|
152
|
+
};
|
|
153
|
+
process.on("SIGINT", shutdown);
|
|
154
|
+
process.on("SIGTERM", shutdown);
|
|
155
|
+
server.onclose = shutdown;
|
|
156
|
+
await server.connect(new StdioServerTransport());
|
|
157
|
+
process.stderr.write(`[retrace] postgres ${v.s} at ${safeUrl}, run ${runId.slice(0, 8)}${opts.dryRun ? " (DRY RUN: nothing will be changed)" : ""}\n`);
|
|
158
|
+
}
|
package/dist/pg-undo.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import pg from "pg";
|
|
2
|
+
import { displayName, qualified, quoteIdent as q } from "./pg-util.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reverses one journaled statement inside a single transaction, newest row first.
|
|
5
|
+
* Row images are rebuilt with jsonb_populate_record, so every column keeps its real type.
|
|
6
|
+
*/
|
|
7
|
+
export async function undoPostgres(b, urlOverride, force = false) {
|
|
8
|
+
const client = new pg.Client({ connectionString: urlOverride ?? process.env.RETRACE_PG_URL ?? b.url });
|
|
9
|
+
try {
|
|
10
|
+
await client.connect();
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
return { ok: false, conflict: false, reason: `can't connect to Postgres (${err.message}). Pass --url or set RETRACE_PG_URL.` };
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
await client.query("BEGIN");
|
|
17
|
+
for (const r of [...b.rows].reverse()) {
|
|
18
|
+
const info = b.tables[r.t];
|
|
19
|
+
const tbl = qualified(info);
|
|
20
|
+
const shown = displayName(info);
|
|
21
|
+
const rec = (param) => `jsonb_populate_record(NULL::${tbl}, ${param}::jsonb)`;
|
|
22
|
+
const pk = (alias) => info.pk.map((c) => `${alias}.${q(c)}`).join(", ");
|
|
23
|
+
const key = (img) => info.pk.map((c) => `${c}=${JSON.parse(img)[c]}`).join(", ");
|
|
24
|
+
if (!force) {
|
|
25
|
+
if (r.new !== null) {
|
|
26
|
+
const cur = await client.query(`SELECT to_jsonb(t) = $1::jsonb AS same FROM ${tbl} t, ${rec("$1")} n WHERE (${pk("t")}) = (${pk("n")})`, [r.new]);
|
|
27
|
+
if (cur.rowCount === 0 || !cur.rows[0].same) {
|
|
28
|
+
await client.query("ROLLBACK");
|
|
29
|
+
return { ok: false, conflict: true, reason: `${shown} row ${key(r.new)} changed after the agent touched it (use --force to overwrite)` };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const cur = await client.query(`SELECT 1 FROM ${tbl} t, ${rec("$1")} o WHERE (${pk("t")}) = (${pk("o")})`, [r.old]);
|
|
34
|
+
if (cur.rowCount) {
|
|
35
|
+
await client.query("ROLLBACK");
|
|
36
|
+
return { ok: false, conflict: true, reason: `${shown} row ${key(r.old)} was re-created after the agent deleted it (use --force to overwrite)` };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (r.old === null) {
|
|
41
|
+
// agent inserted it → delete it
|
|
42
|
+
await client.query(`DELETE FROM ${tbl} t USING ${rec("$1")} n WHERE (${pk("t")}) = (${pk("n")})`, [r.new]);
|
|
43
|
+
}
|
|
44
|
+
else if (r.new === null) {
|
|
45
|
+
// agent deleted it → put it back exactly
|
|
46
|
+
const cols = info.insertable;
|
|
47
|
+
if (force)
|
|
48
|
+
await client.query(`DELETE FROM ${tbl} t USING ${rec("$1")} o WHERE (${pk("t")}) = (${pk("o")})`, [r.old]);
|
|
49
|
+
await client.query(`INSERT INTO ${tbl} (${cols.map(q).join(", ")}) OVERRIDING SYSTEM VALUE SELECT ${cols.map((c) => `o.${q(c)}`).join(", ")} FROM ${rec("$1")} o`, [r.old]);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// agent updated it → restore old values
|
|
53
|
+
const cols = info.updatable;
|
|
54
|
+
await client.query(`UPDATE ${tbl} t SET (${cols.map(q).join(", ")}) = ROW(${cols.map((c) => `o.${q(c)}`).join(", ")})
|
|
55
|
+
FROM ${rec("$1")} o, ${rec("$2")} n WHERE (${pk("t")}) = (${pk("n")})`, [r.old, r.new]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
await client.query("COMMIT");
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
await client.query("ROLLBACK").catch(() => { });
|
|
63
|
+
return { ok: false, conflict: false, reason: err.message };
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await client.end().catch(() => { });
|
|
67
|
+
}
|
|
68
|
+
}
|
package/dist/pg-util.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const quoteIdent = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
2
|
+
export const qualified = (t) => `${quoteIdent(t.schema)}.${quoteIdent(t.name)}`;
|
|
3
|
+
/** For messages people read: public.customers, not "public"."customers". */
|
|
4
|
+
export const displayName = (t) => `${t.schema}.${t.name}`;
|
|
5
|
+
/** Never store a password in the journal. */
|
|
6
|
+
export function sanitizeUrl(url) {
|
|
7
|
+
try {
|
|
8
|
+
const u = new URL(url);
|
|
9
|
+
u.password = "";
|
|
10
|
+
return u.toString();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return url.replace(/:\/\/([^:@/]+):[^@/]*@/, "://$1@");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function inspectTable(client, oid) {
|
|
17
|
+
const { rows } = await client.query(`SELECT n.nspname AS schema, c.relname AS name,
|
|
18
|
+
COALESCE((SELECT array_agg(a.attname::text ORDER BY array_position(i.indkey::int2[], a.attnum))
|
|
19
|
+
FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
|
20
|
+
WHERE i.indrelid = c.oid AND i.indisprimary), '{}') AS pk,
|
|
21
|
+
COALESCE((SELECT array_agg(a.attname::text ORDER BY a.attnum) FROM pg_attribute a
|
|
22
|
+
WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped AND a.attgenerated = ''), '{}') AS insertable,
|
|
23
|
+
COALESCE((SELECT array_agg(a.attname::text ORDER BY a.attnum) FROM pg_attribute a
|
|
24
|
+
WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped AND a.attgenerated = ''
|
|
25
|
+
AND a.attidentity <> 'a'), '{}') AS updatable
|
|
26
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = $1`, [oid]);
|
|
27
|
+
if (!rows[0])
|
|
28
|
+
throw new Error(`table ${oid} not found`);
|
|
29
|
+
return rows[0];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Only plain INSERT / UPDATE / DELETE get through `execute`.
|
|
33
|
+
* WITH is blocked because a data-modifying CTE could change tables Retrace can't see.
|
|
34
|
+
*/
|
|
35
|
+
export function validateDml(sql) {
|
|
36
|
+
const body = sql.trim().replace(/;\s*$/, "");
|
|
37
|
+
const head = body.replace(/^(\s*--[^\n]*\n|\s*\/\*[\s\S]*?\*\/)*/, "").trimStart().toLowerCase();
|
|
38
|
+
if (/^(select|show|explain)\b/.test(head))
|
|
39
|
+
return { error: "Use the query tool for reads. execute is for INSERT, UPDATE and DELETE." };
|
|
40
|
+
if (/^with\b/.test(head))
|
|
41
|
+
return { error: "WITH statements aren't supported by execute yet. Write a plain INSERT, UPDATE or DELETE." };
|
|
42
|
+
if (/^merge\b/.test(head))
|
|
43
|
+
return { error: "MERGE isn't supported yet. Use INSERT ... ON CONFLICT, UPDATE or DELETE." };
|
|
44
|
+
if (!/^(insert|update|delete)\b/.test(head)) {
|
|
45
|
+
return { error: "Blocked by Retrace: only INSERT, UPDATE and DELETE can run. Schema changes (DROP, ALTER, TRUNCATE, CREATE) and other commands aren't allowed." };
|
|
46
|
+
}
|
|
47
|
+
const noStrings = body.replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, '""');
|
|
48
|
+
if (noStrings.includes(";"))
|
|
49
|
+
return { error: "One statement at a time, please. Nothing was changed." };
|
|
50
|
+
if (/\breturning\b/i.test(body))
|
|
51
|
+
return { error: "Remove the RETURNING clause; Retrace adds its own to record the change." };
|
|
52
|
+
return { sql: body };
|
|
53
|
+
}
|
package/dist/preview.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createTwoFilesPatch } from "diff";
|
|
4
|
+
import { readState, resolvePath } from "./fs-state.js";
|
|
5
|
+
const TAG = "[retrace dry-run] Nothing was changed.";
|
|
6
|
+
/** Asks the upstream server which directories it allows, so previews never read outside them. */
|
|
7
|
+
export async function allowedDirectories(upstream) {
|
|
8
|
+
try {
|
|
9
|
+
const res = await upstream.callTool({ name: "list_allowed_directories", arguments: {} });
|
|
10
|
+
const text = (res.content ?? []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
|
|
11
|
+
const dirs = text.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("/") || /^[A-Za-z]:\\/.test(l));
|
|
12
|
+
return Promise.all(dirs.map((d) => fs.realpath(d).catch(() => d)));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Resolves symlinks on the deepest part of the path that exists. */
|
|
19
|
+
async function realish(p) {
|
|
20
|
+
let cur = p;
|
|
21
|
+
const tail = [];
|
|
22
|
+
for (;;) {
|
|
23
|
+
try {
|
|
24
|
+
return path.join(await fs.realpath(cur), ...tail);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
const parent = path.dirname(cur);
|
|
28
|
+
if (parent === cur)
|
|
29
|
+
return p;
|
|
30
|
+
tail.unshift(path.basename(cur));
|
|
31
|
+
cur = parent;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function isAllowed(p, allowed) {
|
|
36
|
+
const real = await realish(p);
|
|
37
|
+
return allowed.some((d) => real === d || real.startsWith(d + path.sep));
|
|
38
|
+
}
|
|
39
|
+
function textOf(res) {
|
|
40
|
+
return (res?.content ?? []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
|
|
41
|
+
}
|
|
42
|
+
/** Describes what a tool call would do, without doing it. */
|
|
43
|
+
export async function previewCall(upstream, tool, args, plan, allowed) {
|
|
44
|
+
if (plan.kind !== "tracked") {
|
|
45
|
+
return `${TAG} Blocked "${tool}": Retrace can't preview this tool yet, so it wasn't run.`;
|
|
46
|
+
}
|
|
47
|
+
for (const p of plan.paths) {
|
|
48
|
+
if (!(await isAllowed(p, allowed)))
|
|
49
|
+
return `${TAG} Would fail: ${p} is outside the allowed directories.`;
|
|
50
|
+
}
|
|
51
|
+
switch (tool) {
|
|
52
|
+
case "edit_file": {
|
|
53
|
+
// The filesystem server can preview edits itself and returns a diff.
|
|
54
|
+
const res = await upstream.callTool({ name: tool, arguments: { ...args, dryRun: true } });
|
|
55
|
+
return res.isError ? `${TAG} Would fail: ${textOf(res)}` : `${TAG} Planned edit:\n${textOf(res)}`;
|
|
56
|
+
}
|
|
57
|
+
case "write_file": {
|
|
58
|
+
const [p] = plan.paths;
|
|
59
|
+
const before = await readState(p);
|
|
60
|
+
if (before.kind === "dir")
|
|
61
|
+
return `${TAG} Would fail: ${p} is a directory.`;
|
|
62
|
+
if (before.tooLarge)
|
|
63
|
+
return `${TAG} Would overwrite ${p} (too large to diff).`;
|
|
64
|
+
const old = before.kind === "file" ? Buffer.from(before.content, "base64").toString("utf8") : "";
|
|
65
|
+
const verb = before.kind === "absent" ? "create" : "overwrite";
|
|
66
|
+
const patch = createTwoFilesPatch(p, p, old, String(args.content ?? ""), verb === "create" ? "(new file)" : "current", "planned");
|
|
67
|
+
return `${TAG} Would ${verb} ${p}:\n${patch}`;
|
|
68
|
+
}
|
|
69
|
+
case "move_file": {
|
|
70
|
+
const [src, dest] = plan.paths;
|
|
71
|
+
if ((await readState(src, false)).kind === "absent")
|
|
72
|
+
return `${TAG} Would fail: ${src} doesn't exist.`;
|
|
73
|
+
if ((await readState(dest, false)).kind !== "absent")
|
|
74
|
+
return `${TAG} Would fail: ${dest} already exists.`;
|
|
75
|
+
return `${TAG} Would move ${src} → ${dest}.`;
|
|
76
|
+
}
|
|
77
|
+
case "create_directory": {
|
|
78
|
+
const [p] = plan.paths;
|
|
79
|
+
const s = await readState(p, false);
|
|
80
|
+
if (s.kind === "dir")
|
|
81
|
+
return `${TAG} ${p} already exists, no change.`;
|
|
82
|
+
if (s.kind === "file")
|
|
83
|
+
return `${TAG} Would fail: ${p} is a file.`;
|
|
84
|
+
return `${TAG} Would create directory ${p}.`;
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
return `${TAG} Would run "${tool}" on ${plan.paths.map(resolvePath).join(", ")}.`;
|
|
88
|
+
}
|
|
89
|
+
}
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { classify } from "./adapters/filesystem.js";
|
|
8
|
+
import { readState } from "./fs-state.js";
|
|
9
|
+
import { Journal } from "./journal.js";
|
|
10
|
+
import { allowedDirectories, previewCall } from "./preview.js";
|
|
11
|
+
import { VERSION } from "./version.js";
|
|
12
|
+
const log = (msg) => process.stderr.write(`[retrace] ${msg}\n`);
|
|
13
|
+
/**
|
|
14
|
+
* Sits between the agent and a real MCP server.
|
|
15
|
+
* The agent sees the same tools; every change gets a before/after snapshot in the journal.
|
|
16
|
+
*/
|
|
17
|
+
export async function startProxy(command, args, opts = {}) {
|
|
18
|
+
const journal = new Journal(opts.journal);
|
|
19
|
+
const runId = randomUUID();
|
|
20
|
+
const upstream = new Client({ name: "retrace", version: VERSION });
|
|
21
|
+
await upstream.connect(new StdioClientTransport({ command, args, env: process.env, stderr: "inherit" }));
|
|
22
|
+
const upstreamName = upstream.getServerVersion()?.name ?? command;
|
|
23
|
+
const allowed = opts.dryRun ? await allowedDirectories(upstream) : [];
|
|
24
|
+
const server = new Server({ name: `retrace(${upstreamName})`, version: VERSION }, { capabilities: { tools: {} } });
|
|
25
|
+
server.setRequestHandler(ListToolsRequestSchema, async (req) => upstream.listTools(req.params));
|
|
26
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
27
|
+
const { name, arguments: toolArgs = {} } = req.params;
|
|
28
|
+
const plan = classify(name, toolArgs);
|
|
29
|
+
if (plan.kind === "read")
|
|
30
|
+
return (await upstream.callTool(req.params));
|
|
31
|
+
if (opts.dryRun) {
|
|
32
|
+
const text = await previewCall(upstream, name, toolArgs, plan, allowed);
|
|
33
|
+
journal.record({
|
|
34
|
+
runId, tool: name, args: toolArgs,
|
|
35
|
+
reversibility: plan.kind === "tracked" ? "reversible" : "untracked",
|
|
36
|
+
before: null, after: null, isError: false, planned: true,
|
|
37
|
+
});
|
|
38
|
+
return { content: [{ type: "text", text }] };
|
|
39
|
+
}
|
|
40
|
+
if (plan.kind === "untracked") {
|
|
41
|
+
const result = await upstream.callTool(req.params);
|
|
42
|
+
journal.record({ runId, tool: name, args: toolArgs, reversibility: "untracked", before: null, after: null, isError: !!result.isError });
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
const before = await Promise.all(plan.paths.map((p) => readState(p)));
|
|
46
|
+
let result;
|
|
47
|
+
let failure;
|
|
48
|
+
try {
|
|
49
|
+
result = await upstream.callTool(req.params);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
failure = err;
|
|
53
|
+
}
|
|
54
|
+
const after = await Promise.all(plan.paths.map((p) => readState(p, false)));
|
|
55
|
+
// Only journal calls that actually changed something.
|
|
56
|
+
if (after.some((s, i) => s.hash !== before[i].hash)) {
|
|
57
|
+
journal.record({ runId, tool: name, args: toolArgs, reversibility: "reversible", before, after, isError: !!failure || !!result?.isError });
|
|
58
|
+
}
|
|
59
|
+
if (failure)
|
|
60
|
+
throw failure;
|
|
61
|
+
return result;
|
|
62
|
+
});
|
|
63
|
+
const shutdown = async () => {
|
|
64
|
+
await upstream.close().catch(() => { });
|
|
65
|
+
journal.close();
|
|
66
|
+
process.exit(0);
|
|
67
|
+
};
|
|
68
|
+
process.on("SIGINT", shutdown);
|
|
69
|
+
process.on("SIGTERM", shutdown);
|
|
70
|
+
server.onclose = shutdown;
|
|
71
|
+
await server.connect(new StdioServerTransport());
|
|
72
|
+
log(`wrapping ${upstreamName}, run ${runId.slice(0, 8)}${opts.dryRun ? " (DRY RUN: nothing will be changed)" : ""}`);
|
|
73
|
+
}
|
package/dist/undo.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readState, restoreState } from "./fs-state.js";
|
|
2
|
+
import { undoPostgres } from "./pg-undo.js";
|
|
3
|
+
export async function undoEntry(journal, e, opts = {}) {
|
|
4
|
+
const force = !!opts.force;
|
|
5
|
+
const base = { id: e.id, step: e.step, tool: e.tool };
|
|
6
|
+
if (e.status === "undone")
|
|
7
|
+
return { ...base, result: "skipped", reason: "already undone" };
|
|
8
|
+
if (e.reversibility !== "reversible" || !e.before || !e.after) {
|
|
9
|
+
return { ...base, result: "skipped", reason: "not tracked, can't undo" };
|
|
10
|
+
}
|
|
11
|
+
const parsed = JSON.parse(e.before);
|
|
12
|
+
if (!Array.isArray(parsed) && parsed?.kind === "postgres") {
|
|
13
|
+
const r = await undoPostgres(parsed, opts.pgUrl, force);
|
|
14
|
+
if (!r.ok)
|
|
15
|
+
return { ...base, result: r.conflict ? "conflict" : "error", reason: r.reason };
|
|
16
|
+
journal.setStatus(e.id, "undone");
|
|
17
|
+
return { ...base, result: "undone" };
|
|
18
|
+
}
|
|
19
|
+
const before = parsed;
|
|
20
|
+
const after = JSON.parse(e.after);
|
|
21
|
+
// Conflict check: never overwrite a change someone made after the agent.
|
|
22
|
+
if (!force) {
|
|
23
|
+
for (const s of after) {
|
|
24
|
+
const now = await readState(s.path, false);
|
|
25
|
+
if (now.hash !== s.hash) {
|
|
26
|
+
return { ...base, result: "conflict", reason: `${s.path} changed after the agent touched it (use --force to overwrite)` };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
for (const s of [...before].reverse())
|
|
32
|
+
await restoreState(s);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
return { ...base, result: "error", reason: err.message };
|
|
36
|
+
}
|
|
37
|
+
journal.setStatus(e.id, "undone");
|
|
38
|
+
return { ...base, result: "undone" };
|
|
39
|
+
}
|
|
40
|
+
/** Undo a whole run, newest step first. Stops at the first conflict so later steps stay consistent. */
|
|
41
|
+
export async function undoRun(journal, runId, opts = {}) {
|
|
42
|
+
const steps = journal
|
|
43
|
+
.entries(runId)
|
|
44
|
+
.filter((e) => e.status === "applied" || e.status === "failed")
|
|
45
|
+
.reverse();
|
|
46
|
+
const outcomes = [];
|
|
47
|
+
for (const e of steps) {
|
|
48
|
+
const o = await undoEntry(journal, e, opts);
|
|
49
|
+
outcomes.push(o);
|
|
50
|
+
if (o.result === "conflict" || o.result === "error")
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
return outcomes;
|
|
54
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = "0.2.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "retrace-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Ctrl+Z for AI agents. Undo any file or database change your agent makes, through MCP.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"retrace": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"test": "npm run build && node tests/e2e.mjs && node tests/pg.e2e.mjs",
|
|
17
|
+
"demo-db": "node scripts/demo-db.mjs",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "Sivaji Kandimalla",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/kandimallasivaji9/retrace.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/kandimallasivaji9/retrace#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/kandimallasivaji9/retrace/issues"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"mcp",
|
|
35
|
+
"model-context-protocol",
|
|
36
|
+
"ai-agents",
|
|
37
|
+
"agent-safety",
|
|
38
|
+
"undo",
|
|
39
|
+
"rollback",
|
|
40
|
+
"postgres",
|
|
41
|
+
"llm",
|
|
42
|
+
"claude",
|
|
43
|
+
"audit-log"
|
|
44
|
+
],
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
47
|
+
"better-sqlite3": "^11.0.0",
|
|
48
|
+
"commander": "^12.1.0",
|
|
49
|
+
"diff": "^8.0.4",
|
|
50
|
+
"pg": "^8.23.1"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@modelcontextprotocol/server-filesystem": "^2.2.0",
|
|
54
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
55
|
+
"@types/node": "^20.14.0",
|
|
56
|
+
"@types/pg": "^8.23.1",
|
|
57
|
+
"embedded-postgres": "18.4.0-beta.17",
|
|
58
|
+
"typescript": "^5.5.0"
|
|
59
|
+
}
|
|
60
|
+
}
|