synartesis 0.6.12 → 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.
@@ -257,6 +257,19 @@ CREATE INDEX IF NOT EXISTS actions_by_run ON actions(run_id, seq);
257
257
  -- two-kilobyte snapshots: 61ms to 0.01ms, and 56ms to 0.00ms.
258
258
  CREATE INDEX IF NOT EXISTS actions_approved ON actions(server, tool, status, approved_at);
259
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);
260
273
  `;
261
274
 
262
275
  // src/journal/journal.ts
@@ -268,6 +281,13 @@ var runSchema = z.object({
268
281
  ended_at: z.string().nullable(),
269
282
  status: z.enum(["active", "complete", "rolled_back", "partial"])
270
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
+ });
271
291
  var actionSchema = z.object({
272
292
  id: z.string(),
273
293
  run_id: z.string(),
@@ -699,6 +719,30 @@ var SqliteJournal = class {
699
719
  () => this.#db.prepare("SELECT * FROM actions WHERE run_id = ? ORDER BY seq").all(runId).map(toAction)
700
720
  );
701
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
+ }
702
746
  recentActions(limit) {
703
747
  return this.#run(
704
748
  "recentActions",
@@ -973,17 +1017,20 @@ var toolPolicy = z2.strictObject({
973
1017
  gate: z2.enum(["always", "on_write", "never"]).optional(),
974
1018
  refusal: z2.enum(["uncertain", "clean"]).optional(),
975
1019
  snapshot: callTemplate.optional(),
976
- inverse: callTemplate.optional()
1020
+ inverse: callTemplate.optional(),
1021
+ verify: callTemplate.optional()
977
1022
  });
978
1023
  var serverSpec = z2.strictObject({
979
1024
  command: z2.string().min(1),
980
1025
  args: z2.array(z2.string()).default([]),
981
- env: z2.record(z2.string(), z2.string()).optional()
1026
+ env: z2.record(z2.string(), z2.string()).optional(),
1027
+ provenance: z2.enum(["live", "documented"]).optional()
982
1028
  });
983
1029
  var manifestSchema = z2.strictObject({
984
1030
  version: z2.literal(1),
985
1031
  servers: z2.record(z2.string(), serverSpec),
986
- 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()
987
1034
  });
988
1035
  var Source = class {
989
1036
  constructor(doc, lines, file) {
@@ -1066,6 +1113,11 @@ function validate(source, manifest) {
1066
1113
  if (servers.length === 0) {
1067
1114
  source.fail(["servers"], "at least one server must be declared");
1068
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
+ }
1069
1121
  const seen = /* @__PURE__ */ new Map();
1070
1122
  manifest.tools.forEach((policy, index) => {
1071
1123
  const path = ["tools", index];
@@ -1087,6 +1139,9 @@ function validate(source, manifest) {
1087
1139
  `${policy.match} names server ${segment}, which is not declared`
1088
1140
  );
1089
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
+ }
1090
1145
  const needsInverse = policy.class === "reversible" || policy.class === "compensable";
1091
1146
  if (needsInverse && policy.inverse === void 0) {
1092
1147
  source.fail(path, `a ${policy.class} tool must declare an inverse`);
@@ -1130,7 +1185,8 @@ function withGate(policy) {
1130
1185
  gate,
1131
1186
  refusal: policy.refusal ?? "uncertain",
1132
1187
  ...policy.snapshot === void 0 ? {} : { snapshot: toCall(policy.snapshot) },
1133
- ...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) }
1134
1190
  };
1135
1191
  }
1136
1192
  function parseManifest(text, file) {
@@ -1166,11 +1222,13 @@ function parseManifest(text, file) {
1166
1222
  {
1167
1223
  command: spec.command,
1168
1224
  args: spec.args,
1169
- ...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 }
1170
1227
  }
1171
1228
  ])
1172
1229
  ),
1173
- tools: parsed.data.tools.map(withGate)
1230
+ tools: parsed.data.tools.map(withGate),
1231
+ ...parsed.data.pins === void 0 ? {} : { pins: parsed.data.pins }
1174
1232
  };
1175
1233
  validate(source, manifest);
1176
1234
  return manifest;
@@ -1193,8 +1251,41 @@ function loadManifest(path) {
1193
1251
  return parseManifest(text, path);
1194
1252
  }
1195
1253
 
1196
- // src/manifest/verify.ts
1197
- 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
+ }
1198
1289
 
1199
1290
  // src/manifest/types.ts
1200
1291
  function qualify(server, tool) {
@@ -1208,13 +1299,77 @@ function splitQualified(qualified) {
1208
1299
  return { server: qualified.slice(0, dot), tool: qualified.slice(dot + 1) };
1209
1300
  }
1210
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
+
1211
1365
  // src/manifest/verify.ts
1366
+ import { z as z3 } from "zod";
1212
1367
  var listSchema = z3.looseObject({
1213
- tools: z3.array(z3.looseObject({ name: z3.string() })),
1368
+ tools: z3.array(z3.looseObject({ name: z3.string(), inputSchema: z3.unknown() })),
1214
1369
  nextCursor: z3.string().optional()
1215
1370
  });
1216
- async function toolNames(upstream) {
1217
- const names = /* @__PURE__ */ new Set();
1371
+ async function toolShapes(upstream) {
1372
+ const shapes = [];
1218
1373
  let cursor;
1219
1374
  do {
1220
1375
  const page = listSchema.parse(
@@ -1224,16 +1379,19 @@ async function toolNames(upstream) {
1224
1379
  )
1225
1380
  );
1226
1381
  for (const tool of page.tools) {
1227
- names.add(tool.name);
1382
+ shapes.push({ name: tool.name, inputSchema: tool.inputSchema });
1228
1383
  }
1229
1384
  cursor = page.nextCursor;
1230
1385
  } while (cursor !== void 0);
1231
- return names;
1386
+ return shapes;
1232
1387
  }
1233
1388
  async function verifyAgainstServers(upstreams, manifest) {
1389
+ const shapes = /* @__PURE__ */ new Map();
1234
1390
  const available = /* @__PURE__ */ new Map();
1235
1391
  for (const upstream of upstreams) {
1236
- 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)));
1237
1395
  }
1238
1396
  const problems = [];
1239
1397
  const check = (qualified, role, match) => {
@@ -1259,11 +1417,50 @@ async function verifyAgainstServers(upstreams, manifest) {
1259
1417
  if (policy.inverse !== void 0) {
1260
1418
  check(policy.inverse.tool, "inverse", policy.match);
1261
1419
  }
1420
+ if (policy.verify !== void 0) {
1421
+ check(policy.verify.tool, "verify", policy.match);
1422
+ }
1262
1423
  }
1263
1424
  if (problems.length > 0) {
1264
1425
  throw new ManifestError(`the manifest calls tools that do not exist:
1265
1426
  ${problems.join("\n ")}`);
1266
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.`;
1267
1464
  }
1268
1465
 
1269
1466
  // src/proxy/routing.ts
@@ -1390,37 +1587,17 @@ async function start(spec) {
1390
1587
  return { client };
1391
1588
  }
1392
1589
 
1393
- // src/manifest/match.ts
1394
- function toRegExp(pattern) {
1395
- const source = pattern.split("*").map((literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[^.]*");
1396
- return new RegExp(`^${source}$`);
1397
- }
1398
- function literalLength(pattern) {
1399
- return pattern.length - pattern.split("*").length + 1;
1400
- }
1401
- function failClosed(qualifiedName) {
1402
- return { match: qualifiedName, class: "irreversible", gate: "always", refusal: "uncertain" };
1403
- }
1404
- function createPolicyResolver(manifest) {
1405
- const compiled = manifest.tools.map((policy) => ({
1406
- policy,
1407
- test: toRegExp(policy.match),
1408
- specificity: literalLength(policy.match),
1409
- wildcards: policy.match.split("*").length - 1
1410
- })).sort((a, b) => b.specificity - a.specificity || a.wildcards - b.wildcards);
1411
- const cache = /* @__PURE__ */ new Map();
1412
- return {
1413
- resolve(qualifiedName) {
1414
- const cached2 = cache.get(qualifiedName);
1415
- if (cached2 !== void 0) {
1416
- return cached2;
1417
- }
1418
- const hit = compiled.find((candidate) => candidate.test.test(qualifiedName));
1419
- const match = hit === void 0 ? { policy: failClosed(qualifiedName), matched: false } : { policy: hit.policy, matched: true };
1420
- cache.set(qualifiedName, match);
1421
- 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;
1422
1597
  }
1423
- };
1598
+ }
1599
+ merged[IDEMPOTENCY_META_KEY] = key;
1600
+ return merged;
1424
1601
  }
1425
1602
 
1426
1603
  // src/proxy/snapshot.ts
@@ -1582,9 +1759,17 @@ export {
1582
1759
  wasRefused,
1583
1760
  parseManifest,
1584
1761
  loadManifest,
1762
+ createPolicyResolver,
1585
1763
  qualify,
1764
+ pinBlock,
1765
+ toolShapes,
1586
1766
  verifyAgainstServers,
1587
- createPolicyResolver,
1767
+ standing,
1768
+ untested,
1769
+ describeStanding,
1770
+ warnUntested,
1771
+ IDEMPOTENCY_META_KEY,
1772
+ withIdempotencyKey,
1588
1773
  createRouter,
1589
1774
  refusal,
1590
1775
  toPayload,
@@ -1597,4 +1782,4 @@ export {
1597
1782
  observeState,
1598
1783
  connectStdioUpstream
1599
1784
  };
1600
- //# sourceMappingURL=chunk-FUVEHRJP.js.map
1785
+ //# sourceMappingURL=chunk-6X7GPUGJ.js.map