synartesis 0.6.11 → 0.6.13

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.
@@ -240,6 +240,36 @@ CREATE TABLE IF NOT EXISTS actions (
240
240
  );
241
241
 
242
242
  CREATE INDEX IF NOT EXISTS actions_by_run ON actions(run_id, seq);
243
+
244
+ -- Deliberately not a schema version bump. Adding an index changes no row and
245
+ -- no meaning, IF NOT EXISTS makes it idempotent, and the statement is run on
246
+ -- every open -- so a journal written months ago gains these the next time it
247
+ -- is opened, and an older build opening the same file afterwards neither
248
+ -- notices nor cares. A version bump would have been the opposite: this build
249
+ -- refuses to open a journal from a different schema, and telling somebody to
250
+ -- abandon everything an agent has ever done in order to gain an index would
251
+ -- be a poor trade.
252
+ --
253
+ -- Both of these sit in front of a person waiting. findApproval runs twice on
254
+ -- every gated call and findGated backs the gates command and the console;
255
+ -- without them each is a full scan over rows that carry the snapshots, which
256
+ -- is the largest thing in the table. Measured at fifty thousand actions with
257
+ -- two-kilobyte snapshots: 61ms to 0.01ms, and 56ms to 0.00ms.
258
+ CREATE INDEX IF NOT EXISTS actions_approved ON actions(server, tool, status, approved_at);
259
+ CREATE INDEX IF NOT EXISTS actions_gated ON actions(status, ts);
260
+
261
+ -- Covering, and that is the whole point. listRuns needs a count and three
262
+ -- status tallies per run, and without this the group-by scans the table --
263
+ -- which carries the snapshots, so the cost of listing sessions grew with the
264
+ -- size of the data those sessions touched, not with how many there were.
265
+ -- Adding status to the run index lets sqlite answer entirely from the index.
266
+ -- Measured on forty runs of five hundred actions with two-kilobyte snapshots,
267
+ -- a hundred-megabyte journal: 76ms to 2ms.
268
+ --
269
+ -- Added the same way as the two above and for the same reason: no row changes,
270
+ -- no meaning changes, IF NOT EXISTS makes it idempotent, and an older build
271
+ -- opening the same file afterwards neither notices nor cares.
272
+ CREATE INDEX IF NOT EXISTS actions_run_status ON actions(run_id, status);
243
273
  `;
244
274
 
245
275
  // src/journal/journal.ts
@@ -251,6 +281,13 @@ var runSchema = z.object({
251
281
  ended_at: z.string().nullable(),
252
282
  status: z.enum(["active", "complete", "rolled_back", "partial"])
253
283
  });
284
+ var tallySchema = z.object({
285
+ run_id: z.string(),
286
+ actions: z.number(),
287
+ unknown: z.number().nullable(),
288
+ waiting: z.number().nullable(),
289
+ applied: z.number().nullable()
290
+ });
254
291
  var actionSchema = z.object({
255
292
  id: z.string(),
256
293
  run_id: z.string(),
@@ -682,6 +719,30 @@ var SqliteJournal = class {
682
719
  () => this.#db.prepare("SELECT * FROM actions WHERE run_id = ? ORDER BY seq").all(runId).map(toAction)
683
720
  );
684
721
  }
722
+ tallyRuns() {
723
+ return this.#run("tallyRuns", () => {
724
+ const rows = this.#db.prepare(
725
+ `SELECT run_id,
726
+ COUNT(*) AS actions,
727
+ SUM(status = 'pending') AS unknown,
728
+ SUM(status = 'gated') AS waiting,
729
+ SUM(status = 'applied') AS applied
730
+ FROM actions
731
+ GROUP BY run_id`
732
+ ).all();
733
+ const tally = /* @__PURE__ */ new Map();
734
+ for (const row of rows) {
735
+ const counts = tallySchema.parse(row);
736
+ tally.set(counts.run_id, {
737
+ actions: counts.actions,
738
+ unknown: counts.unknown ?? 0,
739
+ waiting: counts.waiting ?? 0,
740
+ applied: counts.applied ?? 0
741
+ });
742
+ }
743
+ return tally;
744
+ });
745
+ }
685
746
  recentActions(limit) {
686
747
  return this.#run(
687
748
  "recentActions",
@@ -956,17 +1017,20 @@ var toolPolicy = z2.strictObject({
956
1017
  gate: z2.enum(["always", "on_write", "never"]).optional(),
957
1018
  refusal: z2.enum(["uncertain", "clean"]).optional(),
958
1019
  snapshot: callTemplate.optional(),
959
- inverse: callTemplate.optional()
1020
+ inverse: callTemplate.optional(),
1021
+ verify: callTemplate.optional()
960
1022
  });
961
1023
  var serverSpec = z2.strictObject({
962
1024
  command: z2.string().min(1),
963
1025
  args: z2.array(z2.string()).default([]),
964
- env: z2.record(z2.string(), z2.string()).optional()
1026
+ env: z2.record(z2.string(), z2.string()).optional(),
1027
+ provenance: z2.enum(["live", "documented"]).optional()
965
1028
  });
966
1029
  var manifestSchema = z2.strictObject({
967
1030
  version: z2.literal(1),
968
1031
  servers: z2.record(z2.string(), serverSpec),
969
- tools: z2.array(toolPolicy).default([])
1032
+ tools: z2.array(toolPolicy).default([]),
1033
+ pins: z2.record(z2.string(), z2.record(z2.string(), z2.string().min(1))).optional()
970
1034
  });
971
1035
  var Source = class {
972
1036
  constructor(doc, lines, file) {
@@ -1049,6 +1113,11 @@ function validate(source, manifest) {
1049
1113
  if (servers.length === 0) {
1050
1114
  source.fail(["servers"], "at least one server must be declared");
1051
1115
  }
1116
+ for (const name of Object.keys(manifest.pins ?? {})) {
1117
+ if (!servers.includes(name)) {
1118
+ source.fail(["pins", name], `pins name server ${name}, which is not declared`);
1119
+ }
1120
+ }
1052
1121
  const seen = /* @__PURE__ */ new Map();
1053
1122
  manifest.tools.forEach((policy, index) => {
1054
1123
  const path = ["tools", index];
@@ -1070,6 +1139,9 @@ function validate(source, manifest) {
1070
1139
  `${policy.match} names server ${segment}, which is not declared`
1071
1140
  );
1072
1141
  }
1142
+ if (policy.verify !== void 0 && policy.class === "readonly") {
1143
+ source.fail([...path, "verify"], "a readonly tool has no post-state to check for drift");
1144
+ }
1073
1145
  const needsInverse = policy.class === "reversible" || policy.class === "compensable";
1074
1146
  if (needsInverse && policy.inverse === void 0) {
1075
1147
  source.fail(path, `a ${policy.class} tool must declare an inverse`);
@@ -1113,7 +1185,8 @@ function withGate(policy) {
1113
1185
  gate,
1114
1186
  refusal: policy.refusal ?? "uncertain",
1115
1187
  ...policy.snapshot === void 0 ? {} : { snapshot: toCall(policy.snapshot) },
1116
- ...policy.inverse === void 0 ? {} : { inverse: toCall(policy.inverse) }
1188
+ ...policy.inverse === void 0 ? {} : { inverse: toCall(policy.inverse) },
1189
+ ...policy.verify === void 0 ? {} : { verify: toCall(policy.verify) }
1117
1190
  };
1118
1191
  }
1119
1192
  function parseManifest(text, file) {
@@ -1149,11 +1222,13 @@ function parseManifest(text, file) {
1149
1222
  {
1150
1223
  command: spec.command,
1151
1224
  args: spec.args,
1152
- ...spec.env === void 0 ? {} : { env: expandEnvironment(source, ["servers", name], spec.env) }
1225
+ ...spec.env === void 0 ? {} : { env: expandEnvironment(source, ["servers", name], spec.env) },
1226
+ ...spec.provenance === void 0 ? {} : { provenance: spec.provenance }
1153
1227
  }
1154
1228
  ])
1155
1229
  ),
1156
- tools: parsed.data.tools.map(withGate)
1230
+ tools: parsed.data.tools.map(withGate),
1231
+ ...parsed.data.pins === void 0 ? {} : { pins: parsed.data.pins }
1157
1232
  };
1158
1233
  validate(source, manifest);
1159
1234
  return manifest;
@@ -1176,8 +1251,41 @@ function loadManifest(path) {
1176
1251
  return parseManifest(text, path);
1177
1252
  }
1178
1253
 
1179
- // src/manifest/verify.ts
1180
- import { z as z3 } from "zod";
1254
+ // src/manifest/pin.ts
1255
+ import { createHash } from "crypto";
1256
+
1257
+ // src/manifest/match.ts
1258
+ function toRegExp(pattern) {
1259
+ const source = pattern.split("*").map((literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[^.]*");
1260
+ return new RegExp(`^${source}$`);
1261
+ }
1262
+ function literalLength(pattern) {
1263
+ return pattern.length - pattern.split("*").length + 1;
1264
+ }
1265
+ function failClosed(qualifiedName) {
1266
+ return { match: qualifiedName, class: "irreversible", gate: "always", refusal: "uncertain" };
1267
+ }
1268
+ function createPolicyResolver(manifest) {
1269
+ const compiled = manifest.tools.map((policy) => ({
1270
+ policy,
1271
+ test: toRegExp(policy.match),
1272
+ specificity: literalLength(policy.match),
1273
+ wildcards: policy.match.split("*").length - 1
1274
+ })).sort((a, b) => b.specificity - a.specificity || a.wildcards - b.wildcards);
1275
+ const cache = /* @__PURE__ */ new Map();
1276
+ return {
1277
+ resolve(qualifiedName) {
1278
+ const cached2 = cache.get(qualifiedName);
1279
+ if (cached2 !== void 0) {
1280
+ return cached2;
1281
+ }
1282
+ const hit = compiled.find((candidate) => candidate.test.test(qualifiedName));
1283
+ const match = hit === void 0 ? { policy: failClosed(qualifiedName), matched: false } : { policy: hit.policy, matched: true };
1284
+ cache.set(qualifiedName, match);
1285
+ return match;
1286
+ }
1287
+ };
1288
+ }
1181
1289
 
1182
1290
  // src/manifest/types.ts
1183
1291
  function qualify(server, tool) {
@@ -1191,13 +1299,77 @@ function splitQualified(qualified) {
1191
1299
  return { server: qualified.slice(0, dot), tool: qualified.slice(dot + 1) };
1192
1300
  }
1193
1301
 
1302
+ // src/manifest/pin.ts
1303
+ function fingerprint(inputSchema) {
1304
+ return `sha256:${createHash("sha256").update(canonical(inputSchema)).digest("hex")}`;
1305
+ }
1306
+ function auditPins(server, advertised, manifest) {
1307
+ const pins = manifest.pins?.[server];
1308
+ if (pins === void 0) {
1309
+ return [];
1310
+ }
1311
+ const resolver = createPolicyResolver(manifest);
1312
+ const faults = [];
1313
+ const present = /* @__PURE__ */ new Set();
1314
+ for (const tool of advertised) {
1315
+ present.add(tool.name);
1316
+ if (!resolver.resolve(qualify(server, tool.name)).matched) {
1317
+ continue;
1318
+ }
1319
+ const found = fingerprint(tool.inputSchema);
1320
+ const pinned = pins[tool.name];
1321
+ if (pinned === void 0) {
1322
+ faults.push({ kind: "unpinned", tool: tool.name, found });
1323
+ } else if (pinned !== found) {
1324
+ faults.push({ kind: "moved", tool: tool.name, pinned, found });
1325
+ }
1326
+ }
1327
+ for (const name of Object.keys(pins)) {
1328
+ if (!present.has(name)) {
1329
+ faults.push({ kind: "gone", tool: name });
1330
+ }
1331
+ }
1332
+ return faults;
1333
+ }
1334
+ function pinBlock(shapes, manifest) {
1335
+ const resolver = createPolicyResolver(manifest);
1336
+ const lines = ["pins:"];
1337
+ for (const server of [...shapes.keys()].sort()) {
1338
+ const governed = (shapes.get(server) ?? []).filter((tool) => resolver.resolve(qualify(server, tool.name)).matched).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
1339
+ if (governed.length === 0) {
1340
+ continue;
1341
+ }
1342
+ lines.push(` ${server}:`);
1343
+ for (const tool of governed) {
1344
+ lines.push(` ${tool.name}: "${fingerprint(tool.inputSchema)}"`);
1345
+ }
1346
+ }
1347
+ return lines.join("\n");
1348
+ }
1349
+ function explainPins(server, faults) {
1350
+ return faults.map((fault) => {
1351
+ switch (fault.kind) {
1352
+ case "moved":
1353
+ return `${server}.${fault.tool} no longer has the shape it was pinned at. Its policy was written for the old one, so the snapshot and inverse it carries may no longer describe this tool.
1354
+ pinned ${fault.pinned}
1355
+ now ${fault.found}`;
1356
+ case "unpinned":
1357
+ return `${server}.${fault.tool} is governed by a policy and has no pin, on a server where everything else is pinned.
1358
+ add ${fault.tool}: "${fault.found}"`;
1359
+ case "gone":
1360
+ return `${server}.${fault.tool} is pinned but the server does not expose it, so the pin vouches for nothing. Remove it, or connect the server that has it.`;
1361
+ }
1362
+ });
1363
+ }
1364
+
1194
1365
  // src/manifest/verify.ts
1366
+ import { z as z3 } from "zod";
1195
1367
  var listSchema = z3.looseObject({
1196
- tools: z3.array(z3.looseObject({ name: z3.string() })),
1368
+ tools: z3.array(z3.looseObject({ name: z3.string(), inputSchema: z3.unknown() })),
1197
1369
  nextCursor: z3.string().optional()
1198
1370
  });
1199
- async function toolNames(upstream) {
1200
- const names = /* @__PURE__ */ new Set();
1371
+ async function toolShapes(upstream) {
1372
+ const shapes = [];
1201
1373
  let cursor;
1202
1374
  do {
1203
1375
  const page = listSchema.parse(
@@ -1207,16 +1379,19 @@ async function toolNames(upstream) {
1207
1379
  )
1208
1380
  );
1209
1381
  for (const tool of page.tools) {
1210
- names.add(tool.name);
1382
+ shapes.push({ name: tool.name, inputSchema: tool.inputSchema });
1211
1383
  }
1212
1384
  cursor = page.nextCursor;
1213
1385
  } while (cursor !== void 0);
1214
- return names;
1386
+ return shapes;
1215
1387
  }
1216
1388
  async function verifyAgainstServers(upstreams, manifest) {
1389
+ const shapes = /* @__PURE__ */ new Map();
1217
1390
  const available = /* @__PURE__ */ new Map();
1218
1391
  for (const upstream of upstreams) {
1219
- available.set(upstream.name, await toolNames(upstream));
1392
+ const advertised = await toolShapes(upstream);
1393
+ shapes.set(upstream.name, advertised);
1394
+ available.set(upstream.name, new Set(advertised.map((tool) => tool.name)));
1220
1395
  }
1221
1396
  const problems = [];
1222
1397
  const check = (qualified, role, match) => {
@@ -1242,11 +1417,50 @@ async function verifyAgainstServers(upstreams, manifest) {
1242
1417
  if (policy.inverse !== void 0) {
1243
1418
  check(policy.inverse.tool, "inverse", policy.match);
1244
1419
  }
1420
+ if (policy.verify !== void 0) {
1421
+ check(policy.verify.tool, "verify", policy.match);
1422
+ }
1245
1423
  }
1246
1424
  if (problems.length > 0) {
1247
1425
  throw new ManifestError(`the manifest calls tools that do not exist:
1248
1426
  ${problems.join("\n ")}`);
1249
1427
  }
1428
+ const drifted = [];
1429
+ for (const upstream of upstreams) {
1430
+ const faults = auditPins(upstream.name, shapes.get(upstream.name) ?? [], manifest);
1431
+ drifted.push(...explainPins(upstream.name, faults));
1432
+ }
1433
+ if (drifted.length > 0) {
1434
+ throw new ManifestError(
1435
+ "a pinned tool no longer matches the policy written for it:\n " + drifted.join("\n ") + "\n\n Review what changed before trusting undo on these tools. `synartesis pin` prints the block for the servers you have now."
1436
+ );
1437
+ }
1438
+ }
1439
+
1440
+ // src/manifest/standing.ts
1441
+ function standing(manifest) {
1442
+ return Object.entries(manifest.servers).map(([server, spec]) => ({
1443
+ server,
1444
+ provenance: spec.provenance ?? "unstated"
1445
+ }));
1446
+ }
1447
+ function untested(manifest) {
1448
+ return standing(manifest).filter((entry) => entry.provenance === "documented").map((entry) => entry.server);
1449
+ }
1450
+ function describeStanding(entry) {
1451
+ switch (entry.provenance) {
1452
+ case "live":
1453
+ return "checked against the real server";
1454
+ case "documented":
1455
+ return "written from documentation, never run against the real server";
1456
+ case "unstated":
1457
+ return "no claim either way";
1458
+ }
1459
+ }
1460
+ function warnUntested(servers) {
1461
+ const names = servers.join(", ");
1462
+ const these = servers.length === 1 ? "this policy has" : "these policies have";
1463
+ return `${names}: ${these} never been run against the real server. The classes and inverses here come from documentation, so undo may not work where it says it will. Run \`synartesis check\` against your own credentials, and expect to correct something.`;
1250
1464
  }
1251
1465
 
1252
1466
  // src/proxy/routing.ts
@@ -1373,37 +1587,17 @@ async function start(spec) {
1373
1587
  return { client };
1374
1588
  }
1375
1589
 
1376
- // src/manifest/match.ts
1377
- function toRegExp(pattern) {
1378
- const source = pattern.split("*").map((literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[^.]*");
1379
- return new RegExp(`^${source}$`);
1380
- }
1381
- function literalLength(pattern) {
1382
- return pattern.length - pattern.split("*").length + 1;
1383
- }
1384
- function failClosed(qualifiedName) {
1385
- return { match: qualifiedName, class: "irreversible", gate: "always", refusal: "uncertain" };
1386
- }
1387
- function createPolicyResolver(manifest) {
1388
- const compiled = manifest.tools.map((policy) => ({
1389
- policy,
1390
- test: toRegExp(policy.match),
1391
- specificity: literalLength(policy.match),
1392
- wildcards: policy.match.split("*").length - 1
1393
- })).sort((a, b) => b.specificity - a.specificity || a.wildcards - b.wildcards);
1394
- const cache = /* @__PURE__ */ new Map();
1395
- return {
1396
- resolve(qualifiedName) {
1397
- const cached2 = cache.get(qualifiedName);
1398
- if (cached2 !== void 0) {
1399
- return cached2;
1400
- }
1401
- const hit = compiled.find((candidate) => candidate.test.test(qualifiedName));
1402
- const match = hit === void 0 ? { policy: failClosed(qualifiedName), matched: false } : { policy: hit.policy, matched: true };
1403
- cache.set(qualifiedName, match);
1404
- return match;
1590
+ // src/idempotency.ts
1591
+ var IDEMPOTENCY_META_KEY = "synartesis.dev/idempotency-key";
1592
+ function withIdempotencyKey(meta, key) {
1593
+ const merged = {};
1594
+ if (typeof meta === "object" && meta !== null) {
1595
+ for (const [name, value] of Object.entries(meta)) {
1596
+ merged[name] = value;
1405
1597
  }
1406
- };
1598
+ }
1599
+ merged[IDEMPOTENCY_META_KEY] = key;
1600
+ return merged;
1407
1601
  }
1408
1602
 
1409
1603
  // src/proxy/snapshot.ts
@@ -1565,9 +1759,17 @@ export {
1565
1759
  wasRefused,
1566
1760
  parseManifest,
1567
1761
  loadManifest,
1762
+ createPolicyResolver,
1568
1763
  qualify,
1764
+ pinBlock,
1765
+ toolShapes,
1569
1766
  verifyAgainstServers,
1570
- createPolicyResolver,
1767
+ standing,
1768
+ untested,
1769
+ describeStanding,
1770
+ warnUntested,
1771
+ IDEMPOTENCY_META_KEY,
1772
+ withIdempotencyKey,
1571
1773
  createRouter,
1572
1774
  refusal,
1573
1775
  toPayload,
@@ -1580,4 +1782,4 @@ export {
1580
1782
  observeState,
1581
1783
  connectStdioUpstream
1582
1784
  };
1583
- //# sourceMappingURL=chunk-DRDKIFD3.js.map
1785
+ //# sourceMappingURL=chunk-6X7GPUGJ.js.map