synartesis 0.8.1 → 0.8.5
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/CHANGELOG.md +264 -0
- package/README.md +48 -30
- package/dist/{chunk-O3QDPYEL.js → chunk-JE7MOCZO.js} +170 -10
- package/dist/cli.js +153 -47
- package/dist/proxy.js +15 -8
- package/manifests/git.yaml +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,270 @@
|
|
|
2
2
|
|
|
3
3
|
What changed, and why it mattered. Dates are release dates.
|
|
4
4
|
|
|
5
|
+
## 0.8.5 — 2026-09-22
|
|
6
|
+
|
|
7
|
+
Three states you could get into and not get out of, two screens that read the
|
|
8
|
+
whole journal to draw one frame, and a gate that let two statements through.
|
|
9
|
+
Found by auditing rather than by using it, which is the difference between
|
|
10
|
+
this release and the last one.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **An undo killed halfway can now be resumed.** An action whose inverse was
|
|
15
|
+
being sent is marked `rolling_back`, and until now nothing could ever
|
|
16
|
+
reclaim one: no flag, no command, no amount of waiting. The claim was right
|
|
17
|
+
to refuse -- sending a second inverse is harmless for a restore and a second
|
|
18
|
+
real change to the world for a compensation -- but it was refusing because
|
|
19
|
+
it could not tell a process still working from one that had died, and there
|
|
20
|
+
was nothing to ask. One Ctrl-C during an undo put an action permanently
|
|
21
|
+
beyond the command whose whole job is getting back.
|
|
22
|
+
|
|
23
|
+
There is a lease now, in a `leases` table, holding the host and process id
|
|
24
|
+
that took each claim. A claim is reclaimed only on evidence, and only when
|
|
25
|
+
both questions have answers: the drift check has already proved the inverse
|
|
26
|
+
never landed (a `rolling_back` row that gets that far has a verified
|
|
27
|
+
pre-read, or it halts earlier), and the operating system says the process
|
|
28
|
+
that held it is gone. Anything less certain -- a lease from another machine,
|
|
29
|
+
a lease taken by a build from before the table existed, no lease at all --
|
|
30
|
+
still halts, and now says who held it and since when. There is no timeout to
|
|
31
|
+
tune, so there is no window in which an undo that is merely slow is mistaken
|
|
32
|
+
for one that died.
|
|
33
|
+
|
|
34
|
+
No schema version bump. `CREATE TABLE IF NOT EXISTS` changes no row and no
|
|
35
|
+
meaning, runs on every open, and an older build opening the same journal
|
|
36
|
+
neither notices nor cares -- the same argument the indexes have carried
|
|
37
|
+
since 0.6. A bump would refuse to open every journal already out there,
|
|
38
|
+
because there is still no migration path.
|
|
39
|
+
|
|
40
|
+
- **A call whose outcome was never established no longer blocks a whole run
|
|
41
|
+
for ever.** `pending` means the call went out and nobody can say whether it
|
|
42
|
+
landed, and undo stops at one rather than produce a state that is neither
|
|
43
|
+
the before nor the after. That was right and permanent: nothing in the CLI
|
|
44
|
+
could settle such a row, so one interrupted call blocked the undo of
|
|
45
|
+
everything older than it, and the only way past was `--to` above it, which
|
|
46
|
+
abandons the rest.
|
|
47
|
+
|
|
48
|
+
`synartesis resolve <action> --applied|--failed` records what a person
|
|
49
|
+
found, and who they were. It claims nothing else: an action resolved as
|
|
50
|
+
applied has no inverse, because none was ever resolved, so undo reports it
|
|
51
|
+
as something it cannot put back and carries on with the rest of the run.
|
|
52
|
+
Unblocking a run and undoing an action are different things and only the
|
|
53
|
+
first is on offer. A proxy that comes back and records what it actually saw
|
|
54
|
+
wins over a person's judgement, because it was there.
|
|
55
|
+
|
|
56
|
+
- **Ctrl-C during an undo stops between actions instead of wherever it lands.**
|
|
57
|
+
There was no signal handling in the CLI at all, so an interrupt was node's
|
|
58
|
+
default: die immediately, including with an inverse in flight, which is the
|
|
59
|
+
single best way to produce the state the first item above describes. The
|
|
60
|
+
first Ctrl-C now asks the rollback to stop before it claims the next action
|
|
61
|
+
and says so; the second is the operating system's. Deliberately not wired to
|
|
62
|
+
the abort signal that cancels a request in flight -- what is already in the
|
|
63
|
+
air is allowed to finish and be recorded.
|
|
64
|
+
|
|
65
|
+
- **A client disconnecting no longer erases that a session was undone.** The
|
|
66
|
+
proxy ends its run when its client goes away, which says nothing about
|
|
67
|
+
whether anything was put back -- and it was an unguarded write, so it
|
|
68
|
+
stamped `complete` straight over the `rolled_back` an undo had just
|
|
69
|
+
recorded. A session somebody had reversed came back looking untouched.
|
|
70
|
+
|
|
71
|
+
- **`undo` no longer defaults to a session an agent is still writing to.** The
|
|
72
|
+
newest run is, by definition, the one a running proxy is using, and `undo`
|
|
73
|
+
with no id pointed straight at it. A preview is always allowed, because it
|
|
74
|
+
writes nothing; doing it for real now needs `--yes`, and the refusal names
|
|
75
|
+
all three ways on. A session whose proxy exited normally is `complete`, so
|
|
76
|
+
this fires only where something really is unfinished.
|
|
77
|
+
|
|
78
|
+
- **Two writes the `on_write` gate read as reads.**
|
|
79
|
+
`WITH x AS (DELETE FROM users RETURNING *) SELECT * FROM x` is an ordinary
|
|
80
|
+
PostgreSQL data-modifying CTE, and `EXPLAIN ANALYZE DELETE FROM users` runs
|
|
81
|
+
the statement it claims to be explaining. Both begin with a word on the
|
|
82
|
+
read list, neither contains a semicolon, and both were passed through
|
|
83
|
+
ungated while emptying a table. The heuristic's promise is that anything it
|
|
84
|
+
cannot confidently read as a read is gated; these were read confidently and
|
|
85
|
+
wrongly. The leading word still decides whether a statement could be a read,
|
|
86
|
+
and a second pass over it -- with comments and string literals removed, so a
|
|
87
|
+
keyword cannot hide in either -- decides whether anything in it writes.
|
|
88
|
+
|
|
89
|
+
No shipped policy uses `on_write`, so nothing in the box was exposed. The
|
|
90
|
+
documentation invites you to use it for your own SQL server, which is
|
|
91
|
+
exactly who this was a hole for.
|
|
92
|
+
|
|
93
|
+
### Changed
|
|
94
|
+
|
|
95
|
+
- **The console drew each frame by reading the entire journal.** `runsView`
|
|
96
|
+
called `getActions` for every session on screen -- every snapshot, result
|
|
97
|
+
and inverse in each of them, each through a schema parse -- to arrive at two
|
|
98
|
+
integers per row, eight times a second. `tallyRuns` was added for precisely
|
|
99
|
+
this and `list` was moved onto it; the console was missed. On a
|
|
100
|
+
hundred-megabyte journal that was 62ms a frame against a 120ms tick, so half
|
|
101
|
+
the event loop went on deciding what to draw and keypresses queued behind
|
|
102
|
+
renders. It is about 2ms now, and what each session still has waiting comes
|
|
103
|
+
from two partial indexes rather than from its rows.
|
|
104
|
+
|
|
105
|
+
- **`watch` scanned the whole table to show twelve lines.** `recentActions`
|
|
106
|
+
orders by time and there was no index on time alone, so every 120ms it read
|
|
107
|
+
every row in the table -- the ones carrying the snapshots -- and sorted them
|
|
108
|
+
in a temporary b-tree to return twelve. 7.4ms to 0.03ms on the same journal.
|
|
109
|
+
|
|
110
|
+
- **A large write no longer pays to canonicalise its own arguments for
|
|
111
|
+
nothing.** The three lookups that run on the way in to a call each built a
|
|
112
|
+
canonical form of the arguments before checking whether there was any
|
|
113
|
+
candidate row to compare it against -- and their indexes are partial
|
|
114
|
+
precisely so that there almost never is. 23.5ms of pure waste on a
|
|
115
|
+
ten-megabyte write, gone with an early return.
|
|
116
|
+
|
|
117
|
+
- **A budget test for both screens.** Every performance test here measured one
|
|
118
|
+
call against a journal a few hundred rows deep, which is the one shape in
|
|
119
|
+
which none of the above exists. `tests/screen-budget.test.ts` seeds four
|
|
120
|
+
thousand actions and holds a console frame and a watch frame to a fraction
|
|
121
|
+
of their tick, so the next regression is caught by CI rather than by
|
|
122
|
+
somebody's fan.
|
|
123
|
+
|
|
124
|
+
## 0.8.4 — 2026-09-21
|
|
125
|
+
|
|
126
|
+
Bugs, all of them found by using the thing rather than by a test going red:
|
|
127
|
+
the suite was green before this and is green after it.
|
|
128
|
+
|
|
129
|
+
### Fixed
|
|
130
|
+
|
|
131
|
+
- **A server that was briefly unreachable no longer turns an ordinary undo
|
|
132
|
+
into one that needs `--force`.** The drift check reads the resource as it is
|
|
133
|
+
now, and when that read failed the action was written back as
|
|
134
|
+
`unrecoverable`. Nothing had been learned about the resource -- the read
|
|
135
|
+
never happened -- but the row now said otherwise, and the next plain
|
|
136
|
+
`undo` refused with "halted here on an earlier attempt" and offered
|
|
137
|
+
`--replan` or `--force`. So a server that was down for ten seconds escalated
|
|
138
|
+
an undo that would have worked into one that writes over whatever anybody
|
|
139
|
+
else had changed since.
|
|
140
|
+
|
|
141
|
+
The same write cost a second thing. A row that had already halted on real
|
|
142
|
+
drift carried the diff of what somebody had changed, which is the evidence
|
|
143
|
+
the person deciding is shown; retrying while the server was unreachable
|
|
144
|
+
replaced it with `server fs is not connected`. The halt then offered its
|
|
145
|
+
three-way choice with a transport error where the conflict used to be. The
|
|
146
|
+
read failure is now reported and not recorded, for the reason an unknown
|
|
147
|
+
outcome has never been recorded either: a rollback may not write down as
|
|
148
|
+
fact a thing it did not observe.
|
|
149
|
+
|
|
150
|
+
- **The way past a drift halt no longer sends you round in a circle.** It
|
|
151
|
+
printed `put it back as the run left it: synartesis undo <id> --replan`,
|
|
152
|
+
which reads as though the flag does the putting back. It does not: it
|
|
153
|
+
rebuilds each inverse from the current manifest, and the drift check then
|
|
154
|
+
runs again on a resource nobody has touched. Somebody who typed what that
|
|
155
|
+
line told them to got the identical halt, offering the identical three
|
|
156
|
+
options, including the one they had just chosen, for as long as they cared
|
|
157
|
+
to keep typing it. The restoring is the reader's half, as `--help` has
|
|
158
|
+
always said, and the line now says so: `put the resource back, then:`.
|
|
159
|
+
|
|
160
|
+
While there: the three options were spaced by hand and the middle one sat a
|
|
161
|
+
character left of the other two, which is enough to stop a menu reading as
|
|
162
|
+
a menu. They are padded to a column now, and a test fails if they drift
|
|
163
|
+
apart again.
|
|
164
|
+
|
|
165
|
+
- **`--to` past the end of a run answers in one line.** The message was
|
|
166
|
+
already exact -- `--to 99 is past the end of this run, which goes up to 1`
|
|
167
|
+
-- and was followed by five lines of unrelated commands. The bound in that
|
|
168
|
+
sentence was read off the journal, which is precisely the case the
|
|
169
|
+
command list was already meant to stay out of.
|
|
170
|
+
|
|
171
|
+
- **Counts agree with their nouns.** `1 runs and 1 actions removed`,
|
|
172
|
+
`1 actions: 1 gated`. Three places counted the same way and each had its own
|
|
173
|
+
copy of the arithmetic; there is one now, in `style.ts`, beside the other
|
|
174
|
+
decisions about how text is set.
|
|
175
|
+
|
|
176
|
+
- **The repository's social preview card is the whole card.** Every other
|
|
177
|
+
source in `brand/` states its size at 1x and gets its sharpness from the
|
|
178
|
+
shoot's scale factor; this one was written at 2560x1280 and shot at
|
|
179
|
+
1280x640, so what GitHub served when the repository was pasted into Slack
|
|
180
|
+
or a message was the top-left quarter of itself: three quarters empty
|
|
181
|
+
maroon with the tagline cropped off the bottom edge. It was also the only
|
|
182
|
+
asset here that nothing said how to rebuild, which is how it drifted.
|
|
183
|
+
`brand/README.md` now carries its command with the rest.
|
|
184
|
+
|
|
185
|
+
- **`og:image` states its dimensions.** LinkedIn and Slack lay a link card out
|
|
186
|
+
before the image arrives and held a grey box of the wrong shape until it
|
|
187
|
+
did.
|
|
188
|
+
|
|
189
|
+
## 0.8.3 — 2026-09-20
|
|
190
|
+
|
|
191
|
+
Documentation, brand and repository furniture. No code changed, and the
|
|
192
|
+
published package behaves exactly as 0.8.2 did.
|
|
193
|
+
|
|
194
|
+
0.8.2 was tagged and never published to npm; this goes out in its place rather
|
|
195
|
+
than publishing a tarball whose README differs from what its own tag points
|
|
196
|
+
at.
|
|
197
|
+
|
|
198
|
+
### Added
|
|
199
|
+
|
|
200
|
+
- **Two drawings that move.** The README could say the value is kept before
|
|
201
|
+
the write and could show the result afterwards, and had no way to show the
|
|
202
|
+
sequence in between — which is the whole design. `synartesis-path-loop.svg`
|
|
203
|
+
draws where the proxy sits, which the README had described in prose and
|
|
204
|
+
never drawn at all; `synartesis-undo-loop.svg` is the three phases of an
|
|
205
|
+
undo. Hand-written SVG rather than GIF: two hundred times less weight and no
|
|
206
|
+
fuzziness on a retina screen, at the cost of the brand faces, since a
|
|
207
|
+
browser gives an SVG inside an `<img>` no network and blocks the webfont
|
|
208
|
+
import. Both carry their own ground so they read in either of GitHub's
|
|
209
|
+
themes, and both answer `prefers-reduced-motion` by settling on the end
|
|
210
|
+
state rather than disappearing.
|
|
211
|
+
|
|
212
|
+
- **A wiki, and a sponsor button.** Four pages that deliberately do not repeat
|
|
213
|
+
the documentation — a policy cookbook whose every example was run through
|
|
214
|
+
the real manifest loader first, troubleshooting organised by the symptom in
|
|
215
|
+
front of you, an FAQ, and a home page saying what lives where. Discussions
|
|
216
|
+
opened with a thread saying what is actually proven. Ko-fi produces the
|
|
217
|
+
button today; `github:` is in `FUNDING.yml` and starts working the moment
|
|
218
|
+
that account is enrolled.
|
|
219
|
+
|
|
220
|
+
### Changed
|
|
221
|
+
|
|
222
|
+
- **The README's navigation is buttons rather than blue text.** The top was a
|
|
223
|
+
hand-written contents bar of eleven in-page anchors, most of which
|
|
224
|
+
duplicated GitHub's own table-of-contents button. One row of six now points
|
|
225
|
+
at the places somebody actually leaves for, with the status badges at half
|
|
226
|
+
the size underneath. Two details that had made the row look accidental: the
|
|
227
|
+
Actions badge is a different height from a flat-square row, and shields
|
|
228
|
+
picks text colour by background brightness, which had put white on the one
|
|
229
|
+
button meant to stand out.
|
|
230
|
+
|
|
231
|
+
## 0.8.2 — 2026-09-20
|
|
232
|
+
|
|
233
|
+
### Added
|
|
234
|
+
|
|
235
|
+
- **The git policy's undo is proven, not just plausible.** `check` starts the
|
|
236
|
+
server and confirms every tool the policy names exists, which proves the
|
|
237
|
+
policy loads and proves nothing about recovery: a compensation can resolve to
|
|
238
|
+
a real tool, take the arguments that tool really wants, put nothing back, and
|
|
239
|
+
report `rolled_back`. So `tests/adapter-git.test.ts` stages real changes in a
|
|
240
|
+
real repository, undoes them, and reads the index back with git itself rather
|
|
241
|
+
than through the server being tested.
|
|
242
|
+
|
|
243
|
+
It is a narrower claim than the filesystem and memory files make, and that is
|
|
244
|
+
the policy's shape rather than the test's. This server exposes nothing that
|
|
245
|
+
restores content — nearly every read it offers answers in prose written for a
|
|
246
|
+
person, so almost nothing can be inverted from a captured state — which
|
|
247
|
+
leaves exactly one compensation to prove. Proven: staging comes back off the
|
|
248
|
+
index and the working tree is untouched; a commit is held rather than
|
|
249
|
+
approximated; a branch switch goes through and undo reports it as permanent
|
|
250
|
+
instead of claiming to have reversed it.
|
|
251
|
+
|
|
252
|
+
**The overreach is pinned too.** This server's `git_reset` unstages
|
|
253
|
+
everything, not only what the call staged, so undoing the agent's `git_add`
|
|
254
|
+
also unstages work a person staged by hand. Nothing is lost — both edits are
|
|
255
|
+
still in the working tree — but it is a compensation and not an undo, and the
|
|
256
|
+
test now fails if the policy is ever changed to imply otherwise. Every guard
|
|
257
|
+
in the file was broken in turn to confirm it bites.
|
|
258
|
+
|
|
259
|
+
CI installs `uv`, because mcp-server-git is a Python package. Without it that
|
|
260
|
+
file fails rather than skipping, deliberately: a proof that quietly does not
|
|
261
|
+
run leaves the suite green and the claim unsupported.
|
|
262
|
+
|
|
263
|
+
### Changed
|
|
264
|
+
|
|
265
|
+
- The README, the user guide and the site said git was "checked only for tool
|
|
266
|
+
existence". That was true when written and is not now, so all three say what
|
|
267
|
+
is actually proven and what still is not. **github** remains untested.
|
|
268
|
+
|
|
5
269
|
## 0.8.1 — 2026-09-19
|
|
6
270
|
|
|
7
271
|
Documentation only. No code changed, and the published package behaves exactly
|
package/README.md
CHANGED
|
@@ -9,23 +9,9 @@ Synartesis sits between your MCP client and the servers it talks to, records
|
|
|
9
9
|
every tool call with the state that call replaced, and can put that state back.
|
|
10
10
|
What cannot be put back, it refuses to let an agent do unsupervised.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
[](https://www.npmjs.com/package/synartesis)
|
|
16
|
-
[](https://www.npmjs.com/package/synartesis)
|
|
17
|
-
[](https://nodejs.org)
|
|
18
|
-
[](LICENSE)
|
|
19
|
-
[](https://synartesis.online)
|
|
20
|
-
[](https://github.com/ArhaanDev24/Synartesis)
|
|
21
|
-
|
|
22
|
-
[Choose your path](#choose-your-path) · [See it in action](#see-it-in-action) ·
|
|
23
|
-
[Install](#install) · [The screen](#the-screen) ·
|
|
24
|
-
[The desktop window](#the-desktop-window) ·
|
|
25
|
-
[The four classes](#what-it-can-and-cannot-do) ·
|
|
26
|
-
[Your own server](#writing-a-policy-for-your-own-server) ·
|
|
27
|
-
[Commands](#commands) · [What it does not do](#what-it-does-not-do) ·
|
|
28
|
-
[Trust](#trust) · [Contributing](#contributing)
|
|
12
|
+
[](#install) [](#the-desktop-window) [](docs/synartesis-user-guide.md) [](https://github.com/ArhaanDev24/Synartesis/wiki) [](https://github.com/ArhaanDev24/Synartesis/discussions) [](https://github.com/ArhaanDev24/Synartesis)
|
|
13
|
+
|
|
14
|
+
[](https://github.com/ArhaanDev24/Synartesis/actions/workflows/check.yml) [](https://www.npmjs.com/package/synartesis) [](https://www.npmjs.com/package/synartesis) [](https://nodejs.org) [](LICENSE) [](https://synartesis.online)
|
|
29
15
|
|
|
30
16
|
</div>
|
|
31
17
|
|
|
@@ -38,6 +24,8 @@ It is not a sandbox: the container your agent runs in is disposable, but the
|
|
|
38
24
|
CRM row it updated over the network is not. It is not a tracing tool: a trace
|
|
39
25
|
tells you `update_customer` ran forty times, not what the values were before.
|
|
40
26
|
|
|
27
|
+

|
|
28
|
+
|
|
41
29
|
**If this is a problem you have, [a star](https://github.com/ArhaanDev24/Synartesis) helps other people find it.**
|
|
42
30
|
It is a young project, and that is most of how anybody learns it exists.
|
|
43
31
|
|
|
@@ -75,7 +63,13 @@ machine, and the proxy talks only to the servers your policy names.
|
|
|
75
63
|
|
|
76
64
|
## See it in action
|
|
77
65
|
|
|
78
|
-
|
|
66
|
+
**The order is the whole design.** The value is kept *before* the write, which
|
|
67
|
+
is what makes putting it back possible at all — and it is the one thing a
|
|
68
|
+
screenshot cannot show:
|
|
69
|
+
|
|
70
|
+

|
|
71
|
+
|
|
72
|
+
Both shots below are real output from [`./demo/filesystem-demo.sh`](demo/filesystem-demo.sh),
|
|
79
73
|
pasted rather than typeset. An agent overwrote a file and tried to move another.
|
|
80
74
|
One command puts the first back and reports that the second never happened:
|
|
81
75
|
|
|
@@ -93,7 +87,7 @@ It stops at the record that moved and exits non-zero. Anything already put back
|
|
|
93
87
|
stays put back, and it prints the three ways on: leave it, restore the resource
|
|
94
88
|
and `--replan`, or `--force` to overwrite deliberately.
|
|
95
89
|
|
|
96
|
-
[
|
|
90
|
+
**Next:** [the desktop window](#the-desktop-window) · [what it can and cannot do](#what-it-can-and-cannot-do) · [what it does not do](#what-it-does-not-do) · [the full guide](docs/synartesis-user-guide.md)
|
|
97
91
|
|
|
98
92
|
---
|
|
99
93
|
## Install
|
|
@@ -122,7 +116,7 @@ installed.**
|
|
|
122
116
|
Needs Node 22 or newer. npm ships a prebuilt SQLite binding, so no toolchain is
|
|
123
117
|
required unless you build from a clone.
|
|
124
118
|
|
|
125
|
-
[
|
|
119
|
+
**Setting it up by hand:** [the walkthrough](docs/synartesis-user-guide.md#install-it) · [Claude Desktop](docs/synartesis-user-guide.md#claude-desktop) · [Claude Code](docs/synartesis-user-guide.md#claude-code) · [any other client](docs/synartesis-user-guide.md#any-other-mcp-client)
|
|
126
120
|
|
|
127
121
|
---
|
|
128
122
|
|
|
@@ -273,7 +267,7 @@ It loads the manifest, connects to every server it names, and says which tools
|
|
|
273
267
|
your policy covers, which it does not, and where the two disagree. A policy
|
|
274
268
|
that names a tool the server no longer has is an error, not a warning.
|
|
275
269
|
|
|
276
|
-
[
|
|
270
|
+
**Going further:** [writing a manifest](docs/synartesis-user-guide.md#open-the-file) · [the four classes](docs/synartesis-user-guide.md#the-four-classes) · [checking it](docs/synartesis-user-guide.md#check-that-it-worked) · [pinning a tool's shape](#when-the-server-changes-underneath-you) · [the cookbook](https://github.com/ArhaanDev24/Synartesis/wiki/Policy-cookbook)
|
|
277
271
|
|
|
278
272
|
|
|
279
273
|
---
|
|
@@ -421,6 +415,7 @@ get it if it is not installed.
|
|
|
421
415
|
| `show <id> --live` | The same, plus what has changed in the world since |
|
|
422
416
|
| `show <id> --full` | Every argument, snapshot and inverse, nothing elided |
|
|
423
417
|
| `gates` / `approve <id>` / `deny <id>` | What is waiting, and answering it |
|
|
418
|
+
| `resolve <id> --applied\|--failed` | Settle a call whose outcome nothing established |
|
|
424
419
|
| `undo <id>` | Reverse a session, newest action first |
|
|
425
420
|
| `undo <id> --dry-run` | Plan it and change nothing |
|
|
426
421
|
| `undo <id> --replan` | Rebuild each undo from the current manifest |
|
|
@@ -458,17 +453,32 @@ cannot start a process:** see the [user guide](docs/synartesis-user-guide.md).
|
|
|
458
453
|
- **An error is not proof that nothing happened.** A timeout or a tool-level
|
|
459
454
|
error after a write leaves the outcome *unknown*, not failed, and undo will
|
|
460
455
|
not step past it. Where a pre-read exists it is consulted to settle the
|
|
461
|
-
question instead of guessing
|
|
456
|
+
question instead of guessing, and where none can settle it, `resolve` is how
|
|
457
|
+
a person who looked says which way it went -- an action resolved as applied
|
|
458
|
+
still has no inverse, so undo reports it as something it cannot put back
|
|
459
|
+
rather than pretending otherwise.
|
|
462
460
|
- **An undo is only as good as the policy that recorded it.** Inverses are
|
|
463
461
|
resolved when the call happens, so a mistake in a manifest is baked into every
|
|
464
462
|
run made under it. `undo --replan` rebuilds them from a corrected one.
|
|
465
463
|
|
|
466
|
-
|
|
464
|
+
Three bundled policies are tested against the real server, by making the change
|
|
467
465
|
and undoing it. **filesystem**: exact byte-for-byte restoration, drift refusal,
|
|
468
466
|
and absence told apart from a read that failed. **memory**: the graph is put
|
|
469
467
|
back as it was, entities the agent only tried to create are left alone, and a
|
|
470
|
-
delete of an entity is held rather than approximated.
|
|
471
|
-
|
|
468
|
+
delete of an entity is held rather than approximated.
|
|
469
|
+
|
|
470
|
+
**git** is the narrowest of the three, because the policy is: this server
|
|
471
|
+
exposes nothing that can restore content, so there is no restoration to prove.
|
|
472
|
+
What is proven is what it does claim — staging is taken back off the index and
|
|
473
|
+
the working tree is left alone; a commit is held rather than approximated; a
|
|
474
|
+
branch switch goes through and is reported as something undo cannot take back.
|
|
475
|
+
And the overreach is pinned too: this server's reset unstages *everything*, so
|
|
476
|
+
undoing the agent's `git_add` also unstages work a person staged by hand. That
|
|
477
|
+
is why it is recorded as a compensation and not an undo, and there is now a
|
|
478
|
+
test that fails if it ever gets described as one.
|
|
479
|
+
|
|
480
|
+
**github** is checked only for tool existence — its recovery guarantees are not
|
|
481
|
+
proven.
|
|
472
482
|
|
|
473
483
|
All three of filesystem, memory and git declare `provenance: live`, and that
|
|
474
484
|
word is narrower than it looks: it says the policy has met its server and the
|
|
@@ -561,11 +571,19 @@ same gate a pull request has to pass:
|
|
|
561
571
|
pnpm check
|
|
562
572
|
```
|
|
563
573
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
574
|
+
**Where each thing goes.** A bug is an [issue](https://github.com/ArhaanDev24/Synartesis/issues). A question, an
|
|
575
|
+
idea, or something you built with it is a
|
|
576
|
+
[discussion](https://github.com/ArhaanDev24/Synartesis/discussions). A policy you wrote for a server that does not
|
|
577
|
+
ship with one, a symptom you worked out, or an answer worth keeping goes in the
|
|
578
|
+
[wiki](https://github.com/ArhaanDev24/Synartesis/wiki), which anybody can edit — it holds the things that are
|
|
579
|
+
better off community-maintained, and deliberately does not repeat the
|
|
580
|
+
documentation. A vulnerability goes in none of those:
|
|
581
|
+
[`SECURITY.md`](SECURITY.md) says where.
|
|
582
|
+
|
|
583
|
+
And if it saved you an afternoon, **[star the repo](https://github.com/ArhaanDev24/Synartesis)**; it costs you a
|
|
584
|
+
click and is most of how a project this size gets found. If it saved you more
|
|
585
|
+
than that, there is [a tip jar](https://ko-fi.com/synartesis) — entirely
|
|
586
|
+
optional, and nothing here is behind it.
|
|
569
587
|
|
|
570
588
|
---
|
|
571
589
|
|
|
@@ -150,6 +150,9 @@ function usable(stream) {
|
|
|
150
150
|
function spaced(text) {
|
|
151
151
|
return Array.from(text).join(" ");
|
|
152
152
|
}
|
|
153
|
+
function counted(n, noun) {
|
|
154
|
+
return `${String(n)} ${noun}${n === 1 ? "" : "s"}`;
|
|
155
|
+
}
|
|
153
156
|
function palette(on) {
|
|
154
157
|
const paint = (codes, text) => on ? `${codes}${text}${RESET}` : text;
|
|
155
158
|
return {
|
|
@@ -200,6 +203,7 @@ var NOTHING_RECORDED_YET = [
|
|
|
200
203
|
|
|
201
204
|
// src/journal/journal.ts
|
|
202
205
|
import { chmodSync, existsSync as existsSync2, mkdirSync } from "fs";
|
|
206
|
+
import { hostname } from "os";
|
|
203
207
|
import { dirname as dirname2 } from "path";
|
|
204
208
|
import Database from "better-sqlite3";
|
|
205
209
|
import { z } from "zod";
|
|
@@ -254,6 +258,37 @@ CREATE TABLE IF NOT EXISTS actions (
|
|
|
254
258
|
UNIQUE(run_id, seq)
|
|
255
259
|
);
|
|
256
260
|
|
|
261
|
+
-- Who is currently sending an inverse for an action, so that a dead owner can
|
|
262
|
+
-- be told from a live one.
|
|
263
|
+
--
|
|
264
|
+
-- Without this, a row left in rolling_back by an undo that was killed could
|
|
265
|
+
-- never be reclaimed by anything. The claim exists to stop two undos each
|
|
266
|
+
-- sending the same inverse -- harmless for a restore, a second real change to
|
|
267
|
+
-- the world for a compensation -- and refusing was the only safe answer to
|
|
268
|
+
-- "is somebody still working on this?" when there was no way to ask. So an
|
|
269
|
+
-- action could be stranded permanently by one Ctrl-C, on the command whose
|
|
270
|
+
-- entire job is getting back. rollback.ts said as much in a comment: there is
|
|
271
|
+
-- no lease to consult, and inventing a schema for one is a separate piece of
|
|
272
|
+
-- work. This is that table.
|
|
273
|
+
--
|
|
274
|
+
-- Not a schema version bump, on the same argument the indexes above make: a
|
|
275
|
+
-- table nothing older reads changes no row and no meaning, IF NOT EXISTS makes
|
|
276
|
+
-- it idempotent, and an older build opening the same file afterwards neither
|
|
277
|
+
-- notices nor cares -- it simply goes on refusing to reclaim, which is what it
|
|
278
|
+
-- did before. A bump would refuse to open every journal already out there.
|
|
279
|
+
--
|
|
280
|
+
-- host and pid together, because a pid is only meaningful on the machine that
|
|
281
|
+
-- issued it, and journals are shared. claimed_at is for the message rather
|
|
282
|
+
-- than the decision: liveness is asked of the operating system, not inferred
|
|
283
|
+
-- from a clock, so there is no timeout to tune and no window in which a slow
|
|
284
|
+
-- undo is mistaken for a dead one.
|
|
285
|
+
CREATE TABLE IF NOT EXISTS leases (
|
|
286
|
+
action_id TEXT PRIMARY KEY REFERENCES actions(id),
|
|
287
|
+
host TEXT NOT NULL,
|
|
288
|
+
pid INTEGER NOT NULL,
|
|
289
|
+
claimed_at TEXT NOT NULL
|
|
290
|
+
);
|
|
291
|
+
|
|
257
292
|
CREATE INDEX IF NOT EXISTS actions_by_run ON actions(run_id, seq);
|
|
258
293
|
|
|
259
294
|
-- Deliberately not a schema version bump. Adding an index changes no row and
|
|
@@ -339,6 +374,43 @@ CREATE INDEX IF NOT EXISTS actions_writes ON actions(run_id, seq)
|
|
|
339
374
|
-- changes, no meaning changes, IF NOT EXISTS makes it idempotent, and an older
|
|
340
375
|
-- build opening the same file afterwards neither notices nor cares.
|
|
341
376
|
CREATE INDEX IF NOT EXISTS actions_seen ON actions(server, ts);
|
|
377
|
+
|
|
378
|
+
-- The twin of actions_undoable, and partial for the same reason. The console
|
|
379
|
+
-- asks, of every session, two questions: is there anything here an undo would
|
|
380
|
+
-- reverse, and is there anything here a person still has to decide. The first
|
|
381
|
+
-- is actions_undoable above; this is the second. It was being answered by
|
|
382
|
+
-- materialising every action in every run -- snapshots, results and inverses,
|
|
383
|
+
-- each through a zod parse -- eight times a second, to arrive at two integers
|
|
384
|
+
-- per row. Measured on forty runs of five hundred actions with two-kilobyte
|
|
385
|
+
-- snapshots, a hundred-megabyte journal: 62ms a frame, which on a 120ms tick
|
|
386
|
+
-- is half the event loop spent deciding what to draw, so keypresses queued
|
|
387
|
+
-- behind renders and the screen felt stuck.
|
|
388
|
+
--
|
|
389
|
+
-- The inverse_json test is in the predicate rather than in the query, so the
|
|
390
|
+
-- count never reaches into a row: an unrecoverable action with no inverse is
|
|
391
|
+
-- not a decision anybody can act on, and telling the two apart is exactly
|
|
392
|
+
-- what cost the frame.
|
|
393
|
+
--
|
|
394
|
+
-- Added the same way as the ones above and for the same reason: no row
|
|
395
|
+
-- changes, no meaning changes, IF NOT EXISTS makes it idempotent, and an older
|
|
396
|
+
-- build opening the same file afterwards neither notices nor cares.
|
|
397
|
+
CREATE INDEX IF NOT EXISTS actions_conflicted ON actions(run_id)
|
|
398
|
+
WHERE status = 'unrecoverable' AND inverse_json IS NOT NULL;
|
|
399
|
+
|
|
400
|
+
-- The one query the seven above left behind, and the one running most often.
|
|
401
|
+
-- recentActions asks for the newest twelve rows by time, and the watch screen
|
|
402
|
+
-- asks it every 120ms. There was no index on ts alone -- actions_gated leads
|
|
403
|
+
-- with status and actions_seen leads with server, so neither can serve a bare
|
|
404
|
+
-- ORDER BY ts -- which left a full scan plus a temporary b-tree over a table
|
|
405
|
+
-- whose rows carry the snapshots, to return twelve of them. The columns are
|
|
406
|
+
-- in the same order the query sorts by, so sqlite walks this backwards and
|
|
407
|
+
-- stops at the limit. Measured on forty runs of five hundred actions with
|
|
408
|
+
-- two-kilobyte snapshots, a hundred-megabyte journal: 7.4ms to 0.03ms.
|
|
409
|
+
--
|
|
410
|
+
-- Added the same way as the ones above and for the same reason: no row
|
|
411
|
+
-- changes, no meaning changes, IF NOT EXISTS makes it idempotent, and an older
|
|
412
|
+
-- build opening the same file afterwards neither notices nor cares.
|
|
413
|
+
CREATE INDEX IF NOT EXISTS actions_recent ON actions(ts, seq);
|
|
342
414
|
`;
|
|
343
415
|
|
|
344
416
|
// src/journal/journal.ts
|
|
@@ -351,6 +423,22 @@ var runSchema = z.object({
|
|
|
351
423
|
status: z.enum(["active", "complete", "rolled_back", "partial"])
|
|
352
424
|
});
|
|
353
425
|
var seenSchema = z.object({ server: z.string(), ts: z.string() });
|
|
426
|
+
var countedSchema = z.object({ run_id: z.string(), n: z.number() });
|
|
427
|
+
var leaseSchema = z.object({
|
|
428
|
+
action_id: z.string(),
|
|
429
|
+
host: z.string(),
|
|
430
|
+
pid: z.number(),
|
|
431
|
+
claimed_at: z.string()
|
|
432
|
+
});
|
|
433
|
+
function running(pid) {
|
|
434
|
+
try {
|
|
435
|
+
process.kill(pid, 0);
|
|
436
|
+
return true;
|
|
437
|
+
} catch (error) {
|
|
438
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
439
|
+
return code !== "ESRCH";
|
|
440
|
+
}
|
|
441
|
+
}
|
|
354
442
|
var tallySchema = z.object({
|
|
355
443
|
run_id: z.string(),
|
|
356
444
|
actions: z.number(),
|
|
@@ -394,6 +482,13 @@ function decode(value) {
|
|
|
394
482
|
function orUndefined(value) {
|
|
395
483
|
return value === null ? void 0 : value;
|
|
396
484
|
}
|
|
485
|
+
function sameCall(rows, args) {
|
|
486
|
+
if (rows.length === 0) {
|
|
487
|
+
return void 0;
|
|
488
|
+
}
|
|
489
|
+
const wanted = canonical(args ?? {});
|
|
490
|
+
return rows.find((row) => canonical(row.args) === wanted);
|
|
491
|
+
}
|
|
397
492
|
function toRun(raw) {
|
|
398
493
|
const row = runSchema.parse(raw);
|
|
399
494
|
return {
|
|
@@ -491,9 +586,14 @@ var SqliteJournal = class {
|
|
|
491
586
|
});
|
|
492
587
|
return id;
|
|
493
588
|
}
|
|
494
|
-
endRun(runId, status) {
|
|
589
|
+
endRun(runId, status, from) {
|
|
495
590
|
this.#run("endRun", () => {
|
|
496
|
-
|
|
591
|
+
if (from === void 0) {
|
|
592
|
+
this.#db.prepare("UPDATE runs SET ended_at = ?, status = ? WHERE id = ?").run((/* @__PURE__ */ new Date()).toISOString(), status, runId);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const allowed = from.map(() => "?").join(", ");
|
|
596
|
+
this.#db.prepare(`UPDATE runs SET ended_at = ?, status = ? WHERE id = ? AND status IN (${allowed})`).run((/* @__PURE__ */ new Date()).toISOString(), status, runId, ...from);
|
|
497
597
|
});
|
|
498
598
|
}
|
|
499
599
|
closeAbandonedRun(runId) {
|
|
@@ -603,6 +703,9 @@ var SqliteJournal = class {
|
|
|
603
703
|
markFailed(actionId, error) {
|
|
604
704
|
this.#settle("markFailed", actionId, "failed", ["pending", "gated", "approved"], error);
|
|
605
705
|
}
|
|
706
|
+
settleByHand(actionId, outcome, note) {
|
|
707
|
+
return this.#claim("settleByHand", actionId, outcome, ["pending"], note);
|
|
708
|
+
}
|
|
606
709
|
/**
|
|
607
710
|
* The call was interrupted, so whether the upstream applied it is genuinely
|
|
608
711
|
* unknown. The row deliberately stays `pending`: recording it as failed
|
|
@@ -632,10 +735,38 @@ var SqliteJournal = class {
|
|
|
632
735
|
const result = this.#db.prepare(
|
|
633
736
|
`UPDATE actions SET status = 'rolling_back' WHERE id = ? AND status IN (${slots})`
|
|
634
737
|
).run(actionId, ...from);
|
|
635
|
-
|
|
738
|
+
if (result.changes !== 1) {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
this.#db.prepare(
|
|
742
|
+
`INSERT INTO leases (action_id, host, pid, claimed_at) VALUES (?, ?, ?, ?)
|
|
743
|
+
ON CONFLICT(action_id) DO UPDATE SET host = excluded.host,
|
|
744
|
+
pid = excluded.pid, claimed_at = excluded.claimed_at`
|
|
745
|
+
).run(actionId, hostname(), process.pid, (/* @__PURE__ */ new Date()).toISOString());
|
|
746
|
+
return true;
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
leaseFor(actionId) {
|
|
750
|
+
return this.#run("leaseFor", () => {
|
|
751
|
+
const raw = this.#db.prepare("SELECT * FROM leases WHERE action_id = ?").get(actionId);
|
|
752
|
+
if (raw === void 0) {
|
|
753
|
+
return void 0;
|
|
754
|
+
}
|
|
755
|
+
const row = leaseSchema.parse(raw);
|
|
756
|
+
return {
|
|
757
|
+
host: row.host,
|
|
758
|
+
pid: row.pid,
|
|
759
|
+
claimedAt: row.claimed_at,
|
|
760
|
+
alive: row.host === hostname() ? running(row.pid) : void 0
|
|
761
|
+
};
|
|
636
762
|
});
|
|
637
763
|
}
|
|
764
|
+
/** Releases a claim. Safe to call when there is none. */
|
|
765
|
+
#release(actionId) {
|
|
766
|
+
this.#db.prepare("DELETE FROM leases WHERE action_id = ?").run(actionId);
|
|
767
|
+
}
|
|
638
768
|
markRolledBack(actionId) {
|
|
769
|
+
this.#release(actionId);
|
|
639
770
|
this.#settle("markRolledBack", actionId, "rolled_back", [
|
|
640
771
|
"rolling_back",
|
|
641
772
|
"applied",
|
|
@@ -649,6 +780,7 @@ var SqliteJournal = class {
|
|
|
649
780
|
* was briefly unwell, and rollback is expected to be retried (D7).
|
|
650
781
|
*/
|
|
651
782
|
markInverseRejected(actionId, error) {
|
|
783
|
+
this.#release(actionId);
|
|
652
784
|
this.#settle("markInverseRejected", actionId, "applied", ["rolling_back"], error);
|
|
653
785
|
}
|
|
654
786
|
/**
|
|
@@ -721,6 +853,12 @@ var SqliteJournal = class {
|
|
|
721
853
|
return move.immediate();
|
|
722
854
|
});
|
|
723
855
|
}
|
|
856
|
+
listPending() {
|
|
857
|
+
return this.#run(
|
|
858
|
+
"listPending",
|
|
859
|
+
() => this.#db.prepare("SELECT * FROM actions WHERE status = 'pending' ORDER BY ts").all().map(toAction)
|
|
860
|
+
);
|
|
861
|
+
}
|
|
724
862
|
listGated() {
|
|
725
863
|
return this.#run(
|
|
726
864
|
"listGated",
|
|
@@ -736,8 +874,7 @@ var SqliteJournal = class {
|
|
|
736
874
|
AND approved_at >= ?
|
|
737
875
|
ORDER BY approved_at DESC`
|
|
738
876
|
).all(query.server, query.tool, query.notBefore).map(toAction);
|
|
739
|
-
|
|
740
|
-
return rows.find((row) => canonical(row.args) === wanted);
|
|
877
|
+
return sameCall(rows, query.args);
|
|
741
878
|
});
|
|
742
879
|
}
|
|
743
880
|
findGated(query) {
|
|
@@ -747,8 +884,7 @@ var SqliteJournal = class {
|
|
|
747
884
|
WHERE run_id = ? AND server = ? AND tool = ? AND status = 'gated'
|
|
748
885
|
ORDER BY seq`
|
|
749
886
|
).all(query.runId, query.server, query.tool).map(toAction);
|
|
750
|
-
|
|
751
|
-
return rows.find((row) => canonical(row.args) === wanted);
|
|
887
|
+
return sameCall(rows, query.args);
|
|
752
888
|
});
|
|
753
889
|
}
|
|
754
890
|
findPending(query) {
|
|
@@ -758,8 +894,7 @@ var SqliteJournal = class {
|
|
|
758
894
|
WHERE run_id = ? AND server = ? AND tool = ? AND status = 'pending'
|
|
759
895
|
ORDER BY seq`
|
|
760
896
|
).all(query.runId, query.server, query.tool).map(toAction);
|
|
761
|
-
|
|
762
|
-
return rows.find((row) => canonical(row.args) === wanted);
|
|
897
|
+
return sameCall(rows, query.args);
|
|
763
898
|
});
|
|
764
899
|
}
|
|
765
900
|
getAction(actionId) {
|
|
@@ -824,6 +959,30 @@ var SqliteJournal = class {
|
|
|
824
959
|
return tally;
|
|
825
960
|
});
|
|
826
961
|
}
|
|
962
|
+
standingPerRun() {
|
|
963
|
+
return this.#run("standingPerRun", () => {
|
|
964
|
+
const standing2 = /* @__PURE__ */ new Map();
|
|
965
|
+
const count = (sql, field) => {
|
|
966
|
+
for (const raw of this.#db.prepare(sql).all()) {
|
|
967
|
+
const row = countedSchema.parse(raw);
|
|
968
|
+
const at = standing2.get(row.run_id) ?? { undoable: 0, conflicted: 0 };
|
|
969
|
+
at[field] = row.n;
|
|
970
|
+
standing2.set(row.run_id, at);
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
count(
|
|
974
|
+
`SELECT run_id, COUNT(*) AS n FROM actions INDEXED BY actions_undoable
|
|
975
|
+
WHERE status = 'applied' AND inverse_json IS NOT NULL GROUP BY run_id`,
|
|
976
|
+
"undoable"
|
|
977
|
+
);
|
|
978
|
+
count(
|
|
979
|
+
`SELECT run_id, COUNT(*) AS n FROM actions INDEXED BY actions_conflicted
|
|
980
|
+
WHERE status = 'unrecoverable' AND inverse_json IS NOT NULL GROUP BY run_id`,
|
|
981
|
+
"conflicted"
|
|
982
|
+
);
|
|
983
|
+
return standing2;
|
|
984
|
+
});
|
|
985
|
+
}
|
|
827
986
|
/**
|
|
828
987
|
* The newest run that still holds something an undo would put back.
|
|
829
988
|
*
|
|
@@ -1973,6 +2132,7 @@ export {
|
|
|
1973
2132
|
pathBinaryMatches,
|
|
1974
2133
|
findManifest,
|
|
1975
2134
|
findJournal,
|
|
2135
|
+
counted,
|
|
1976
2136
|
style,
|
|
1977
2137
|
errorStyle,
|
|
1978
2138
|
WORDMARK,
|
|
@@ -2011,4 +2171,4 @@ export {
|
|
|
2011
2171
|
observeState,
|
|
2012
2172
|
connectStdioUpstream
|
|
2013
2173
|
};
|
|
2014
|
-
//# sourceMappingURL=chunk-
|
|
2174
|
+
//# sourceMappingURL=chunk-JE7MOCZO.js.map
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
canonical,
|
|
10
10
|
cliCommand,
|
|
11
11
|
connectStdioUpstream,
|
|
12
|
+
counted,
|
|
12
13
|
createPolicyResolver,
|
|
13
14
|
createRouter,
|
|
14
15
|
describeStanding,
|
|
@@ -38,7 +39,7 @@ import {
|
|
|
38
39
|
verifyAgainstServers,
|
|
39
40
|
warnUntested,
|
|
40
41
|
wasRefused
|
|
41
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-JE7MOCZO.js";
|
|
42
43
|
import {
|
|
43
44
|
DriftConflict,
|
|
44
45
|
ManifestError,
|
|
@@ -365,6 +366,15 @@ var toolResult = z2.looseObject({ isError: z2.boolean().default(false) });
|
|
|
365
366
|
function sameState(a, b) {
|
|
366
367
|
return canonical(a) === canonical(b);
|
|
367
368
|
}
|
|
369
|
+
function held(lease) {
|
|
370
|
+
if (lease === void 0) {
|
|
371
|
+
return "No lease is recorded against it, so it was claimed by a build from before leases existed and there is no way from here to tell a live owner from a dead one. ";
|
|
372
|
+
}
|
|
373
|
+
if (lease.alive === void 0) {
|
|
374
|
+
return `It was claimed by process ${String(lease.pid)} on ${lease.host} at ${lease.claimedAt}, and this is not that machine, so whether it is still running cannot be asked from here. `;
|
|
375
|
+
}
|
|
376
|
+
return `It is held by process ${String(lease.pid)} on ${lease.host}, claimed at ${lease.claimedAt}, and that process is still running. `;
|
|
377
|
+
}
|
|
368
378
|
function classify(action, replanning, goAhead) {
|
|
369
379
|
switch (action.status) {
|
|
370
380
|
case "rolled_back":
|
|
@@ -435,6 +445,14 @@ async function rollback(options) {
|
|
|
435
445
|
let leftInPlace = false;
|
|
436
446
|
const projected = /* @__PURE__ */ new Map();
|
|
437
447
|
for (const action of inScope) {
|
|
448
|
+
if (options.interrupt?.aborted === true) {
|
|
449
|
+
halted = {
|
|
450
|
+
seq: action.seq,
|
|
451
|
+
reason: "interrupted, so this and everything before it were left as they are",
|
|
452
|
+
detail: "Nothing was half applied: the stop was taken between actions. Run undo again to carry on."
|
|
453
|
+
};
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
438
456
|
let forcedOver;
|
|
439
457
|
if (action.class === "readonly") {
|
|
440
458
|
steps.push({ ...describeStep(action), kind: "skip", reason: "readonly", verified: true });
|
|
@@ -483,9 +501,6 @@ ${seen}` : seen;
|
|
|
483
501
|
const reason = `could not read current state to check for drift: ${describe(error)}`;
|
|
484
502
|
halted = { seq: action.seq, reason, detail: "" };
|
|
485
503
|
steps.push({ ...describeStep(action), kind: "halt", reason, verified: false });
|
|
486
|
-
if (!dryRun) {
|
|
487
|
-
journal.markUnrecoverable(action.id, reason);
|
|
488
|
-
}
|
|
489
504
|
break;
|
|
490
505
|
}
|
|
491
506
|
if (sameState(current, recordedPost.data)) {
|
|
@@ -576,14 +591,20 @@ ${seen}` : seen;
|
|
|
576
591
|
if (dryRun) {
|
|
577
592
|
continue;
|
|
578
593
|
}
|
|
579
|
-
const
|
|
594
|
+
const lease = action.status === "rolling_back" ? journal.leaseFor(action.id) : void 0;
|
|
595
|
+
const abandoned = verified && lease?.alive === false;
|
|
596
|
+
const claimable = [
|
|
597
|
+
"applied",
|
|
598
|
+
...force || policies !== void 0 ? ["unrecoverable"] : [],
|
|
599
|
+
...abandoned ? ["rolling_back"] : []
|
|
600
|
+
];
|
|
580
601
|
const claimed = journal.markRollingBack(action.id, claimable);
|
|
581
602
|
if (!claimed) {
|
|
582
|
-
const reason = action.status === "rolling_back" ? "an inverse for this action was already sent and never finished
|
|
603
|
+
const reason = action.status === "rolling_back" ? "an inverse for this action was already sent and never finished, and the undo that sent it is still running" : "another undo is already working on this action";
|
|
583
604
|
halted = {
|
|
584
605
|
seq: action.seq,
|
|
585
606
|
reason,
|
|
586
|
-
detail: action.status === "rolling_back" ?
|
|
607
|
+
detail: action.status === "rolling_back" ? held(lease) + "Read the action with `show --live`: if the resource still shows the agent's write, the inverse never landed." : ""
|
|
587
608
|
};
|
|
588
609
|
steps[steps.length - 1] = {
|
|
589
610
|
...describeStep(action),
|
|
@@ -2101,36 +2122,28 @@ function runsView(journal, screen, options) {
|
|
|
2101
2122
|
];
|
|
2102
2123
|
}
|
|
2103
2124
|
const at = Math.min(screen.cursor, runs.length - 1);
|
|
2125
|
+
const tally2 = journal.tallyRuns();
|
|
2104
2126
|
return runs.map((run, index) => {
|
|
2105
|
-
const
|
|
2106
|
-
const
|
|
2127
|
+
const counts = tally2.get(run.id);
|
|
2128
|
+
const total = counts?.actions ?? 0;
|
|
2129
|
+
const held2 = counts?.waiting ?? 0;
|
|
2107
2130
|
const here = index === at && canPress(options);
|
|
2108
2131
|
const name = (run.label ?? "an agent").padEnd(24);
|
|
2109
|
-
const note =
|
|
2110
|
-
return ` ${here ? style.accent(CURSOR) : " "} ${here ? style.accent(name) : style.strong(name)} ${style.quiet(shortTime(run.startedAt).trimEnd().padEnd(13))} ${style.quiet(run.status.padEnd(11))} ${style.quiet(
|
|
2132
|
+
const note = held2 === 0 ? "" : ` ${style.accent(`${String(held2)} awaiting approval`)}`;
|
|
2133
|
+
return ` ${here ? style.accent(CURSOR) : " "} ${here ? style.accent(name) : style.strong(name)} ${style.quiet(shortTime(run.startedAt).trimEnd().padEnd(13))} ${style.quiet(run.status.padEnd(11))} ${style.quiet(counted(total, "action"))}${note}`;
|
|
2111
2134
|
});
|
|
2112
2135
|
}
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
for (const action of actions) {
|
|
2117
|
-
if (action.inverse === void 0) {
|
|
2118
|
-
continue;
|
|
2119
|
-
}
|
|
2120
|
-
if (action.status === "applied") {
|
|
2121
|
-
undoable += 1;
|
|
2122
|
-
} else if (action.status === "unrecoverable") {
|
|
2123
|
-
conflicted += 1;
|
|
2124
|
-
}
|
|
2125
|
-
}
|
|
2126
|
-
return { undoable, conflicted };
|
|
2136
|
+
var NOTHING = { undoable: 0, conflicted: 0 };
|
|
2137
|
+
function standing2(journal, runId) {
|
|
2138
|
+
return journal.standingPerRun().get(runId) ?? NOTHING;
|
|
2127
2139
|
}
|
|
2128
2140
|
function elsewhere(journal, exceptId) {
|
|
2141
|
+
const perRun = journal.standingPerRun();
|
|
2129
2142
|
for (const run of [...journal.listRuns()].reverse()) {
|
|
2130
2143
|
if (run.id === exceptId) {
|
|
2131
2144
|
continue;
|
|
2132
2145
|
}
|
|
2133
|
-
const found =
|
|
2146
|
+
const found = perRun.get(run.id) ?? NOTHING;
|
|
2134
2147
|
if (found.undoable > 0 || found.conflicted > 0) {
|
|
2135
2148
|
return run;
|
|
2136
2149
|
}
|
|
@@ -2371,7 +2384,7 @@ async function openConsole(options) {
|
|
|
2371
2384
|
if (run === void 0) {
|
|
2372
2385
|
return [];
|
|
2373
2386
|
}
|
|
2374
|
-
const here = standing2(ready
|
|
2387
|
+
const here = standing2(ready, run.id);
|
|
2375
2388
|
const id = run.id.slice(0, 8);
|
|
2376
2389
|
if (here.undoable > 0) {
|
|
2377
2390
|
return [
|
|
@@ -2592,7 +2605,7 @@ async function openConsole(options) {
|
|
|
2592
2605
|
if (ready === void 0 || run === void 0) {
|
|
2593
2606
|
return;
|
|
2594
2607
|
}
|
|
2595
|
-
const here = standing2(ready
|
|
2608
|
+
const here = standing2(ready, run.id);
|
|
2596
2609
|
if (here.undoable === 0 && here.conflicted === 0) {
|
|
2597
2610
|
say(nothingToUndo(ready, run));
|
|
2598
2611
|
return;
|
|
@@ -2614,7 +2627,7 @@ async function openConsole(options) {
|
|
|
2614
2627
|
if (ready === void 0 || run === void 0) {
|
|
2615
2628
|
return;
|
|
2616
2629
|
}
|
|
2617
|
-
const here = standing2(ready
|
|
2630
|
+
const here = standing2(ready, run.id);
|
|
2618
2631
|
if (here.undoable === 0 && here.conflicted === 0) {
|
|
2619
2632
|
say(nothingToUndo(ready, run));
|
|
2620
2633
|
return;
|
|
@@ -2788,6 +2801,7 @@ var COMMANDS = `
|
|
|
2788
2801
|
synartesis watch [--by <name>] [--journal <path>]
|
|
2789
2802
|
synartesis approve [actionId|--all] [--by <name>] [--journal <path>]
|
|
2790
2803
|
synartesis deny [actionId|--all] [--by <name>] [--reason <text>] [--journal <path>]
|
|
2804
|
+
synartesis resolve [actionId] --applied|--failed [--by <name>] [--reason <text>]
|
|
2791
2805
|
synartesis undo [runId] [--to <seq>] [--dry-run] [--replan] [--force [--yes]]
|
|
2792
2806
|
[--manifest <path>] [--journal <path>]
|
|
2793
2807
|
|
|
@@ -2803,6 +2817,14 @@ this command. Both share one journal, so either can undo what the other did.
|
|
|
2803
2817
|
close ends a run left active by a proxy that was killed; nothing guesses at
|
|
2804
2818
|
that, since several proxies can share one journal.
|
|
2805
2819
|
|
|
2820
|
+
resolve settles a call whose outcome was never established -- a proxy killed
|
|
2821
|
+
mid-write, or a server that answered too late. undo stops at one of those
|
|
2822
|
+
rather than guess, and stopping there used to be permanent: it blocked
|
|
2823
|
+
everything older in the same run. Only a person can say which way it went, so
|
|
2824
|
+
this records that they said it, and who. An action resolved as applied still
|
|
2825
|
+
has no inverse, so undo will report it as something it cannot put back and
|
|
2826
|
+
carry on with the rest.
|
|
2827
|
+
|
|
2806
2828
|
prune reclaims space. Putting a file back means keeping what was in it, so a
|
|
2807
2829
|
journal grows at several times what an agent writes and never shrinks by
|
|
2808
2830
|
itself. Nothing still active or still waiting on a person is ever pruned.
|
|
@@ -2831,6 +2853,8 @@ resource back as the run left it and --replan, or --force to overwrite.
|
|
|
2831
2853
|
--once watch prints the current state and exits
|
|
2832
2854
|
--json machine-readable output for list, show and gates
|
|
2833
2855
|
--dry-run read current state and print the plan without changing anything
|
|
2856
|
+
--applied resolve: the call did land, though nothing recorded it
|
|
2857
|
+
--failed resolve: the call never landed
|
|
2834
2858
|
--replan rebuild each undo from the current manifest, for a run recorded
|
|
2835
2859
|
under a policy that turned out to be wrong
|
|
2836
2860
|
--force undo even where the resource changed after the run. On its own it
|
|
@@ -3012,7 +3036,7 @@ async function runCheck(argv) {
|
|
|
3012
3036
|
out(` ${style.quiet("guarded ")} ${style.accent(String(gated))}`);
|
|
3013
3037
|
const pinned = Object.entries(manifest.pins ?? {});
|
|
3014
3038
|
const servers = Object.keys(manifest.servers);
|
|
3015
|
-
const unpinned = servers.filter((name) => !pinned.some(([
|
|
3039
|
+
const unpinned = servers.filter((name) => !pinned.some(([held2]) => held2 === name));
|
|
3016
3040
|
out(
|
|
3017
3041
|
` ${style.quiet("pinned ")} ${pinned.length === 0 ? style.quiet("nothing -- run `synartesis pin`") : pinned.map(([name, tools]) => `${name} (${String(Object.keys(tools).length)})`).join(", ") + (unpinned.length === 0 ? "" : style.quiet(`; not ${unpinned.join(", ")}`))}`
|
|
3018
3042
|
);
|
|
@@ -3332,6 +3356,7 @@ ${listed(matches)}`,
|
|
|
3332
3356
|
}
|
|
3333
3357
|
var RUN = { one: "run", many: "runs" };
|
|
3334
3358
|
var WAITING = { one: "action awaiting approval", many: "actions awaiting approval" };
|
|
3359
|
+
var UNSETTLED = { one: "action whose outcome is unknown", many: "actions whose outcome is unknown" };
|
|
3335
3360
|
process.stdout.on("error", (error) => {
|
|
3336
3361
|
if (error.code === "EPIPE") {
|
|
3337
3362
|
process.exit(0);
|
|
@@ -3408,12 +3433,12 @@ function laid(text, width) {
|
|
|
3408
3433
|
function runList(journal, asJson, journalPath) {
|
|
3409
3434
|
const runs = [...journal.listRuns()].reverse();
|
|
3410
3435
|
const tally2 = journal.tallyRuns();
|
|
3411
|
-
const
|
|
3436
|
+
const counted2 = (id) => tally2.get(id) ?? { actions: 0, unknown: 0, waiting: 0, applied: 0 };
|
|
3412
3437
|
if (asJson) {
|
|
3413
3438
|
out(
|
|
3414
3439
|
JSON.stringify(
|
|
3415
3440
|
runs.map((run) => {
|
|
3416
|
-
const actions =
|
|
3441
|
+
const actions = counted2(run.id).actions;
|
|
3417
3442
|
return { ...run, actions, actionCount: actions };
|
|
3418
3443
|
})
|
|
3419
3444
|
)
|
|
@@ -3435,7 +3460,7 @@ function runList(journal, asJson, journalPath) {
|
|
|
3435
3460
|
)
|
|
3436
3461
|
);
|
|
3437
3462
|
for (const run of runs) {
|
|
3438
|
-
const actions =
|
|
3463
|
+
const actions = counted2(run.id);
|
|
3439
3464
|
const { unknown, waiting } = actions;
|
|
3440
3465
|
const notes = [
|
|
3441
3466
|
unknown === 0 ? "" : `${String(unknown)} of unknown outcome`,
|
|
@@ -3658,7 +3683,7 @@ function summarise2(actions) {
|
|
|
3658
3683
|
}
|
|
3659
3684
|
const parts = [...counts].sort(([a], [b]) => a < b ? -1 : 1).map(([k, v]) => `${String(v)} ${k}`);
|
|
3660
3685
|
const undoable = actions.filter((a) => a.inverse !== void 0).length;
|
|
3661
|
-
return `${
|
|
3686
|
+
return `${counted(actions.length, "action")}: ${parts.join(", ")} | ${String(undoable)} with a recorded undo`;
|
|
3662
3687
|
}
|
|
3663
3688
|
var journalArg = "";
|
|
3664
3689
|
var manifestArg = "";
|
|
@@ -3718,13 +3743,13 @@ function runPrune(argv, journal, journalPath) {
|
|
|
3718
3743
|
for (const run of stale) {
|
|
3719
3744
|
actions += run.actions;
|
|
3720
3745
|
out(
|
|
3721
|
-
` ${style.strong((run.label ?? "an agent").padEnd(24))} ${style.quiet(run.at.slice(0, 19).replace("T", " "))} ${style.quiet(run.status.padEnd(11))} ${style.quiet(
|
|
3746
|
+
` ${style.strong((run.label ?? "an agent").padEnd(24))} ${style.quiet(run.at.slice(0, 19).replace("T", " "))} ${style.quiet(run.status.padEnd(11))} ${style.quiet(counted(run.actions, "action"))}`
|
|
3722
3747
|
);
|
|
3723
3748
|
}
|
|
3724
3749
|
out("");
|
|
3725
3750
|
if (planned) {
|
|
3726
3751
|
out(
|
|
3727
|
-
` ${style.accent(
|
|
3752
|
+
` ${style.accent(counted(stale.length, "run"))} ${style.quiet(`and ${counted(actions, "action")} would go. Nothing was changed.`)}`
|
|
3728
3753
|
);
|
|
3729
3754
|
out("");
|
|
3730
3755
|
return 0;
|
|
@@ -3732,12 +3757,58 @@ function runPrune(argv, journal, journalPath) {
|
|
|
3732
3757
|
const removed = journal.deleteRuns(stale.map((run) => run.id));
|
|
3733
3758
|
journal.vacuum();
|
|
3734
3759
|
out(
|
|
3735
|
-
` ${style.accent(
|
|
3760
|
+
` ${style.accent(counted(removed.runs, "run"))} ${style.quiet(`and ${counted(removed.actions, "action")} removed.`)}`
|
|
3736
3761
|
);
|
|
3737
3762
|
out(` ${style.quiet(`Journal ${sizeBefore} \u2192 ${sizeOf(journalPath)}.`)}`);
|
|
3738
3763
|
out("");
|
|
3739
3764
|
return 0;
|
|
3740
3765
|
}
|
|
3766
|
+
function runResolve(argv, journal) {
|
|
3767
|
+
const applied = argv.includes("--applied");
|
|
3768
|
+
const failed = argv.includes("--failed");
|
|
3769
|
+
if (applied === failed) {
|
|
3770
|
+
throw new UsageError(
|
|
3771
|
+
"resolve needs exactly one of --applied (the call landed) or --failed (it never did)"
|
|
3772
|
+
);
|
|
3773
|
+
}
|
|
3774
|
+
const by = flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown";
|
|
3775
|
+
const why = flag(argv, "--reason");
|
|
3776
|
+
const given = positional(argv)[1];
|
|
3777
|
+
if (given !== void 0) {
|
|
3778
|
+
const already = journal.getAction(given);
|
|
3779
|
+
if (already !== void 0 && already.status !== "pending") {
|
|
3780
|
+
process.stderr.write(
|
|
3781
|
+
`synartesis: ${given} is not waiting to be resolved (it is ${labelFor(already)})
|
|
3782
|
+
`
|
|
3783
|
+
);
|
|
3784
|
+
return 1;
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
const action = pick(journal.listPending(), given, UNSETTLED);
|
|
3788
|
+
const outcome = applied ? "applied" : "failed";
|
|
3789
|
+
const note = `resolved as ${outcome} by ${by}` + (why === void 0 ? "" : `: ${why}`);
|
|
3790
|
+
if (!journal.settleByHand(action.id, outcome, note)) {
|
|
3791
|
+
const now = journal.getAction(action.id);
|
|
3792
|
+
process.stderr.write(
|
|
3793
|
+
`synartesis: ${action.id} was settled by the proxy first (it is ${now?.status ?? "gone"})
|
|
3794
|
+
`
|
|
3795
|
+
);
|
|
3796
|
+
return 1;
|
|
3797
|
+
}
|
|
3798
|
+
out("");
|
|
3799
|
+
out(
|
|
3800
|
+
` ${style.accent(outcome)} ${style.strong(`${action.server}.${action.tool}`)} ${style.quiet(action.id.slice(0, 8))}`
|
|
3801
|
+
);
|
|
3802
|
+
out("");
|
|
3803
|
+
out(
|
|
3804
|
+
` ${style.quiet(
|
|
3805
|
+
applied ? "Undo will now pass it, and report it as something it cannot put back:" : "Undo will now pass it, as a call that never applied:"
|
|
3806
|
+
)}`
|
|
3807
|
+
);
|
|
3808
|
+
out(` ${style.strong(`${cliCommand()} undo ${action.runId.slice(0, 8)}`)}`);
|
|
3809
|
+
out("");
|
|
3810
|
+
return 0;
|
|
3811
|
+
}
|
|
3741
3812
|
function runClose(argv, journal) {
|
|
3742
3813
|
const all = journal.listRuns();
|
|
3743
3814
|
const given = positional(argv)[1];
|
|
@@ -3903,13 +3974,15 @@ function report(result, alreadyForcing = false, as = "") {
|
|
|
3903
3974
|
const self = cliCommand();
|
|
3904
3975
|
const id = result.runId.slice(0, 8);
|
|
3905
3976
|
out("");
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
)
|
|
3977
|
+
const ways = [
|
|
3978
|
+
["keep the change, drop the undo:", style.quiet("nothing to do")],
|
|
3979
|
+
["put the resource back, then:", style.strong(`${self} undo ${id} --replan`)],
|
|
3980
|
+
["undo anyway, losing the change:", style.strong(`${self} undo ${id} --force`)]
|
|
3981
|
+
];
|
|
3982
|
+
const column = Math.max(...ways.map(([label]) => label.length));
|
|
3983
|
+
for (const [label, command] of ways) {
|
|
3984
|
+
out(` ${style.quiet(label.padEnd(column))} ${command}`);
|
|
3985
|
+
}
|
|
3913
3986
|
}
|
|
3914
3987
|
}
|
|
3915
3988
|
const permanent = result.steps.filter((step) => step.kind === "permanent");
|
|
@@ -3970,6 +4043,22 @@ async function withUpstreams(manifestPath, use, only) {
|
|
|
3970
4043
|
}
|
|
3971
4044
|
}
|
|
3972
4045
|
}
|
|
4046
|
+
function stopOnInterrupt() {
|
|
4047
|
+
const stopping = new AbortController();
|
|
4048
|
+
let asked = false;
|
|
4049
|
+
for (const sign of ["SIGINT", "SIGTERM"]) {
|
|
4050
|
+
process.once(sign, () => {
|
|
4051
|
+
if (asked) {
|
|
4052
|
+
process.exit(130);
|
|
4053
|
+
}
|
|
4054
|
+
asked = true;
|
|
4055
|
+
stopping.abort();
|
|
4056
|
+
out("");
|
|
4057
|
+
out(` ${style.quiet("stopping after this action; press again to stop now")}`);
|
|
4058
|
+
});
|
|
4059
|
+
}
|
|
4060
|
+
return stopping.signal;
|
|
4061
|
+
}
|
|
3973
4062
|
function serversUsedBy(journal, runId) {
|
|
3974
4063
|
return new Set(journal.getActions(runId).map((action) => action.server));
|
|
3975
4064
|
}
|
|
@@ -3983,7 +4072,8 @@ async function performUndo(manifestPath, journal, runId, options) {
|
|
|
3983
4072
|
...options.toSeq === void 0 ? {} : { toSeq: options.toSeq },
|
|
3984
4073
|
...options.replan === true ? { replanWith: manifest } : {},
|
|
3985
4074
|
dryRun: options.dryRun,
|
|
3986
|
-
...options.force === true ? { force: true } : {}
|
|
4075
|
+
...options.force === true ? { force: true } : {},
|
|
4076
|
+
interrupt: stopOnInterrupt()
|
|
3987
4077
|
}),
|
|
3988
4078
|
// A replan re-resolves inverses from the current policy, which may name a
|
|
3989
4079
|
// server this run never used; everything else needs only what it touched.
|
|
@@ -3994,11 +4084,20 @@ async function runUndo(argv, journal) {
|
|
|
3994
4084
|
const rawTo = flag(argv, "--to");
|
|
3995
4085
|
const toSeq = rawTo === void 0 ? void 0 : Number(rawTo);
|
|
3996
4086
|
if (toSeq !== void 0 && (!Number.isInteger(toSeq) || toSeq < 1)) {
|
|
3997
|
-
throw new UsageError("--to needs a positive whole number");
|
|
4087
|
+
throw new UsageError("--to needs a positive whole number", false);
|
|
3998
4088
|
}
|
|
3999
4089
|
const given = positional(argv)[1];
|
|
4000
4090
|
const chosen = pick([...journal.listRuns()].reverse(), given, RUN, true);
|
|
4001
4091
|
const runId = chosen.id;
|
|
4092
|
+
if (journal.getRun(runId)?.status === "active" && !argv.includes("--yes") && !argv.includes("--dry-run")) {
|
|
4093
|
+
throw new UsageError(
|
|
4094
|
+
`${runId.slice(0, 8)} has not ended, so an agent may still be writing to it.
|
|
4095
|
+
See what an undo would do: ${cliCommand()} undo ${runId.slice(0, 8)} --dry-run
|
|
4096
|
+
If its proxy is gone: ${cliCommand()} close ${runId.slice(0, 8)}
|
|
4097
|
+
Undo it anyway: ${cliCommand()} undo ${runId.slice(0, 8)} --yes`,
|
|
4098
|
+
false
|
|
4099
|
+
);
|
|
4100
|
+
}
|
|
4002
4101
|
if (given === void 0) {
|
|
4003
4102
|
const actions = journal.getActions(runId);
|
|
4004
4103
|
const left = actions.filter(
|
|
@@ -4039,7 +4138,8 @@ async function runUndo(argv, journal) {
|
|
|
4039
4138
|
const highest = journal.getActions(runId).reduce((top, action) => Math.max(top, action.seq), 0);
|
|
4040
4139
|
if (toSeq > highest) {
|
|
4041
4140
|
throw new UsageError(
|
|
4042
|
-
`--to ${String(toSeq)} is past the end of this run, which goes up to ${String(highest)}
|
|
4141
|
+
`--to ${String(toSeq)} is past the end of this run, which goes up to ${String(highest)}`,
|
|
4142
|
+
false
|
|
4043
4143
|
);
|
|
4044
4144
|
}
|
|
4045
4145
|
}
|
|
@@ -4117,6 +4217,7 @@ var KNOWN_COMMANDS = [
|
|
|
4117
4217
|
"watch",
|
|
4118
4218
|
"approve",
|
|
4119
4219
|
"deny",
|
|
4220
|
+
"resolve",
|
|
4120
4221
|
"undo",
|
|
4121
4222
|
"help",
|
|
4122
4223
|
"version"
|
|
@@ -4140,6 +4241,9 @@ var FLAGS = /* @__PURE__ */ new Set([
|
|
|
4140
4241
|
"--dry-run",
|
|
4141
4242
|
"--replan",
|
|
4142
4243
|
"--reason",
|
|
4244
|
+
// resolve: which way the unknown outcome actually went.
|
|
4245
|
+
"--applied",
|
|
4246
|
+
"--failed",
|
|
4143
4247
|
"--force",
|
|
4144
4248
|
"--yes",
|
|
4145
4249
|
"--older-than",
|
|
@@ -4313,6 +4417,8 @@ ${COMMANDS}`);
|
|
|
4313
4417
|
return runPrune(argv, journal, journalPath);
|
|
4314
4418
|
case "gates":
|
|
4315
4419
|
return runGates(journal, asJson);
|
|
4420
|
+
case "resolve":
|
|
4421
|
+
return runResolve(argv, journal);
|
|
4316
4422
|
case "approve":
|
|
4317
4423
|
return runDecision(argv, journal, true);
|
|
4318
4424
|
case "deny":
|
package/dist/proxy.js
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
verifyAgainstServers,
|
|
26
26
|
warnUntested,
|
|
27
27
|
withIdempotencyKey
|
|
28
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-JE7MOCZO.js";
|
|
29
29
|
import {
|
|
30
30
|
SnapshotError,
|
|
31
31
|
UpstreamError,
|
|
@@ -256,12 +256,19 @@ import { z } from "zod";
|
|
|
256
256
|
|
|
257
257
|
// src/gate/heuristic.ts
|
|
258
258
|
var READ_ONLY = /^(select|with|show|explain|describe|desc|values|table)\b/;
|
|
259
|
+
var WRITES = /\b(insert|update|delete|merge|upsert|replace|truncate|drop|alter|create|grant|revoke|vacuum|attach|detach|reindex|call|do|copy|lock|pragma|into)\b/;
|
|
260
|
+
function code(text) {
|
|
261
|
+
return text.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ").replace(/'(?:[^']|'')*'/g, " ").replace(/"(?:[^"]|"")*"/g, " ").trim();
|
|
262
|
+
}
|
|
259
263
|
function isReadOnlyStatement(text) {
|
|
260
|
-
const stripped = text
|
|
264
|
+
const stripped = code(text);
|
|
261
265
|
if (!READ_ONLY.test(stripped.toLowerCase())) {
|
|
262
266
|
return false;
|
|
263
267
|
}
|
|
264
|
-
|
|
268
|
+
if (stripped.replace(/;\s*$/, "").indexOf(";") !== -1) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
return !WRITES.test(stripped.toLowerCase());
|
|
265
272
|
}
|
|
266
273
|
function shouldGateOnWrite(args) {
|
|
267
274
|
if (typeof args !== "object" || args === null) {
|
|
@@ -344,8 +351,8 @@ function neverDispatched(error) {
|
|
|
344
351
|
if (message.includes("Not connected")) {
|
|
345
352
|
return true;
|
|
346
353
|
}
|
|
347
|
-
const
|
|
348
|
-
return
|
|
354
|
+
const code2 = isRecord(error) ? error["code"] : void 0;
|
|
355
|
+
return code2 === ErrorCode.MethodNotFound || code2 === ErrorCode.InvalidParams || code2 === ErrorCode.InvalidRequest || code2 === ErrorCode.ParseError;
|
|
349
356
|
}
|
|
350
357
|
var SYNARTESIS_INSTRUCTIONS = [
|
|
351
358
|
"These tools are guarded by Synartesis, which records every change so it can be undone later.",
|
|
@@ -1078,7 +1085,7 @@ function createProxyServer(options) {
|
|
|
1078
1085
|
server.onclose = () => {
|
|
1079
1086
|
connected = false;
|
|
1080
1087
|
if (runId !== void 0) {
|
|
1081
|
-
journal.endRun(runId, "complete");
|
|
1088
|
+
journal.endRun(runId, "complete", ["active"]);
|
|
1082
1089
|
runId = void 0;
|
|
1083
1090
|
}
|
|
1084
1091
|
previousOnClose?.();
|
|
@@ -1259,7 +1266,7 @@ async function main() {
|
|
|
1259
1266
|
}
|
|
1260
1267
|
const proxy = build();
|
|
1261
1268
|
let shuttingDown = false;
|
|
1262
|
-
const shutdown = (
|
|
1269
|
+
const shutdown = (code2) => {
|
|
1263
1270
|
if (shuttingDown) {
|
|
1264
1271
|
return;
|
|
1265
1272
|
}
|
|
@@ -1274,7 +1281,7 @@ async function main() {
|
|
|
1274
1281
|
await upstream.close();
|
|
1275
1282
|
}
|
|
1276
1283
|
journal.close();
|
|
1277
|
-
process.exit(
|
|
1284
|
+
process.exit(code2);
|
|
1278
1285
|
})();
|
|
1279
1286
|
};
|
|
1280
1287
|
process.on("SIGINT", () => {
|
package/manifests/git.yaml
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Policy for mcp-server-git, the official git server, run through uvx.
|
|
2
2
|
#
|
|
3
|
-
# Verified against the real server on a real repository
|
|
3
|
+
# Verified against the real server on a real repository, and round-tripped by
|
|
4
|
+
# tests/adapter-git.test.ts: the one compensation below is run and its effect
|
|
5
|
+
# read back with git itself, and both gates are checked from both ends. Change
|
|
6
|
+
# a class or a gate here and that file fails.
|
|
4
7
|
#
|
|
5
8
|
# The finding worth carrying away from writing this one: nearly every read this
|
|
6
9
|
# server offers answers in prose meant for a person -- git_status says "On
|