synartesis 0.8.4 → 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 CHANGED
@@ -2,6 +2,125 @@
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
+
5
124
  ## 0.8.4 — 2026-09-21
6
125
 
7
126
  Bugs, all of them found by using the thing rather than by a test going red:
package/README.md CHANGED
@@ -415,6 +415,7 @@ get it if it is not installed.
415
415
  | `show <id> --live` | The same, plus what has changed in the world since |
416
416
  | `show <id> --full` | Every argument, snapshot and inverse, nothing elided |
417
417
  | `gates` / `approve <id>` / `deny <id>` | What is waiting, and answering it |
418
+ | `resolve <id> --applied\|--failed` | Settle a call whose outcome nothing established |
418
419
  | `undo <id>` | Reverse a session, newest action first |
419
420
  | `undo <id> --dry-run` | Plan it and change nothing |
420
421
  | `undo <id> --replan` | Rebuild each undo from the current manifest |
@@ -452,7 +453,10 @@ cannot start a process:** see the [user guide](docs/synartesis-user-guide.md).
452
453
  - **An error is not proof that nothing happened.** A timeout or a tool-level
453
454
  error after a write leaves the outcome *unknown*, not failed, and undo will
454
455
  not step past it. Where a pre-read exists it is consulted to settle the
455
- 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.
456
460
  - **An undo is only as good as the policy that recorded it.** Inverses are
457
461
  resolved when the call happens, so a mistake in a manifest is baked into every
458
462
  run made under it. `undo --replan` rebuilds them from a corrected one.
@@ -203,6 +203,7 @@ var NOTHING_RECORDED_YET = [
203
203
 
204
204
  // src/journal/journal.ts
205
205
  import { chmodSync, existsSync as existsSync2, mkdirSync } from "fs";
206
+ import { hostname } from "os";
206
207
  import { dirname as dirname2 } from "path";
207
208
  import Database from "better-sqlite3";
208
209
  import { z } from "zod";
@@ -257,6 +258,37 @@ CREATE TABLE IF NOT EXISTS actions (
257
258
  UNIQUE(run_id, seq)
258
259
  );
259
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
+
260
292
  CREATE INDEX IF NOT EXISTS actions_by_run ON actions(run_id, seq);
261
293
 
262
294
  -- Deliberately not a schema version bump. Adding an index changes no row and
@@ -342,6 +374,43 @@ CREATE INDEX IF NOT EXISTS actions_writes ON actions(run_id, seq)
342
374
  -- changes, no meaning changes, IF NOT EXISTS makes it idempotent, and an older
343
375
  -- build opening the same file afterwards neither notices nor cares.
344
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);
345
414
  `;
346
415
 
347
416
  // src/journal/journal.ts
@@ -354,6 +423,22 @@ var runSchema = z.object({
354
423
  status: z.enum(["active", "complete", "rolled_back", "partial"])
355
424
  });
356
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
+ }
357
442
  var tallySchema = z.object({
358
443
  run_id: z.string(),
359
444
  actions: z.number(),
@@ -397,6 +482,13 @@ function decode(value) {
397
482
  function orUndefined(value) {
398
483
  return value === null ? void 0 : value;
399
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
+ }
400
492
  function toRun(raw) {
401
493
  const row = runSchema.parse(raw);
402
494
  return {
@@ -494,9 +586,14 @@ var SqliteJournal = class {
494
586
  });
495
587
  return id;
496
588
  }
497
- endRun(runId, status) {
589
+ endRun(runId, status, from) {
498
590
  this.#run("endRun", () => {
499
- this.#db.prepare("UPDATE runs SET ended_at = ?, status = ? WHERE id = ?").run((/* @__PURE__ */ new Date()).toISOString(), status, runId);
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);
500
597
  });
501
598
  }
502
599
  closeAbandonedRun(runId) {
@@ -606,6 +703,9 @@ var SqliteJournal = class {
606
703
  markFailed(actionId, error) {
607
704
  this.#settle("markFailed", actionId, "failed", ["pending", "gated", "approved"], error);
608
705
  }
706
+ settleByHand(actionId, outcome, note) {
707
+ return this.#claim("settleByHand", actionId, outcome, ["pending"], note);
708
+ }
609
709
  /**
610
710
  * The call was interrupted, so whether the upstream applied it is genuinely
611
711
  * unknown. The row deliberately stays `pending`: recording it as failed
@@ -635,10 +735,38 @@ var SqliteJournal = class {
635
735
  const result = this.#db.prepare(
636
736
  `UPDATE actions SET status = 'rolling_back' WHERE id = ? AND status IN (${slots})`
637
737
  ).run(actionId, ...from);
638
- return result.changes === 1;
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;
639
747
  });
640
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
+ };
762
+ });
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
+ }
641
768
  markRolledBack(actionId) {
769
+ this.#release(actionId);
642
770
  this.#settle("markRolledBack", actionId, "rolled_back", [
643
771
  "rolling_back",
644
772
  "applied",
@@ -652,6 +780,7 @@ var SqliteJournal = class {
652
780
  * was briefly unwell, and rollback is expected to be retried (D7).
653
781
  */
654
782
  markInverseRejected(actionId, error) {
783
+ this.#release(actionId);
655
784
  this.#settle("markInverseRejected", actionId, "applied", ["rolling_back"], error);
656
785
  }
657
786
  /**
@@ -724,6 +853,12 @@ var SqliteJournal = class {
724
853
  return move.immediate();
725
854
  });
726
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
+ }
727
862
  listGated() {
728
863
  return this.#run(
729
864
  "listGated",
@@ -739,8 +874,7 @@ var SqliteJournal = class {
739
874
  AND approved_at >= ?
740
875
  ORDER BY approved_at DESC`
741
876
  ).all(query.server, query.tool, query.notBefore).map(toAction);
742
- const wanted = canonical(query.args ?? {});
743
- return rows.find((row) => canonical(row.args) === wanted);
877
+ return sameCall(rows, query.args);
744
878
  });
745
879
  }
746
880
  findGated(query) {
@@ -750,8 +884,7 @@ var SqliteJournal = class {
750
884
  WHERE run_id = ? AND server = ? AND tool = ? AND status = 'gated'
751
885
  ORDER BY seq`
752
886
  ).all(query.runId, query.server, query.tool).map(toAction);
753
- const wanted = canonical(query.args ?? {});
754
- return rows.find((row) => canonical(row.args) === wanted);
887
+ return sameCall(rows, query.args);
755
888
  });
756
889
  }
757
890
  findPending(query) {
@@ -761,8 +894,7 @@ var SqliteJournal = class {
761
894
  WHERE run_id = ? AND server = ? AND tool = ? AND status = 'pending'
762
895
  ORDER BY seq`
763
896
  ).all(query.runId, query.server, query.tool).map(toAction);
764
- const wanted = canonical(query.args ?? {});
765
- return rows.find((row) => canonical(row.args) === wanted);
897
+ return sameCall(rows, query.args);
766
898
  });
767
899
  }
768
900
  getAction(actionId) {
@@ -827,6 +959,30 @@ var SqliteJournal = class {
827
959
  return tally;
828
960
  });
829
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
+ }
830
986
  /**
831
987
  * The newest run that still holds something an undo would put back.
832
988
  *
@@ -2015,4 +2171,4 @@ export {
2015
2171
  observeState,
2016
2172
  connectStdioUpstream
2017
2173
  };
2018
- //# sourceMappingURL=chunk-FIOKOIT6.js.map
2174
+ //# sourceMappingURL=chunk-JE7MOCZO.js.map
package/dist/cli.js CHANGED
@@ -39,7 +39,7 @@ import {
39
39
  verifyAgainstServers,
40
40
  warnUntested,
41
41
  wasRefused
42
- } from "./chunk-FIOKOIT6.js";
42
+ } from "./chunk-JE7MOCZO.js";
43
43
  import {
44
44
  DriftConflict,
45
45
  ManifestError,
@@ -366,6 +366,15 @@ var toolResult = z2.looseObject({ isError: z2.boolean().default(false) });
366
366
  function sameState(a, b) {
367
367
  return canonical(a) === canonical(b);
368
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
+ }
369
378
  function classify(action, replanning, goAhead) {
370
379
  switch (action.status) {
371
380
  case "rolled_back":
@@ -436,6 +445,14 @@ async function rollback(options) {
436
445
  let leftInPlace = false;
437
446
  const projected = /* @__PURE__ */ new Map();
438
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
+ }
439
456
  let forcedOver;
440
457
  if (action.class === "readonly") {
441
458
  steps.push({ ...describeStep(action), kind: "skip", reason: "readonly", verified: true });
@@ -574,14 +591,20 @@ ${seen}` : seen;
574
591
  if (dryRun) {
575
592
  continue;
576
593
  }
577
- const claimable = force || policies !== void 0 ? ["applied", "unrecoverable"] : ["applied"];
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
+ ];
578
601
  const claimed = journal.markRollingBack(action.id, claimable);
579
602
  if (!claimed) {
580
- const reason = action.status === "rolling_back" ? "an inverse for this action was already sent and never finished; whether another undo still holds it cannot be told from here" : "another undo is already working on this action";
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";
581
604
  halted = {
582
605
  seq: action.seq,
583
606
  reason,
584
- detail: action.status === "rolling_back" ? "Nothing here can tell a live owner from a dead one, and this build has no lease to consult. Read the action with `show --live`: if the resource still shows the agent's write, the inverse never landed." : ""
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." : ""
585
608
  };
586
609
  steps[steps.length - 1] = {
587
610
  ...describeStep(action),
@@ -2099,36 +2122,28 @@ function runsView(journal, screen, options) {
2099
2122
  ];
2100
2123
  }
2101
2124
  const at = Math.min(screen.cursor, runs.length - 1);
2125
+ const tally2 = journal.tallyRuns();
2102
2126
  return runs.map((run, index) => {
2103
- const actions = journal.getActions(run.id);
2104
- const held = actions.filter((action) => action.status === "gated").length;
2127
+ const counts = tally2.get(run.id);
2128
+ const total = counts?.actions ?? 0;
2129
+ const held2 = counts?.waiting ?? 0;
2105
2130
  const here = index === at && canPress(options);
2106
2131
  const name = (run.label ?? "an agent").padEnd(24);
2107
- const note = held === 0 ? "" : ` ${style.accent(`${String(held)} awaiting approval`)}`;
2108
- 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(actions.length, "action"))}${note}`;
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}`;
2109
2134
  });
2110
2135
  }
2111
- function standing2(actions) {
2112
- let undoable = 0;
2113
- let conflicted = 0;
2114
- for (const action of actions) {
2115
- if (action.inverse === void 0) {
2116
- continue;
2117
- }
2118
- if (action.status === "applied") {
2119
- undoable += 1;
2120
- } else if (action.status === "unrecoverable") {
2121
- conflicted += 1;
2122
- }
2123
- }
2124
- return { undoable, conflicted };
2136
+ var NOTHING = { undoable: 0, conflicted: 0 };
2137
+ function standing2(journal, runId) {
2138
+ return journal.standingPerRun().get(runId) ?? NOTHING;
2125
2139
  }
2126
2140
  function elsewhere(journal, exceptId) {
2141
+ const perRun = journal.standingPerRun();
2127
2142
  for (const run of [...journal.listRuns()].reverse()) {
2128
2143
  if (run.id === exceptId) {
2129
2144
  continue;
2130
2145
  }
2131
- const found = standing2(journal.getActions(run.id));
2146
+ const found = perRun.get(run.id) ?? NOTHING;
2132
2147
  if (found.undoable > 0 || found.conflicted > 0) {
2133
2148
  return run;
2134
2149
  }
@@ -2369,7 +2384,7 @@ async function openConsole(options) {
2369
2384
  if (run === void 0) {
2370
2385
  return [];
2371
2386
  }
2372
- const here = standing2(ready.getActions(run.id));
2387
+ const here = standing2(ready, run.id);
2373
2388
  const id = run.id.slice(0, 8);
2374
2389
  if (here.undoable > 0) {
2375
2390
  return [
@@ -2590,7 +2605,7 @@ async function openConsole(options) {
2590
2605
  if (ready === void 0 || run === void 0) {
2591
2606
  return;
2592
2607
  }
2593
- const here = standing2(ready.getActions(run.id));
2608
+ const here = standing2(ready, run.id);
2594
2609
  if (here.undoable === 0 && here.conflicted === 0) {
2595
2610
  say(nothingToUndo(ready, run));
2596
2611
  return;
@@ -2612,7 +2627,7 @@ async function openConsole(options) {
2612
2627
  if (ready === void 0 || run === void 0) {
2613
2628
  return;
2614
2629
  }
2615
- const here = standing2(ready.getActions(run.id));
2630
+ const here = standing2(ready, run.id);
2616
2631
  if (here.undoable === 0 && here.conflicted === 0) {
2617
2632
  say(nothingToUndo(ready, run));
2618
2633
  return;
@@ -2786,6 +2801,7 @@ var COMMANDS = `
2786
2801
  synartesis watch [--by <name>] [--journal <path>]
2787
2802
  synartesis approve [actionId|--all] [--by <name>] [--journal <path>]
2788
2803
  synartesis deny [actionId|--all] [--by <name>] [--reason <text>] [--journal <path>]
2804
+ synartesis resolve [actionId] --applied|--failed [--by <name>] [--reason <text>]
2789
2805
  synartesis undo [runId] [--to <seq>] [--dry-run] [--replan] [--force [--yes]]
2790
2806
  [--manifest <path>] [--journal <path>]
2791
2807
 
@@ -2801,6 +2817,14 @@ this command. Both share one journal, so either can undo what the other did.
2801
2817
  close ends a run left active by a proxy that was killed; nothing guesses at
2802
2818
  that, since several proxies can share one journal.
2803
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
+
2804
2828
  prune reclaims space. Putting a file back means keeping what was in it, so a
2805
2829
  journal grows at several times what an agent writes and never shrinks by
2806
2830
  itself. Nothing still active or still waiting on a person is ever pruned.
@@ -2829,6 +2853,8 @@ resource back as the run left it and --replan, or --force to overwrite.
2829
2853
  --once watch prints the current state and exits
2830
2854
  --json machine-readable output for list, show and gates
2831
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
2832
2858
  --replan rebuild each undo from the current manifest, for a run recorded
2833
2859
  under a policy that turned out to be wrong
2834
2860
  --force undo even where the resource changed after the run. On its own it
@@ -3010,7 +3036,7 @@ async function runCheck(argv) {
3010
3036
  out(` ${style.quiet("guarded ")} ${style.accent(String(gated))}`);
3011
3037
  const pinned = Object.entries(manifest.pins ?? {});
3012
3038
  const servers = Object.keys(manifest.servers);
3013
- const unpinned = servers.filter((name) => !pinned.some(([held]) => held === name));
3039
+ const unpinned = servers.filter((name) => !pinned.some(([held2]) => held2 === name));
3014
3040
  out(
3015
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(", ")}`))}`
3016
3042
  );
@@ -3330,6 +3356,7 @@ ${listed(matches)}`,
3330
3356
  }
3331
3357
  var RUN = { one: "run", many: "runs" };
3332
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" };
3333
3360
  process.stdout.on("error", (error) => {
3334
3361
  if (error.code === "EPIPE") {
3335
3362
  process.exit(0);
@@ -3736,6 +3763,52 @@ function runPrune(argv, journal, journalPath) {
3736
3763
  out("");
3737
3764
  return 0;
3738
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
+ }
3739
3812
  function runClose(argv, journal) {
3740
3813
  const all = journal.listRuns();
3741
3814
  const given = positional(argv)[1];
@@ -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(
@@ -4118,6 +4217,7 @@ var KNOWN_COMMANDS = [
4118
4217
  "watch",
4119
4218
  "approve",
4120
4219
  "deny",
4220
+ "resolve",
4121
4221
  "undo",
4122
4222
  "help",
4123
4223
  "version"
@@ -4141,6 +4241,9 @@ var FLAGS = /* @__PURE__ */ new Set([
4141
4241
  "--dry-run",
4142
4242
  "--replan",
4143
4243
  "--reason",
4244
+ // resolve: which way the unknown outcome actually went.
4245
+ "--applied",
4246
+ "--failed",
4144
4247
  "--force",
4145
4248
  "--yes",
4146
4249
  "--older-than",
@@ -4314,6 +4417,8 @@ ${COMMANDS}`);
4314
4417
  return runPrune(argv, journal, journalPath);
4315
4418
  case "gates":
4316
4419
  return runGates(journal, asJson);
4420
+ case "resolve":
4421
+ return runResolve(argv, journal);
4317
4422
  case "approve":
4318
4423
  return runDecision(argv, journal, true);
4319
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-FIOKOIT6.js";
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.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ").trim();
264
+ const stripped = code(text);
261
265
  if (!READ_ONLY.test(stripped.toLowerCase())) {
262
266
  return false;
263
267
  }
264
- return stripped.replace(/;\s*$/, "").indexOf(";") === -1;
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 code = isRecord(error) ? error["code"] : void 0;
348
- return code === ErrorCode.MethodNotFound || code === ErrorCode.InvalidParams || code === ErrorCode.InvalidRequest || code === ErrorCode.ParseError;
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 = (code) => {
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(code);
1284
+ process.exit(code2);
1278
1285
  })();
1279
1286
  };
1280
1287
  process.on("SIGINT", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synartesis",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "An undo layer for AI agents.",
5
5
  "type": "module",
6
6
  "private": false,