synartesis 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +138 -0
- package/dist/{chunk-LAZLEAZW.js → chunk-CACV6R74.js} +75 -29
- package/dist/cli.js +81 -27
- package/dist/proxy.js +71 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,144 @@
|
|
|
2
2
|
|
|
3
3
|
What changed, and why it mattered. Dates are release dates.
|
|
4
4
|
|
|
5
|
+
## 0.5.0 — 2026-09-10
|
|
6
|
+
|
|
7
|
+
A safety release. Two more races of the same family as 0.4.2's, one structural
|
|
8
|
+
change so that family cannot be written again, the first proof that a shipped
|
|
9
|
+
policy actually restores anything, and durability by default.
|
|
10
|
+
|
|
11
|
+
Minor rather than patch: two behaviours change in ways you will notice.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **The journal is durable by default.** It ran with `synchronous = NORMAL`, on
|
|
16
|
+
the reasoning that the tail of the write-ahead log holds the record of a call
|
|
17
|
+
and never the call itself. True, and the wrong way round: the call reached the
|
|
18
|
+
server or it did not regardless of this file, so losing the record means the
|
|
19
|
+
world changed and the journal does not know. Undo cannot reverse what it has
|
|
20
|
+
no record of, and `show --live` would report that nothing was recorded — the
|
|
21
|
+
reassuring answer, on no evidence.
|
|
22
|
+
|
|
23
|
+
Measured here on 2,000 inserts of a 2 kB payload: NORMAL 0.0246 ms per write,
|
|
24
|
+
FULL 0.0594. Durability costs **0.035 ms per action**, about eight per cent of
|
|
25
|
+
the proxy's own 0.43 ms overhead. `SYNARTESIS_SYNC=normal` restores the old
|
|
26
|
+
behaviour.
|
|
27
|
+
|
|
28
|
+
- **An interrupted inverse no longer resumes itself.** An action in
|
|
29
|
+
`rolling_back` was let through without a claim, on the reasoning that it must
|
|
30
|
+
be a crash. It is also what a live undo looks like between claiming an action
|
|
31
|
+
and finishing its inverse, and nothing here can tell those apart, so a second
|
|
32
|
+
undo starting in that window sent the same inverse again. It now halts and
|
|
33
|
+
names the evidence to look at. Finishing such an inverse needs a lease that
|
|
34
|
+
distinguishes a live owner from an abandoned attempt, which needs a schema
|
|
35
|
+
and a migration path this build does not have; that limit is now stated
|
|
36
|
+
rather than silently crossed.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **Adapter contract tests for the shipped filesystem policy**, run against the
|
|
41
|
+
real server. Policies were checked only for tool *existence* — that
|
|
42
|
+
`fs.read_text_file` is a real tool — and never for whether the declared
|
|
43
|
+
inverse puts the file back. That is the failure mode that looks fine: a real
|
|
44
|
+
tool, a clean resolution, a report saying `rolled_back`, and the file still
|
|
45
|
+
wrong. Drift detection cannot catch it, because nothing is wrong with the
|
|
46
|
+
drift.
|
|
47
|
+
|
|
48
|
+
The tests make the mutation, undo it and compare bytes: exact restoration
|
|
49
|
+
including trailing whitespace and non-ascii, repeated writes to one file, a
|
|
50
|
+
colleague's edit refused, absence told apart from a read that failed, and
|
|
51
|
+
inspection distinguishing a touched file from an untouched one. Changing the
|
|
52
|
+
inverse to restore `$.content` instead of `$snapshot.content` fails them.
|
|
53
|
+
|
|
54
|
+
The tested server version is named in the file. **memory, git and github ship
|
|
55
|
+
policies with no such proof**; those servers are not installed in this
|
|
56
|
+
repository and their guarantees remain assumed.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- **Every status change now goes through one door.** Three of thirteen
|
|
61
|
+
transitions were conditional; the other ten wrote whatever they were told and
|
|
62
|
+
were safe only by convention. The convention failed three times in two days.
|
|
63
|
+
There are now two ways to change a status and no third: one reports whether it
|
|
64
|
+
won the race, the other names the statuses the caller must be holding and
|
|
65
|
+
throws when the row is not in one of them. Turning it on found three states
|
|
66
|
+
nothing can produce, all of them in tests that had been setting end states
|
|
67
|
+
directly.
|
|
68
|
+
|
|
69
|
+
- **A second place one approval could authorise two calls.** The
|
|
70
|
+
missing-prior-state branch ignored the result of spending the approval.
|
|
71
|
+
|
|
72
|
+
## 0.4.3 — 2026-09-10
|
|
73
|
+
|
|
74
|
+
A correctness release. Seven reproduced defects in how effects are accounted
|
|
75
|
+
for and how recovery is planned. Each is pinned by a test that fails against
|
|
76
|
+
0.4.2 on real state and inverse-call counts, not on messages.
|
|
77
|
+
|
|
78
|
+
The theme is one mistake made in seven places: treating absence of evidence as
|
|
79
|
+
evidence. An error was read as proof nothing happened, a missing post-state as
|
|
80
|
+
permission to write anyway, a stripped rule as proof of absence, and an
|
|
81
|
+
unreadable resource as one with nothing left applied.
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
|
|
85
|
+
- **A timeout after the write was recorded as never applied.** The uncertainty
|
|
86
|
+
check knew only "Connection closed" and a downstream abort, so an upstream
|
|
87
|
+
that mutates a record and then times out fell through to `failed` — and
|
|
88
|
+
rollback steps over a failed action without looking at it. The change was
|
|
89
|
+
invisible to recovery.
|
|
90
|
+
|
|
91
|
+
- **`isError` was treated as proof of a refusal.** The protocol gives the flag
|
|
92
|
+
no transactional meaning: it covers business-logic failures that happen after
|
|
93
|
+
a write as readily as a refusal before one.
|
|
94
|
+
|
|
95
|
+
Both now ask instead of assuming. Where a pre-read was declared, that same
|
|
96
|
+
read says what is true now, and comparing it against what was there before
|
|
97
|
+
turns a guess into evidence: unchanged is recorded as never applied, changed
|
|
98
|
+
is recorded as applied so it can be undone, and a read that cannot answer
|
|
99
|
+
leaves the outcome unknown. Only evidence of no dispatch — no transport, or a
|
|
100
|
+
JSON-RPC rejection of the envelope — is now recorded as definitely not
|
|
101
|
+
applied. Where there is no pre-read to ask, an adapter can state the
|
|
102
|
+
guarantee itself with `refusal: clean` on a tool policy; the default is
|
|
103
|
+
`uncertain`.
|
|
104
|
+
|
|
105
|
+
- **Undo overwrote human edits when the post-state was missing.** A reversible
|
|
106
|
+
action promises evidence. Where the post-read failed at capture, rollback
|
|
107
|
+
reverted anyway, labelled it unverified and reported success — writing the
|
|
108
|
+
old value over whatever was there, with no `--force` required. It now halts
|
|
109
|
+
and says which half of the evidence is missing. `--force` is how a person
|
|
110
|
+
decides to proceed; the step stays unverified in the report. Compensable
|
|
111
|
+
actions, whose policies declare no pre-read at all, are deliberately not
|
|
112
|
+
caught by this.
|
|
113
|
+
|
|
114
|
+
- **`absentWhen` was stripped on the way back in.** Rollback and inspection
|
|
115
|
+
each parsed a stored verify read through a schema naming only server, tool
|
|
116
|
+
and args, so the absence rules were dropped and every read failure became
|
|
117
|
+
"the resource is gone". A "permission denied" was reported as a definite
|
|
118
|
+
change and could send an inverse. There is now one shared schema for a stored
|
|
119
|
+
read.
|
|
120
|
+
|
|
121
|
+
- **`--replan` could not recover a resolved conflict.** Taking the halt's own
|
|
122
|
+
advice — put the resource back, then replan — passed every check and then
|
|
123
|
+
failed to claim the row, reporting that another undo held it, which was never
|
|
124
|
+
true. Unresolved drift still halts.
|
|
125
|
+
|
|
126
|
+
- **Preview invented drift it would never meet.** Undo puts intervening states
|
|
127
|
+
back as it walks down; a preview sends nothing, so an older write to a
|
|
128
|
+
twice-written record was compared against the newest value and called drift.
|
|
129
|
+
The preview now carries the state each planned inverse would leave, and only
|
|
130
|
+
where that is knowable — a compensation stops the chain rather than guessing.
|
|
131
|
+
|
|
132
|
+
- **Inspection claimed nothing was left applied about resources it could not
|
|
133
|
+
read.** The summary counted only changed and unchanged, so an unreadable
|
|
134
|
+
resource fell through to the most reassuring sentence available. Unknowns are
|
|
135
|
+
now said first and never absorbed into a clean answer; `tally()` exposes the
|
|
136
|
+
counts. A failed post-read is also no longer reported as "no pre-read was
|
|
137
|
+
declared".
|
|
138
|
+
|
|
139
|
+
- **A second place where one approval could authorise two calls.** The
|
|
140
|
+
missing-prior-state branch ignored the boolean from `adoptApproval`, so a
|
|
141
|
+
caller that lost the claim proceeded anyway. It now asks, like the gate path.
|
|
142
|
+
|
|
5
143
|
## 0.4.2 — 2026-09-09
|
|
6
144
|
|
|
7
145
|
Three bugs, found by auditing the paths the last pass did not touch. Each is
|
|
@@ -339,7 +339,9 @@ function openDatabase(path) {
|
|
|
339
339
|
db.pragma("journal_mode = WAL");
|
|
340
340
|
db.pragma("foreign_keys = ON");
|
|
341
341
|
db.pragma("busy_timeout = 5000");
|
|
342
|
-
db.pragma(
|
|
342
|
+
db.pragma(
|
|
343
|
+
process.env["SYNARTESIS_SYNC"]?.toLowerCase() === "normal" ? "synchronous = NORMAL" : "synchronous = FULL"
|
|
344
|
+
);
|
|
343
345
|
if (path !== ":memory:") {
|
|
344
346
|
restrictToOwner(path);
|
|
345
347
|
}
|
|
@@ -454,11 +456,46 @@ var SqliteJournal = class {
|
|
|
454
456
|
);
|
|
455
457
|
});
|
|
456
458
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
459
|
+
/**
|
|
460
|
+
* Every status change, in one place, and none of them silent.
|
|
461
|
+
*
|
|
462
|
+
* Three of thirteen transitions used to be conditional; the other ten wrote
|
|
463
|
+
* whatever they were told. That was safe only by convention -- each caller
|
|
464
|
+
* happened to hold a claim first -- and the convention failed three times:
|
|
465
|
+
* a forced undo, a spent approval, and an adopted one all wrote a status
|
|
466
|
+
* they did not own, and two irreversible calls went out on one person's yes.
|
|
467
|
+
*
|
|
468
|
+
* `from` is the set of statuses the caller must be holding. #settle throws
|
|
469
|
+
* when the row is not in one of them, because a caller that has lost its
|
|
470
|
+
* claim has lost the right to say what happened; #claim reports it instead,
|
|
471
|
+
* for the callers whose job is to race. Neither can be bypassed by writing
|
|
472
|
+
* status directly, because nothing else in this class does.
|
|
473
|
+
*/
|
|
474
|
+
#settle(operation, actionId, to, from, error) {
|
|
475
|
+
this.#run(operation, () => {
|
|
476
|
+
const slots = from.map(() => "?").join(",");
|
|
477
|
+
const sql = error === void 0 ? `UPDATE actions SET status = ? WHERE id = ? AND status IN (${slots})` : `UPDATE actions SET status = ?, error = ? WHERE id = ? AND status IN (${slots})`;
|
|
478
|
+
const args = error === void 0 ? [to, actionId, ...from] : [to, error, actionId, ...from];
|
|
479
|
+
const result = this.#db.prepare(sql).run(...args);
|
|
480
|
+
if (result.changes !== 1) {
|
|
481
|
+
const now = this.getAction(actionId)?.status ?? "gone";
|
|
482
|
+
throw new Error(
|
|
483
|
+
`cannot move ${actionId} to ${to}: it is ${now}, not one of ${from.join(", ")}`
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
#claim(operation, actionId, to, from, error) {
|
|
489
|
+
return this.#run(operation, () => {
|
|
490
|
+
const slots = from.map(() => "?").join(",");
|
|
491
|
+
const sql = error === void 0 ? `UPDATE actions SET status = ? WHERE id = ? AND status IN (${slots})` : `UPDATE actions SET status = ?, error = ? WHERE id = ? AND status IN (${slots})`;
|
|
492
|
+
const args = error === void 0 ? [to, actionId, ...from] : [to, error, actionId, ...from];
|
|
493
|
+
return this.#db.prepare(sql).run(...args).changes === 1;
|
|
460
494
|
});
|
|
461
495
|
}
|
|
496
|
+
markFailed(actionId, error) {
|
|
497
|
+
this.#settle("markFailed", actionId, "failed", ["pending", "gated", "approved"], error);
|
|
498
|
+
}
|
|
462
499
|
/**
|
|
463
500
|
* The call was interrupted, so whether the upstream applied it is genuinely
|
|
464
501
|
* unknown. The row deliberately stays `pending`: recording it as failed
|
|
@@ -466,9 +503,7 @@ var SqliteJournal = class {
|
|
|
466
503
|
* case surfaced rather than resolved by guesswork.
|
|
467
504
|
*/
|
|
468
505
|
markUnknown(actionId, error) {
|
|
469
|
-
this.#
|
|
470
|
-
this.#db.prepare("UPDATE actions SET status = 'pending', error = ? WHERE id = ?").run(error, actionId);
|
|
471
|
-
});
|
|
506
|
+
this.#settle("markUnknown", actionId, "pending", ["pending", "gated", "approved"], error);
|
|
472
507
|
}
|
|
473
508
|
/**
|
|
474
509
|
* Claim an action to send its inverse, or report that somebody else has.
|
|
@@ -494,9 +529,11 @@ var SqliteJournal = class {
|
|
|
494
529
|
});
|
|
495
530
|
}
|
|
496
531
|
markRolledBack(actionId) {
|
|
497
|
-
this.#
|
|
498
|
-
|
|
499
|
-
|
|
532
|
+
this.#settle("markRolledBack", actionId, "rolled_back", [
|
|
533
|
+
"rolling_back",
|
|
534
|
+
"applied",
|
|
535
|
+
"unrecoverable"
|
|
536
|
+
]);
|
|
500
537
|
}
|
|
501
538
|
/**
|
|
502
539
|
* The upstream processed the inverse and refused it, so nothing was applied
|
|
@@ -505,9 +542,7 @@ var SqliteJournal = class {
|
|
|
505
542
|
* was briefly unwell, and rollback is expected to be retried (D7).
|
|
506
543
|
*/
|
|
507
544
|
markInverseRejected(actionId, error) {
|
|
508
|
-
this.#
|
|
509
|
-
this.#db.prepare("UPDATE actions SET status = 'applied', error = ? WHERE id = ?").run(error, actionId);
|
|
510
|
-
});
|
|
545
|
+
this.#settle("markInverseRejected", actionId, "applied", ["rolling_back"], error);
|
|
511
546
|
}
|
|
512
547
|
/**
|
|
513
548
|
* The inverse may or may not have reached the upstream. The row stays in
|
|
@@ -515,14 +550,10 @@ var SqliteJournal = class {
|
|
|
515
550
|
* current state rather than assuming either way.
|
|
516
551
|
*/
|
|
517
552
|
markUnknownInverse(actionId, error) {
|
|
518
|
-
this.#
|
|
519
|
-
this.#db.prepare("UPDATE actions SET status = 'rolling_back', error = ? WHERE id = ?").run(error, actionId);
|
|
520
|
-
});
|
|
553
|
+
this.#settle("markUnknownInverse", actionId, "rolling_back", ["rolling_back"], error);
|
|
521
554
|
}
|
|
522
555
|
markGated(actionId, why) {
|
|
523
|
-
this.#
|
|
524
|
-
this.#db.prepare("UPDATE actions SET status = 'gated', error = ? WHERE id = ?").run(why ?? null, actionId);
|
|
525
|
-
});
|
|
556
|
+
this.#settle("markGated", actionId, "gated", ["pending", "gated"], why ?? null);
|
|
526
557
|
}
|
|
527
558
|
/**
|
|
528
559
|
* Spend a standing approval, or report that somebody else already has.
|
|
@@ -620,9 +651,11 @@ var SqliteJournal = class {
|
|
|
620
651
|
});
|
|
621
652
|
}
|
|
622
653
|
markUnrecoverable(actionId, error) {
|
|
623
|
-
this.#
|
|
624
|
-
|
|
625
|
-
|
|
654
|
+
this.#settle("markUnrecoverable", actionId, "unrecoverable", [
|
|
655
|
+
"applied",
|
|
656
|
+
"rolling_back",
|
|
657
|
+
"unrecoverable"
|
|
658
|
+
], error);
|
|
626
659
|
}
|
|
627
660
|
listRuns() {
|
|
628
661
|
return this.#run(
|
|
@@ -921,6 +954,7 @@ var toolPolicy = z2.strictObject({
|
|
|
921
954
|
match: z2.string().min(1),
|
|
922
955
|
class: z2.enum(["readonly", "reversible", "compensable", "irreversible"]),
|
|
923
956
|
gate: z2.enum(["always", "on_write", "never"]).optional(),
|
|
957
|
+
refusal: z2.enum(["uncertain", "clean"]).optional(),
|
|
924
958
|
snapshot: callTemplate.optional(),
|
|
925
959
|
inverse: callTemplate.optional()
|
|
926
960
|
});
|
|
@@ -1077,6 +1111,7 @@ function withGate(policy) {
|
|
|
1077
1111
|
match: policy.match,
|
|
1078
1112
|
class: policy.class,
|
|
1079
1113
|
gate,
|
|
1114
|
+
refusal: policy.refusal ?? "uncertain",
|
|
1080
1115
|
...policy.snapshot === void 0 ? {} : { snapshot: toCall(policy.snapshot) },
|
|
1081
1116
|
...policy.inverse === void 0 ? {} : { inverse: toCall(policy.inverse) }
|
|
1082
1117
|
};
|
|
@@ -1347,7 +1382,7 @@ function literalLength(pattern) {
|
|
|
1347
1382
|
return pattern.length - pattern.split("*").length + 1;
|
|
1348
1383
|
}
|
|
1349
1384
|
function failClosed(qualifiedName) {
|
|
1350
|
-
return { match: qualifiedName, class: "irreversible", gate: "always" };
|
|
1385
|
+
return { match: qualifiedName, class: "irreversible", gate: "always", refusal: "uncertain" };
|
|
1351
1386
|
}
|
|
1352
1387
|
function createPolicyResolver(manifest) {
|
|
1353
1388
|
const compiled = manifest.tools.map((policy) => ({
|
|
@@ -1424,6 +1459,20 @@ function planInverse(call, context) {
|
|
|
1424
1459
|
}
|
|
1425
1460
|
return { server: target.server, tool: target.tool, args: resolveArgs(call, context) };
|
|
1426
1461
|
}
|
|
1462
|
+
var resolvedRead = z4.object({
|
|
1463
|
+
server: z4.string(),
|
|
1464
|
+
tool: z4.string(),
|
|
1465
|
+
args: z4.record(z4.string(), z4.unknown()),
|
|
1466
|
+
absentWhen: z4.array(z4.string()).optional()
|
|
1467
|
+
});
|
|
1468
|
+
function toResolvedRead(parsed) {
|
|
1469
|
+
return {
|
|
1470
|
+
server: parsed.server,
|
|
1471
|
+
tool: parsed.tool,
|
|
1472
|
+
args: parsed.args,
|
|
1473
|
+
...parsed.absentWhen === void 0 ? {} : { absentWhen: parsed.absentWhen }
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1427
1476
|
function planRead(call, context) {
|
|
1428
1477
|
const target = splitQualified(call.tool);
|
|
1429
1478
|
if (target === void 0) {
|
|
@@ -1444,10 +1493,6 @@ function isDisconnected(error) {
|
|
|
1444
1493
|
const message = error instanceof Error ? error.message : String(error);
|
|
1445
1494
|
return message.includes("Not connected") || message.includes("Connection closed");
|
|
1446
1495
|
}
|
|
1447
|
-
function mayHaveArrived(error) {
|
|
1448
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1449
|
-
return message.includes("Connection closed");
|
|
1450
|
-
}
|
|
1451
1496
|
async function runRead(router, read2, signal) {
|
|
1452
1497
|
const label = `${read2.server}.${read2.tool}`;
|
|
1453
1498
|
const upstream = router.byName(read2.server);
|
|
@@ -1527,11 +1572,12 @@ export {
|
|
|
1527
1572
|
refusal,
|
|
1528
1573
|
toPayload,
|
|
1529
1574
|
planInverse,
|
|
1575
|
+
resolvedRead,
|
|
1576
|
+
toResolvedRead,
|
|
1530
1577
|
planRead,
|
|
1531
1578
|
isDisconnected,
|
|
1532
|
-
mayHaveArrived,
|
|
1533
1579
|
runRead,
|
|
1534
1580
|
observeState,
|
|
1535
1581
|
connectStdioUpstream
|
|
1536
1582
|
};
|
|
1537
|
-
//# sourceMappingURL=chunk-
|
|
1583
|
+
//# sourceMappingURL=chunk-CACV6R74.js.map
|
package/dist/cli.js
CHANGED
|
@@ -20,12 +20,14 @@ import {
|
|
|
20
20
|
planRead,
|
|
21
21
|
proxyCommand,
|
|
22
22
|
qualify,
|
|
23
|
+
resolvedRead,
|
|
23
24
|
rule,
|
|
24
25
|
style,
|
|
25
26
|
toPayload,
|
|
27
|
+
toResolvedRead,
|
|
26
28
|
verifyAgainstServers,
|
|
27
29
|
wasRefused
|
|
28
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-CACV6R74.js";
|
|
29
31
|
import {
|
|
30
32
|
DriftConflict,
|
|
31
33
|
ManifestError,
|
|
@@ -384,6 +386,7 @@ async function rollback(options) {
|
|
|
384
386
|
);
|
|
385
387
|
let halted;
|
|
386
388
|
let leftInPlace = false;
|
|
389
|
+
const projected = /* @__PURE__ */ new Map();
|
|
387
390
|
for (const action of inScope) {
|
|
388
391
|
let forcedOver;
|
|
389
392
|
const early = classify(action, policies !== void 0, force || dryRun);
|
|
@@ -414,13 +417,14 @@ ${seen}` : seen;
|
|
|
414
417
|
continue;
|
|
415
418
|
}
|
|
416
419
|
const plan = parsedPlan.data;
|
|
417
|
-
const verifyRead =
|
|
420
|
+
const verifyRead = resolvedRead.safeParse(rebuilt.verify ?? action.verify);
|
|
418
421
|
const recordedPost = observation.safeParse(action.postSnapshot);
|
|
419
422
|
let verified = false;
|
|
420
423
|
if (recordedPost.success && verifyRead.success) {
|
|
424
|
+
const foreseen = dryRun ? projected.get(resourceKey(toResolvedRead(verifyRead.data))) : void 0;
|
|
421
425
|
let current;
|
|
422
426
|
try {
|
|
423
|
-
current = await observeState(router, verifyRead.data, signal);
|
|
427
|
+
current = foreseen !== void 0 && foreseen !== UNFORESEEABLE ? foreseen : await observeState(router, toResolvedRead(verifyRead.data), signal);
|
|
424
428
|
} catch (error) {
|
|
425
429
|
const reason = `could not read current state to check for drift: ${describe(error)}`;
|
|
426
430
|
halted = { seq: action.seq, reason, detail: "" };
|
|
@@ -471,6 +475,17 @@ ${seen}` : seen;
|
|
|
471
475
|
forcedOver = "the resource had changed since; that change was overwritten";
|
|
472
476
|
}
|
|
473
477
|
}
|
|
478
|
+
if (!verified && action.class === "reversible" && !force) {
|
|
479
|
+
const reason = unverifiedBecause(action);
|
|
480
|
+
halted = {
|
|
481
|
+
seq: action.seq,
|
|
482
|
+
reason,
|
|
483
|
+
detail: "Without it there is no way to tell this resource from one somebody has edited since, so the recorded value was not written.",
|
|
484
|
+
conflict: true
|
|
485
|
+
};
|
|
486
|
+
steps.push({ ...describeStep(action), kind: "halt", reason, verified: false, plan });
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
474
489
|
if (!verified && action.status === "rolling_back") {
|
|
475
490
|
const reason = "an inverse was already sent before an interruption and no pre-read is declared, so whether it applied cannot be determined";
|
|
476
491
|
halted = { seq: action.seq, reason, detail: action.error ?? "" };
|
|
@@ -480,6 +495,10 @@ ${seen}` : seen;
|
|
|
480
495
|
}
|
|
481
496
|
break;
|
|
482
497
|
}
|
|
498
|
+
if (dryRun && verifyRead.success) {
|
|
499
|
+
const after = intendedAfterInverse(action);
|
|
500
|
+
projected.set(resourceKey(toResolvedRead(verifyRead.data)), after ?? UNFORESEEABLE);
|
|
501
|
+
}
|
|
483
502
|
steps.push({
|
|
484
503
|
...describeStep(action),
|
|
485
504
|
kind: "revert",
|
|
@@ -491,13 +510,15 @@ ${seen}` : seen;
|
|
|
491
510
|
if (dryRun) {
|
|
492
511
|
continue;
|
|
493
512
|
}
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
513
|
+
const claimable = force || policies !== void 0 ? ["applied", "unrecoverable"] : ["applied"];
|
|
514
|
+
const claimed = journal.markRollingBack(action.id, claimable);
|
|
515
|
+
if (!claimed) {
|
|
516
|
+
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";
|
|
517
|
+
halted = {
|
|
518
|
+
seq: action.seq,
|
|
519
|
+
reason,
|
|
520
|
+
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." : ""
|
|
521
|
+
};
|
|
501
522
|
steps[steps.length - 1] = {
|
|
502
523
|
...describeStep(action),
|
|
503
524
|
kind: "halt",
|
|
@@ -555,6 +576,10 @@ function unverifiedBecause(action) {
|
|
|
555
576
|
function describeStep(action) {
|
|
556
577
|
return { seq: action.seq, server: action.server, tool: action.tool };
|
|
557
578
|
}
|
|
579
|
+
function resourceKey(read) {
|
|
580
|
+
return canonical({ server: read.server, tool: read.tool, args: read.args });
|
|
581
|
+
}
|
|
582
|
+
var UNFORESEEABLE = /* @__PURE__ */ Symbol("unforeseeable");
|
|
558
583
|
function overwriteText(current, action) {
|
|
559
584
|
const intended2 = intendedAfterInverse(action);
|
|
560
585
|
if (intended2 === void 0) {
|
|
@@ -600,11 +625,6 @@ async function executeInverse(router, plan, idempotencyKey, signal) {
|
|
|
600
625
|
|
|
601
626
|
// src/rollback/inspect.ts
|
|
602
627
|
import { z as z3 } from "zod";
|
|
603
|
-
var inversePlan2 = z3.object({
|
|
604
|
-
server: z3.string(),
|
|
605
|
-
tool: z3.string(),
|
|
606
|
-
args: z3.record(z3.string(), z3.unknown())
|
|
607
|
-
});
|
|
608
628
|
var observation2 = z3.union([
|
|
609
629
|
z3.object({ present: z3.literal(true), value: z3.unknown() }),
|
|
610
630
|
z3.object({ present: z3.literal(false) })
|
|
@@ -615,7 +635,7 @@ function sameState2(a, b) {
|
|
|
615
635
|
function intended(action) {
|
|
616
636
|
return action.snapshot === void 0 ? void 0 : { present: true, value: action.snapshot };
|
|
617
637
|
}
|
|
618
|
-
function
|
|
638
|
+
function resourceKey2(plan) {
|
|
619
639
|
return canonical({ server: plan.server, tool: plan.tool, args: plan.args });
|
|
620
640
|
}
|
|
621
641
|
var SETTLED = /* @__PURE__ */ new Set(["failed", "denied", "gated", "approved"]);
|
|
@@ -638,17 +658,18 @@ async function inspect(options) {
|
|
|
638
658
|
resources.push({ ...at, condition: "restored" });
|
|
639
659
|
continue;
|
|
640
660
|
}
|
|
641
|
-
const verify =
|
|
661
|
+
const verify = resolvedRead.safeParse(action.verify);
|
|
642
662
|
const post = observation2.safeParse(action.postSnapshot);
|
|
643
663
|
if (!verify.success || !post.success) {
|
|
664
|
+
const note = !verify.success ? action.verify === void 0 ? "no pre-read was declared, so there is nothing to compare against" : "the recorded pre-read could not be read back" : action.postSnapshot === void 0 ? "the post-state was never captured, so there is nothing to compare against" : "the recorded post-state could not be read back";
|
|
644
665
|
resources.push({
|
|
645
666
|
...at,
|
|
646
667
|
condition: "unknowable",
|
|
647
|
-
note: action.inverse === void 0 ?
|
|
668
|
+
note: action.inverse === void 0 ? `${note}; and nothing was captured to restore` : note
|
|
648
669
|
});
|
|
649
670
|
continue;
|
|
650
671
|
}
|
|
651
|
-
const key =
|
|
672
|
+
const key = resourceKey2(toResolvedRead(verify.data));
|
|
652
673
|
if (seen.has(key)) {
|
|
653
674
|
resources.push({ ...at, condition: "superseded" });
|
|
654
675
|
continue;
|
|
@@ -656,7 +677,7 @@ async function inspect(options) {
|
|
|
656
677
|
seen.add(key);
|
|
657
678
|
let current;
|
|
658
679
|
try {
|
|
659
|
-
current = await observeState(router, verify.data, signal);
|
|
680
|
+
current = await observeState(router, toResolvedRead(verify.data), signal);
|
|
660
681
|
} catch (error) {
|
|
661
682
|
resources.push({ ...at, condition: "unknowable", note: `could not read it: ${describe(error)}` });
|
|
662
683
|
continue;
|
|
@@ -678,16 +699,49 @@ async function inspect(options) {
|
|
|
678
699
|
}
|
|
679
700
|
return { runId, resources: resources.reverse() };
|
|
680
701
|
}
|
|
702
|
+
function tally(inspection) {
|
|
703
|
+
const counts = {
|
|
704
|
+
unchanged: 0,
|
|
705
|
+
changed: 0,
|
|
706
|
+
restored: 0,
|
|
707
|
+
superseded: 0,
|
|
708
|
+
"not-applied": 0,
|
|
709
|
+
unknowable: 0
|
|
710
|
+
};
|
|
711
|
+
for (const resource of inspection.resources) {
|
|
712
|
+
counts[resource.condition] += 1;
|
|
713
|
+
}
|
|
714
|
+
return counts;
|
|
715
|
+
}
|
|
681
716
|
function verdict(inspection) {
|
|
682
|
-
const
|
|
683
|
-
const
|
|
684
|
-
if (changed > 0) {
|
|
685
|
-
|
|
717
|
+
const counts = tally(inspection);
|
|
718
|
+
const said = [];
|
|
719
|
+
if (counts.changed > 0) {
|
|
720
|
+
said.push(
|
|
721
|
+
`${String(counts.changed)} changed since this ran; undoing would write over ${counts.changed === 1 ? "it" : "them"}`
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
if (counts.unknowable > 0) {
|
|
725
|
+
said.push(
|
|
726
|
+
`${String(counts.unknowable)} could not be checked, so whether ${counts.unknowable === 1 ? "it is" : "they are"} still applied is unknown`
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
if (said.length > 0) {
|
|
730
|
+
if (counts.unchanged > 0) {
|
|
731
|
+
said.push(`${String(counts.unchanged)} unchanged`);
|
|
732
|
+
}
|
|
733
|
+
return said.join(" \xB7 ");
|
|
734
|
+
}
|
|
735
|
+
if (counts.unchanged > 0) {
|
|
736
|
+
return `nothing has been touched since; all ${String(counts.unchanged)} would undo cleanly`;
|
|
737
|
+
}
|
|
738
|
+
if (counts.restored > 0 || counts.superseded > 0) {
|
|
739
|
+
return "everything here has already been put back";
|
|
686
740
|
}
|
|
687
|
-
if (
|
|
688
|
-
return
|
|
741
|
+
if (counts["not-applied"] > 0) {
|
|
742
|
+
return "nothing here was ever applied";
|
|
689
743
|
}
|
|
690
|
-
return "nothing
|
|
744
|
+
return "nothing was recorded in this session";
|
|
691
745
|
}
|
|
692
746
|
|
|
693
747
|
// src/watch.ts
|
package/dist/proxy.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
canonical,
|
|
3
4
|
cliCommandFrom,
|
|
4
5
|
connectStdioUpstream,
|
|
5
6
|
createPolicyResolver,
|
|
@@ -9,7 +10,6 @@ import {
|
|
|
9
10
|
isDisconnected,
|
|
10
11
|
loadManifest,
|
|
11
12
|
mark,
|
|
12
|
-
mayHaveArrived,
|
|
13
13
|
observeState,
|
|
14
14
|
openJournal,
|
|
15
15
|
planInverse,
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
runRead,
|
|
20
20
|
toPayload,
|
|
21
21
|
verifyAgainstServers
|
|
22
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-CACV6R74.js";
|
|
23
23
|
import {
|
|
24
24
|
SnapshotError,
|
|
25
25
|
UpstreamError,
|
|
@@ -322,6 +322,25 @@ function identityFor(router) {
|
|
|
322
322
|
}
|
|
323
323
|
return { name: "synartesis", version: "0.0.0" };
|
|
324
324
|
}
|
|
325
|
+
async function whatHappened(router, verify, before, signal) {
|
|
326
|
+
if (verify === void 0) {
|
|
327
|
+
return "unknown";
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const now = await observeState(router, verify, signal);
|
|
331
|
+
return canonical(now) === canonical(before) ? "none" : "applied";
|
|
332
|
+
} catch {
|
|
333
|
+
return "unknown";
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function neverDispatched(error) {
|
|
337
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
338
|
+
if (message.includes("Not connected")) {
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
const code = isRecord(error) ? error["code"] : void 0;
|
|
342
|
+
return code === ErrorCode.MethodNotFound || code === ErrorCode.InvalidParams || code === ErrorCode.InvalidRequest || code === ErrorCode.ParseError;
|
|
343
|
+
}
|
|
325
344
|
var SYNARTESIS_INSTRUCTIONS = [
|
|
326
345
|
"These tools are guarded by Synartesis, which records every change so it can be undone later.",
|
|
327
346
|
"",
|
|
@@ -660,10 +679,12 @@ function createProxyServer(options) {
|
|
|
660
679
|
}
|
|
661
680
|
let snapshot;
|
|
662
681
|
let verify;
|
|
682
|
+
let probe;
|
|
663
683
|
let missingPriorState;
|
|
664
684
|
if (policy.snapshot !== void 0) {
|
|
665
685
|
try {
|
|
666
686
|
verify = planRead(policy.snapshot, { args });
|
|
687
|
+
probe = verify;
|
|
667
688
|
snapshot = await runRead(router, verify, extra.signal);
|
|
668
689
|
journal.attachSnapshot(pending.actionId, snapshot);
|
|
669
690
|
} catch (error) {
|
|
@@ -684,6 +705,7 @@ function createProxyServer(options) {
|
|
|
684
705
|
}
|
|
685
706
|
}
|
|
686
707
|
}
|
|
708
|
+
const priorState = probe === void 0 || missingPriorState !== void 0 ? { present: false } : { present: true, value: snapshot };
|
|
687
709
|
if (missingPriorState !== void 0 && !askedAlready) {
|
|
688
710
|
const standing = journal.findApproval({
|
|
689
711
|
server: route.upstream.name,
|
|
@@ -695,12 +717,15 @@ function createProxyServer(options) {
|
|
|
695
717
|
await decide(
|
|
696
718
|
`nothing was captured to restore, so this cannot be undone \u2014 the read said: ${missingPriorState}`
|
|
697
719
|
);
|
|
698
|
-
} else {
|
|
699
|
-
journal.adoptApproval(pending.actionId, standing);
|
|
720
|
+
} else if (journal.adoptApproval(pending.actionId, standing)) {
|
|
700
721
|
log?.info(
|
|
701
722
|
{ action: pending.actionId, by: standing.approvedBy, from: standing.runId },
|
|
702
723
|
"proceeding on a standing approval"
|
|
703
724
|
);
|
|
725
|
+
} else {
|
|
726
|
+
await decide(
|
|
727
|
+
`nothing was captured to restore, so this cannot be undone \u2014 the read said: ${missingPriorState}`
|
|
728
|
+
);
|
|
704
729
|
}
|
|
705
730
|
}
|
|
706
731
|
const forwarded = {
|
|
@@ -713,12 +738,27 @@ function createProxyServer(options) {
|
|
|
713
738
|
});
|
|
714
739
|
const refused = refusal(result);
|
|
715
740
|
if (refused !== void 0) {
|
|
716
|
-
|
|
717
|
-
|
|
741
|
+
const settled = policy.refusal === "clean" ? "none" : await whatHappened(router, probe, priorState, extra.signal);
|
|
742
|
+
if (settled !== "applied") {
|
|
743
|
+
const why = `the upstream refused the call: ${refused}`;
|
|
744
|
+
if (settled === "none") {
|
|
745
|
+
journal.markFailed(pending.actionId, why);
|
|
746
|
+
} else {
|
|
747
|
+
journal.markUnknown(
|
|
748
|
+
pending.actionId,
|
|
749
|
+
`${why} -- and whether anything changed could not be established`
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
log?.debug(
|
|
753
|
+
{ seq: pending.seq, tool: route.tool, reason: refused, settled },
|
|
754
|
+
"refused by the upstream"
|
|
755
|
+
);
|
|
756
|
+
return result;
|
|
757
|
+
}
|
|
758
|
+
log?.warn(
|
|
718
759
|
{ seq: pending.seq, tool: route.tool, reason: refused },
|
|
719
|
-
"
|
|
760
|
+
"the upstream reported an error after changing the resource"
|
|
720
761
|
);
|
|
721
|
-
return result;
|
|
722
762
|
}
|
|
723
763
|
const context = { args, snapshot, result: toPayload(result) };
|
|
724
764
|
const warnings = [];
|
|
@@ -760,10 +800,30 @@ function createProxyServer(options) {
|
|
|
760
800
|
return result;
|
|
761
801
|
} catch (error) {
|
|
762
802
|
const disconnected = isDisconnected(error);
|
|
763
|
-
if (
|
|
764
|
-
journal.markUnknown(pending.actionId, describe(error));
|
|
765
|
-
} else {
|
|
803
|
+
if (neverDispatched(error)) {
|
|
766
804
|
journal.markFailed(pending.actionId, describe(error));
|
|
805
|
+
} else {
|
|
806
|
+
const settled = await whatHappened(router, probe, priorState, extra.signal);
|
|
807
|
+
if (settled === "none") {
|
|
808
|
+
journal.markFailed(pending.actionId, describe(error));
|
|
809
|
+
} else if (settled === "unknown") {
|
|
810
|
+
journal.markUnknown(pending.actionId, describe(error));
|
|
811
|
+
} else {
|
|
812
|
+
let recovered;
|
|
813
|
+
if (policy.inverse !== void 0 && missingPriorState === void 0) {
|
|
814
|
+
try {
|
|
815
|
+
recovered = planInverse(policy.inverse, { args, snapshot, result: void 0 });
|
|
816
|
+
} catch {
|
|
817
|
+
recovered = void 0;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
journal.markApplied(pending.actionId, {
|
|
821
|
+
result: void 0,
|
|
822
|
+
...recovered === void 0 ? {} : { inverse: recovered },
|
|
823
|
+
...verify === void 0 ? {} : { verify },
|
|
824
|
+
warning: `the call applied but its answer never arrived: ${describe(error)}`
|
|
825
|
+
});
|
|
826
|
+
}
|
|
767
827
|
}
|
|
768
828
|
if (disconnected && route.upstream.reconnect !== void 0) {
|
|
769
829
|
await route.upstream.reconnect().catch(() => void 0);
|