@synapsor/runner 0.1.14 → 0.1.16
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 +33 -1
- package/README.md +9 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/runner.mjs +514 -405
- package/docs/README.md +4 -0
- package/docs/capability-authoring.md +11 -2
- package/docs/conformance.md +8 -0
- package/docs/doctor.md +3 -3
- package/docs/dsl-reference.md +197 -0
- package/docs/migrating-to-synapsor-spec.md +5 -2
- package/docs/production.md +7 -8
- package/docs/release-notes.md +35 -6
- package/docs/runner-config-reference.md +194 -0
- package/docs/security-boundary.md +5 -5
- package/docs/store-lifecycle.md +38 -0
- package/docs/writeback-executors.md +3 -3
- package/examples/mcp-postgres-billing-app-handler/schema.sql +11 -1
- package/examples/reference-support-billing-app/schema.sql +11 -1
- package/examples/support-billing-agent/db/schema.sql +11 -1
- package/examples/support-plan-credit/README.md +2 -2
- package/examples/support-plan-credit/seed/001_seed.sql +11 -1
- package/package.json +1 -1
- package/schemas/synapsor.runner.schema.json +116 -3
- /package/examples/support-plan-credit/{contract.synapsor → contract.synapsor.sql} +0 -0
package/dist/runner.mjs
CHANGED
|
@@ -1381,7 +1381,7 @@ function isRecord(value) {
|
|
|
1381
1381
|
}
|
|
1382
1382
|
|
|
1383
1383
|
// packages/mcp-server/src/index.ts
|
|
1384
|
-
import
|
|
1384
|
+
import crypto2 from "node:crypto";
|
|
1385
1385
|
import fs from "node:fs";
|
|
1386
1386
|
import { createServer } from "node:http";
|
|
1387
1387
|
import path from "node:path";
|
|
@@ -1390,9 +1390,291 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
1390
1390
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
1391
1391
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
1392
1392
|
|
|
1393
|
+
// packages/postgres/src/index.ts
|
|
1394
|
+
import crypto from "node:crypto";
|
|
1395
|
+
import { Pool, types as pgTypes } from "pg";
|
|
1396
|
+
var postgresReceiptMigration = `CREATE TABLE IF NOT EXISTS synapsor_writeback_receipts (
|
|
1397
|
+
idempotency_key text PRIMARY KEY,
|
|
1398
|
+
job_id text UNIQUE NOT NULL,
|
|
1399
|
+
proposal_id text NOT NULL,
|
|
1400
|
+
status text NOT NULL,
|
|
1401
|
+
result_hash text,
|
|
1402
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
1403
|
+
completed_at timestamptz
|
|
1404
|
+
)`;
|
|
1405
|
+
var POSTGRES_TIMESTAMP_OIDS = /* @__PURE__ */ new Set([1114, 1184]);
|
|
1406
|
+
function postgresPoolConfig(connectionString) {
|
|
1407
|
+
return {
|
|
1408
|
+
connectionString,
|
|
1409
|
+
types: {
|
|
1410
|
+
getTypeParser(oid, format) {
|
|
1411
|
+
if ((format ?? "text") === "text" && POSTGRES_TIMESTAMP_OIDS.has(oid)) {
|
|
1412
|
+
return (value) => value;
|
|
1413
|
+
}
|
|
1414
|
+
return pgTypes.getTypeParser(oid, format);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
function createPostgresPool(connectionString) {
|
|
1420
|
+
return new Pool(postgresPoolConfig(connectionString));
|
|
1421
|
+
}
|
|
1422
|
+
function quotePostgresIdentifier(identifier) {
|
|
1423
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
1424
|
+
throw new Error(`unsafe postgres identifier: ${identifier}`);
|
|
1425
|
+
}
|
|
1426
|
+
return `"${identifier}"`;
|
|
1427
|
+
}
|
|
1428
|
+
function buildPostgresUpdate(job) {
|
|
1429
|
+
validatePostgresPatch(job);
|
|
1430
|
+
const values = [];
|
|
1431
|
+
const setFragments = Object.entries(job.patch).map(([column, value]) => {
|
|
1432
|
+
values.push(value);
|
|
1433
|
+
return `${quotePostgresIdentifier(column)} = $${values.length}`;
|
|
1434
|
+
});
|
|
1435
|
+
values.push(job.target.primary_key.value);
|
|
1436
|
+
const pkParam = `$${values.length}`;
|
|
1437
|
+
values.push(job.target.tenant_guard.value);
|
|
1438
|
+
const tenantParam = `$${values.length}`;
|
|
1439
|
+
const where = [
|
|
1440
|
+
`${quotePostgresIdentifier(job.target.primary_key.column)} = ${pkParam}`,
|
|
1441
|
+
`${quotePostgresIdentifier(job.target.tenant_guard.column)} = ${tenantParam}`
|
|
1442
|
+
];
|
|
1443
|
+
if (job.conflict_guard.kind === "version_column") {
|
|
1444
|
+
values.push(job.conflict_guard.expected_value);
|
|
1445
|
+
where.push(`${quotePostgresIdentifier(job.conflict_guard.column)} = $${values.length}`);
|
|
1446
|
+
}
|
|
1447
|
+
const sql = `UPDATE ${quotePostgresIdentifier(job.target.schema)}.${quotePostgresIdentifier(job.target.table)}
|
|
1448
|
+
SET ${setFragments.join(", ")}
|
|
1449
|
+
WHERE ${where.join(" AND ")}`;
|
|
1450
|
+
return { sql, values };
|
|
1451
|
+
}
|
|
1452
|
+
function validatePostgresPatch(job) {
|
|
1453
|
+
const patchColumns = Object.keys(job.patch || {});
|
|
1454
|
+
if (!patchColumns.length) {
|
|
1455
|
+
throw new Error("postgres writeback patch must not be empty");
|
|
1456
|
+
}
|
|
1457
|
+
const allowedColumns = new Set(Array.isArray(job.allowed_columns) ? job.allowed_columns : []);
|
|
1458
|
+
if (allowedColumns.has(job.target.primary_key.column)) {
|
|
1459
|
+
throw new Error("postgres primary key column must not be patch-allowlisted");
|
|
1460
|
+
}
|
|
1461
|
+
if (allowedColumns.has(job.target.tenant_guard.column)) {
|
|
1462
|
+
throw new Error("postgres tenant guard column must not be patch-allowlisted");
|
|
1463
|
+
}
|
|
1464
|
+
for (const column of patchColumns) {
|
|
1465
|
+
if (!allowedColumns.has(column)) {
|
|
1466
|
+
throw new Error(`postgres patch column not allowlisted: ${column}`);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
function resultHash(job, status, version) {
|
|
1471
|
+
return `sha256:${crypto.createHash("sha256").update(JSON.stringify({ job_id: job.job_id, status, version })).digest("hex")}`;
|
|
1472
|
+
}
|
|
1473
|
+
function normalizeVersionValue(value) {
|
|
1474
|
+
if (value instanceof Date) return normalizeVersionValue(value.toISOString());
|
|
1475
|
+
const text = String(value ?? "").trim();
|
|
1476
|
+
const timestamp = text.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}(?::?\d{2})?)?$/i);
|
|
1477
|
+
if (timestamp) {
|
|
1478
|
+
const fraction = (timestamp[3] ?? "").padEnd(6, "0").slice(0, 6);
|
|
1479
|
+
const offset = timestamp[4];
|
|
1480
|
+
if (offset && offset.toUpperCase() !== "Z") {
|
|
1481
|
+
const dateParts = timestamp[1].split("-");
|
|
1482
|
+
const timeParts = timestamp[2].split(":");
|
|
1483
|
+
const year = Number(dateParts[0]);
|
|
1484
|
+
const month = Number(dateParts[1]);
|
|
1485
|
+
const day = Number(dateParts[2]);
|
|
1486
|
+
const hour = Number(timeParts[0]);
|
|
1487
|
+
const minute = Number(timeParts[1]);
|
|
1488
|
+
const second = Number(timeParts[2]);
|
|
1489
|
+
const utc = new Date(Date.UTC(year, month - 1, day, hour, minute, second) - offsetMinutes(offset) * 6e4);
|
|
1490
|
+
return `${utc.getUTCFullYear()}-${pad2(utc.getUTCMonth() + 1)}-${pad2(utc.getUTCDate())} ${pad2(utc.getUTCHours())}:${pad2(utc.getUTCMinutes())}:${pad2(utc.getUTCSeconds())}.${fraction}`;
|
|
1491
|
+
}
|
|
1492
|
+
return `${timestamp[1]} ${timestamp[2]}.${fraction}`;
|
|
1493
|
+
}
|
|
1494
|
+
return text;
|
|
1495
|
+
}
|
|
1496
|
+
function offsetMinutes(offset) {
|
|
1497
|
+
const sign = offset.startsWith("-") ? -1 : 1;
|
|
1498
|
+
const compact = offset.slice(1).replace(":", "");
|
|
1499
|
+
const hours = Number(compact.slice(0, 2));
|
|
1500
|
+
const minutes = Number(compact.slice(2, 4) || "0");
|
|
1501
|
+
return sign * (hours * 60 + minutes);
|
|
1502
|
+
}
|
|
1503
|
+
function pad2(value) {
|
|
1504
|
+
return String(value).padStart(2, "0");
|
|
1505
|
+
}
|
|
1506
|
+
function versionValuesMatch(actual, expected) {
|
|
1507
|
+
return normalizeVersionValue(actual) === normalizeVersionValue(expected);
|
|
1508
|
+
}
|
|
1509
|
+
async function withTransaction(client, fn) {
|
|
1510
|
+
await client.query("BEGIN");
|
|
1511
|
+
try {
|
|
1512
|
+
const result = await fn();
|
|
1513
|
+
await client.query("COMMIT");
|
|
1514
|
+
return result;
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
1517
|
+
throw error;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
async function applyPostgresJob(job, config) {
|
|
1521
|
+
if (config.dryRun) {
|
|
1522
|
+
buildPostgresUpdate(job);
|
|
1523
|
+
return {
|
|
1524
|
+
protocol_version: "1.0",
|
|
1525
|
+
job_id: job.job_id,
|
|
1526
|
+
runner_id: config.runnerId,
|
|
1527
|
+
status: "applied",
|
|
1528
|
+
affected_rows: 0,
|
|
1529
|
+
result_hash: resultHash(job, "dry-run"),
|
|
1530
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
if (!config.databaseUrl) {
|
|
1534
|
+
return failed(job, config, "DATABASE_UNAVAILABLE");
|
|
1535
|
+
}
|
|
1536
|
+
const pool = createPostgresPool(config.databaseUrl);
|
|
1537
|
+
const client = await pool.connect();
|
|
1538
|
+
try {
|
|
1539
|
+
return await applyPostgresJobWithClient(job, config, client);
|
|
1540
|
+
} catch (error) {
|
|
1541
|
+
const message = error instanceof Error ? error.message : "TRANSACTION_FAILED";
|
|
1542
|
+
return failed(job, config, message.includes("MULTI_ROW") ? "MULTI_ROW_WRITE_BLOCKED" : message.includes("VERSION") ? "VERSION_CONFLICT" : "TRANSACTION_FAILED");
|
|
1543
|
+
} finally {
|
|
1544
|
+
client.release();
|
|
1545
|
+
await pool.end();
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
async function applyPostgresJobWithClient(job, config, client) {
|
|
1549
|
+
return await withTransaction(client, async () => {
|
|
1550
|
+
const existing = await claimReceipt(client, job, config);
|
|
1551
|
+
if (existing) return existing;
|
|
1552
|
+
const conflictProjection = job.conflict_guard.kind === "version_column" ? `, ${quotePostgresIdentifier(job.conflict_guard.column)}::text AS "__synapsor_conflict_value"` : "";
|
|
1553
|
+
const row = await client.query(
|
|
1554
|
+
`SELECT *${conflictProjection} FROM ${quotePostgresIdentifier(job.target.schema)}.${quotePostgresIdentifier(job.target.table)}
|
|
1555
|
+
WHERE ${quotePostgresIdentifier(job.target.primary_key.column)} = $1
|
|
1556
|
+
AND ${quotePostgresIdentifier(job.target.tenant_guard.column)} = $2
|
|
1557
|
+
FOR UPDATE`,
|
|
1558
|
+
[job.target.primary_key.value, job.target.tenant_guard.value]
|
|
1559
|
+
);
|
|
1560
|
+
if (!row.rowCount) {
|
|
1561
|
+
const hash2 = resultHash(job, "ROW_NOT_FOUND");
|
|
1562
|
+
await recordReceipt(client, job, "conflict", hash2);
|
|
1563
|
+
return conflict(job, config, "ROW_NOT_FOUND", hash2);
|
|
1564
|
+
}
|
|
1565
|
+
const currentVersion = row.rows[0]?.__synapsor_conflict_value ?? row.rows[0]?.[job.conflict_guard.kind === "version_column" ? job.conflict_guard.column : ""];
|
|
1566
|
+
if (job.conflict_guard.kind === "version_column" && !versionValuesMatch(currentVersion, job.conflict_guard.expected_value)) {
|
|
1567
|
+
const hash2 = resultHash(job, "VERSION_CONFLICT", currentVersion);
|
|
1568
|
+
await recordReceipt(client, job, "conflict", hash2);
|
|
1569
|
+
return conflict(job, config, "VERSION_CONFLICT", hash2);
|
|
1570
|
+
}
|
|
1571
|
+
const update = buildPostgresUpdate(job);
|
|
1572
|
+
const applied = await client.query(update.sql, update.values);
|
|
1573
|
+
if (applied.rowCount !== 1) {
|
|
1574
|
+
throw new Error(applied.rowCount === 0 ? "VERSION_CONFLICT" : "MULTI_ROW_WRITE_BLOCKED");
|
|
1575
|
+
}
|
|
1576
|
+
const hash = resultHash(job, "applied", job.conflict_guard.kind === "version_column" ? job.conflict_guard.expected_value : void 0);
|
|
1577
|
+
await recordReceipt(client, job, "applied", hash);
|
|
1578
|
+
return {
|
|
1579
|
+
protocol_version: "1.0",
|
|
1580
|
+
job_id: job.job_id,
|
|
1581
|
+
runner_id: config.runnerId,
|
|
1582
|
+
status: "applied",
|
|
1583
|
+
affected_rows: 1,
|
|
1584
|
+
result_hash: hash,
|
|
1585
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1586
|
+
};
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
async function claimReceipt(client, job, config) {
|
|
1590
|
+
const claimed = await client.query(
|
|
1591
|
+
`INSERT INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash)
|
|
1592
|
+
VALUES ($1, $2, $3, 'in_progress', NULL)
|
|
1593
|
+
ON CONFLICT (idempotency_key) DO NOTHING
|
|
1594
|
+
RETURNING status, result_hash`,
|
|
1595
|
+
[job.idempotency_key, job.job_id, job.proposal_id]
|
|
1596
|
+
);
|
|
1597
|
+
if (claimed.rowCount === 1) return void 0;
|
|
1598
|
+
const receipt = await client.query(
|
|
1599
|
+
"SELECT status, result_hash FROM synapsor_writeback_receipts WHERE idempotency_key = $1 FOR UPDATE",
|
|
1600
|
+
[job.idempotency_key]
|
|
1601
|
+
);
|
|
1602
|
+
const row = receipt.rows[0];
|
|
1603
|
+
if (!row) return failed(job, config, "IDEMPOTENCY_RECEIPT_UNAVAILABLE");
|
|
1604
|
+
return resultFromExistingReceipt(job, config, row.status, row.result_hash);
|
|
1605
|
+
}
|
|
1606
|
+
function resultFromExistingReceipt(job, config, status, hash) {
|
|
1607
|
+
const result_hash = typeof hash === "string" && hash ? hash : resultHash(job, "idempotent");
|
|
1608
|
+
if (status === "applied" || status === "already_applied") {
|
|
1609
|
+
return {
|
|
1610
|
+
protocol_version: "1.0",
|
|
1611
|
+
job_id: job.job_id,
|
|
1612
|
+
runner_id: config.runnerId,
|
|
1613
|
+
status: "already_applied",
|
|
1614
|
+
affected_rows: 0,
|
|
1615
|
+
result_hash,
|
|
1616
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
if (status === "conflict") return conflict(job, config, "IDEMPOTENT_CONFLICT", result_hash);
|
|
1620
|
+
if (status === "failed") return failed(job, config, "IDEMPOTENT_FAILED");
|
|
1621
|
+
return failed(job, config, "IDEMPOTENCY_RECEIPT_IN_PROGRESS");
|
|
1622
|
+
}
|
|
1623
|
+
async function recordReceipt(client, job, status, hash) {
|
|
1624
|
+
const result = await client.query(
|
|
1625
|
+
`UPDATE synapsor_writeback_receipts
|
|
1626
|
+
SET status = $2, result_hash = $3, completed_at = now()
|
|
1627
|
+
WHERE idempotency_key = $1`,
|
|
1628
|
+
[job.idempotency_key, status, hash]
|
|
1629
|
+
);
|
|
1630
|
+
if (result.rowCount !== 1) throw new Error("IDEMPOTENCY_RECEIPT_UNAVAILABLE");
|
|
1631
|
+
}
|
|
1632
|
+
function conflict(job, config, code, result_hash) {
|
|
1633
|
+
return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "conflict", affected_rows: 0, error_code: code, result_hash, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1634
|
+
}
|
|
1635
|
+
function failed(job, config, code) {
|
|
1636
|
+
return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "failed", error_code: code, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1637
|
+
}
|
|
1638
|
+
var postgresAdapter = {
|
|
1639
|
+
async doctor(config) {
|
|
1640
|
+
if (!config.databaseUrl) return { ok: false, details: { error: "database URL required; local config apply reads source.write_url_env, legacy workers use SYNAPSOR_DATABASE_URL" } };
|
|
1641
|
+
const pool = createPostgresPool(config.databaseUrl);
|
|
1642
|
+
const client = await pool.connect();
|
|
1643
|
+
try {
|
|
1644
|
+
const result = await client.query("SELECT version()");
|
|
1645
|
+
await client.query("BEGIN");
|
|
1646
|
+
try {
|
|
1647
|
+
const doctorId = `doctor-${Date.now()}`;
|
|
1648
|
+
await client.query(
|
|
1649
|
+
`INSERT INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash, completed_at)
|
|
1650
|
+
VALUES ($1, $2, $3, $4, $5, now())`,
|
|
1651
|
+
[doctorId, doctorId, "doctor", "doctor", "sha256:doctor"]
|
|
1652
|
+
);
|
|
1653
|
+
await client.query("ROLLBACK");
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
1656
|
+
throw error;
|
|
1657
|
+
}
|
|
1658
|
+
return {
|
|
1659
|
+
ok: true,
|
|
1660
|
+
details: {
|
|
1661
|
+
version: result.rows[0]?.version,
|
|
1662
|
+
receipt_table: "ready",
|
|
1663
|
+
write_permission_rollback: true,
|
|
1664
|
+
dry_run: config.dryRun
|
|
1665
|
+
}
|
|
1666
|
+
};
|
|
1667
|
+
} finally {
|
|
1668
|
+
client.release();
|
|
1669
|
+
await pool.end();
|
|
1670
|
+
}
|
|
1671
|
+
},
|
|
1672
|
+
apply: applyPostgresJob
|
|
1673
|
+
};
|
|
1674
|
+
|
|
1393
1675
|
// packages/proposal-store/src/index.ts
|
|
1394
1676
|
import { DatabaseSync } from "node:sqlite";
|
|
1395
|
-
import { mkdirSync } from "node:fs";
|
|
1677
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
1396
1678
|
import { dirname, resolve } from "node:path";
|
|
1397
1679
|
var ProposalStoreError = class extends Error {
|
|
1398
1680
|
constructor(code, message) {
|
|
@@ -1408,9 +1690,17 @@ var ProposalStore = class {
|
|
|
1408
1690
|
constructor(path4 = ":memory:") {
|
|
1409
1691
|
this.path = path4;
|
|
1410
1692
|
if (path4 !== ":memory:") {
|
|
1411
|
-
mkdirSync(dirname(resolve(path4)), { recursive: true });
|
|
1693
|
+
mkdirSync(dirname(resolve(path4)), { recursive: true, mode: 448 });
|
|
1412
1694
|
}
|
|
1413
1695
|
this.db = new DatabaseSync(path4);
|
|
1696
|
+
if (path4 !== ":memory:" && process.platform !== "win32") {
|
|
1697
|
+
try {
|
|
1698
|
+
chmodSync(path4, 384);
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
process.stderr.write(`warning: unable to restrict Synapsor store permissions to 0600: ${error instanceof Error ? error.message : String(error)}
|
|
1701
|
+
`);
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1414
1704
|
this.migrate();
|
|
1415
1705
|
}
|
|
1416
1706
|
close() {
|
|
@@ -1802,6 +2092,18 @@ var ProposalStore = class {
|
|
|
1802
2092
|
}
|
|
1803
2093
|
return existing;
|
|
1804
2094
|
}
|
|
2095
|
+
const active = this.findActiveProposal({
|
|
2096
|
+
tenant_id: changeSet.scope.tenant_id,
|
|
2097
|
+
action: changeSet.action,
|
|
2098
|
+
business_object: changeSet.scope.business_object,
|
|
2099
|
+
object_id: changeSet.scope.object_id
|
|
2100
|
+
});
|
|
2101
|
+
if (active) {
|
|
2102
|
+
throw new ProposalStoreError(
|
|
2103
|
+
"PROPOSAL_ALREADY_EXISTS",
|
|
2104
|
+
`active proposal ${active.proposal_id} is ${active.state} for ${active.business_object}:${active.object_id}`
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
1805
2107
|
const state = stateFromChangeSet(changeSet);
|
|
1806
2108
|
const now = changeSet.created_at || (/* @__PURE__ */ new Date()).toISOString();
|
|
1807
2109
|
const insert = this.db.prepare(`
|
|
@@ -1867,6 +2169,19 @@ var ProposalStore = class {
|
|
|
1867
2169
|
const row = this.db.prepare("SELECT * FROM proposals WHERE proposal_id = ?").get(proposalId);
|
|
1868
2170
|
return rowToProposal(row);
|
|
1869
2171
|
}
|
|
2172
|
+
findActiveProposal(input) {
|
|
2173
|
+
const row = this.db.prepare(`
|
|
2174
|
+
SELECT * FROM proposals
|
|
2175
|
+
WHERE tenant_id = ?
|
|
2176
|
+
AND action = ?
|
|
2177
|
+
AND business_object = ?
|
|
2178
|
+
AND object_id = ?
|
|
2179
|
+
AND state IN ('pending_review', 'approved', 'pending_worker')
|
|
2180
|
+
ORDER BY created_at DESC
|
|
2181
|
+
LIMIT 1
|
|
2182
|
+
`).get(input.tenant_id, input.action, input.business_object, input.object_id);
|
|
2183
|
+
return rowToProposal(row);
|
|
2184
|
+
}
|
|
1870
2185
|
listProposals(filters) {
|
|
1871
2186
|
if (typeof filters === "string") filters = { state: filters };
|
|
1872
2187
|
const query = buildProposalQuery(filters ?? {});
|
|
@@ -3635,7 +3950,6 @@ function sortJson(value) {
|
|
|
3635
3950
|
|
|
3636
3951
|
// packages/mcp-server/src/index.ts
|
|
3637
3952
|
import mysql from "mysql2/promise";
|
|
3638
|
-
import { Pool } from "pg";
|
|
3639
3953
|
import { z as z2 } from "zod";
|
|
3640
3954
|
var RESERVED_MODEL_ARGS = /* @__PURE__ */ new Set([
|
|
3641
3955
|
"tenant_id",
|
|
@@ -4128,7 +4442,7 @@ async function handleStreamableHttpMcpRequest(input) {
|
|
|
4128
4442
|
}
|
|
4129
4443
|
let session;
|
|
4130
4444
|
const transport = new StreamableHTTPServerTransport({
|
|
4131
|
-
sessionIdGenerator: () =>
|
|
4445
|
+
sessionIdGenerator: () => crypto2.randomUUID(),
|
|
4132
4446
|
onsessioninitialized: (newSessionId) => {
|
|
4133
4447
|
if (session) {
|
|
4134
4448
|
session.sessionId = newSessionId;
|
|
@@ -4253,7 +4567,7 @@ function validBearerToken(header, expected) {
|
|
|
4253
4567
|
const actual = header.slice("Bearer ".length);
|
|
4254
4568
|
const actualBuffer = Buffer.from(actual);
|
|
4255
4569
|
const expectedBuffer = Buffer.from(expected);
|
|
4256
|
-
return actualBuffer.length === expectedBuffer.length &&
|
|
4570
|
+
return actualBuffer.length === expectedBuffer.length && crypto2.timingSafeEqual(actualBuffer, expectedBuffer);
|
|
4257
4571
|
}
|
|
4258
4572
|
function headerValue(value) {
|
|
4259
4573
|
return Array.isArray(value) ? value[0] : value;
|
|
@@ -4311,7 +4625,7 @@ function toolNameExposureMap(canonicalNames, style) {
|
|
|
4311
4625
|
return exposedByCanonical;
|
|
4312
4626
|
}
|
|
4313
4627
|
function shortToolHash(value) {
|
|
4314
|
-
return
|
|
4628
|
+
return crypto2.createHash("sha256").update(value).digest("hex").slice(0, 8);
|
|
4315
4629
|
}
|
|
4316
4630
|
function setCorsHeaders(response, corsOrigin) {
|
|
4317
4631
|
if (corsOrigin) {
|
|
@@ -4611,7 +4925,28 @@ async function callConfiguredTool(input) {
|
|
|
4611
4925
|
queryFingerprint,
|
|
4612
4926
|
objectId
|
|
4613
4927
|
});
|
|
4614
|
-
const
|
|
4928
|
+
const activeProposal = input.store.findActiveProposal({
|
|
4929
|
+
tenant_id: context.tenant_id,
|
|
4930
|
+
action: capability.name,
|
|
4931
|
+
business_object: capability.target.table,
|
|
4932
|
+
object_id: objectId
|
|
4933
|
+
});
|
|
4934
|
+
if (activeProposal) throw proposalAlreadyExists(activeProposal);
|
|
4935
|
+
let proposal;
|
|
4936
|
+
try {
|
|
4937
|
+
proposal = input.store.createProposal(changeSet);
|
|
4938
|
+
} catch (error) {
|
|
4939
|
+
if (error instanceof ProposalStoreError && error.code === "PROPOSAL_ALREADY_EXISTS") {
|
|
4940
|
+
const existing = input.store.findActiveProposal({
|
|
4941
|
+
tenant_id: context.tenant_id,
|
|
4942
|
+
action: capability.name,
|
|
4943
|
+
business_object: capability.target.table,
|
|
4944
|
+
object_id: objectId
|
|
4945
|
+
});
|
|
4946
|
+
if (existing) throw proposalAlreadyExists(existing);
|
|
4947
|
+
}
|
|
4948
|
+
throw error;
|
|
4949
|
+
}
|
|
4615
4950
|
const approvalResult = maybeAutoApproveProposal({
|
|
4616
4951
|
config: input.config,
|
|
4617
4952
|
capability,
|
|
@@ -4893,6 +5228,9 @@ function safeToolError(error) {
|
|
|
4893
5228
|
if (runtimeCode === "PROPOSALS_DISABLED") {
|
|
4894
5229
|
return { code: "APPROVAL_REQUIRED", message: "Proposal tools are disabled for this runner mode.", retryable: false };
|
|
4895
5230
|
}
|
|
5231
|
+
if (runtimeCode === "PROPOSAL_ALREADY_EXISTS") {
|
|
5232
|
+
return { code: "PROPOSAL_ALREADY_EXISTS", message: error instanceof Error ? error.message : "An active proposal already exists.", retryable: false };
|
|
5233
|
+
}
|
|
4896
5234
|
if (runtimeCode && (runtimeCode.startsWith("ARGUMENT_") || runtimeCode === "LOOKUP_ARG_MISSING" || runtimeCode === "MODEL_CANNOT_OVERRIDE_BINDING" || runtimeCode === "TRUSTED_BINDING_MISSING" || runtimeCode === "TRUSTED_CONTEXT_MISSING")) {
|
|
4897
5235
|
return { code: "INVALID_ARGUMENT", message: "The tool input or trusted context binding is invalid.", retryable: false };
|
|
4898
5236
|
}
|
|
@@ -4920,6 +5258,7 @@ function buildChangeSet(input) {
|
|
|
4920
5258
|
const writebackMode = capabilityWritebackMode2(input.capability);
|
|
4921
5259
|
const changeSetWritebackMode = writebackMode === "none" ? "read_only" : "trusted_worker_required";
|
|
4922
5260
|
const writebackExecutor = writebackMode === "none" ? "none" : writebackMode === "cloud_worker" ? "cloud_worker" : writebackMode === "direct_sql" ? "sql_update" : capabilityWritebackExecutor(input.capability);
|
|
5261
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4923
5262
|
const proposalCore = {
|
|
4924
5263
|
schema_version: protocolVersions.changeSet,
|
|
4925
5264
|
proposal_id: stableId("wrp", {
|
|
@@ -4927,7 +5266,8 @@ function buildChangeSet(input) {
|
|
|
4927
5266
|
tenant: input.context.tenant_id,
|
|
4928
5267
|
object: input.objectId,
|
|
4929
5268
|
before,
|
|
4930
|
-
patch
|
|
5269
|
+
patch,
|
|
5270
|
+
created_at: createdAt
|
|
4931
5271
|
}),
|
|
4932
5272
|
proposal_version: 1,
|
|
4933
5273
|
action: input.capability.name,
|
|
@@ -4973,7 +5313,7 @@ function buildChangeSet(input) {
|
|
|
4973
5313
|
executor: writebackExecutor
|
|
4974
5314
|
},
|
|
4975
5315
|
source_database_mutated: false,
|
|
4976
|
-
created_at:
|
|
5316
|
+
created_at: createdAt
|
|
4977
5317
|
};
|
|
4978
5318
|
return {
|
|
4979
5319
|
...proposalCore,
|
|
@@ -4995,7 +5335,7 @@ async function readCurrentRow(input) {
|
|
|
4995
5335
|
async function readPostgresRow(input) {
|
|
4996
5336
|
const connectionString = envValue(input.env, input.source.read_url_env);
|
|
4997
5337
|
if (!connectionString) throw new McpRuntimeError("SOURCE_CREDENTIAL_MISSING", `${input.source.read_url_env} is not set.`);
|
|
4998
|
-
const pool =
|
|
5338
|
+
const pool = createPostgresPool(connectionString);
|
|
4999
5339
|
const client = await pool.connect();
|
|
5000
5340
|
try {
|
|
5001
5341
|
const query = buildSelect(input.capability, "$");
|
|
@@ -5279,309 +5619,94 @@ function scalar2(value) {
|
|
|
5279
5619
|
if (typeof value === "bigint") return Number(value);
|
|
5280
5620
|
return String(value);
|
|
5281
5621
|
}
|
|
5282
|
-
function
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
}
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
}
|
|
5296
|
-
return { ok: false, code: error.code, error: error.message };
|
|
5297
|
-
}
|
|
5298
|
-
return { ok: false, code: "MCP_TOOL_FAILED", error: error instanceof Error ? error.message : String(error) };
|
|
5299
|
-
}
|
|
5300
|
-
|
|
5301
|
-
// packages/mysql/src/index.ts
|
|
5302
|
-
import crypto2 from "node:crypto";
|
|
5303
|
-
import mysql2 from "mysql2/promise";
|
|
5304
|
-
var mysqlReceiptMigration = `CREATE TABLE IF NOT EXISTS synapsor_writeback_receipts (
|
|
5305
|
-
idempotency_key varchar(255) PRIMARY KEY,
|
|
5306
|
-
job_id varchar(255) UNIQUE NOT NULL,
|
|
5307
|
-
proposal_id varchar(512) NOT NULL,
|
|
5308
|
-
status varchar(64) NOT NULL,
|
|
5309
|
-
result_hash varchar(128),
|
|
5310
|
-
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
5311
|
-
completed_at timestamp NULL
|
|
5312
|
-
)`;
|
|
5313
|
-
function quoteMysqlIdentifier(identifier) {
|
|
5314
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
5315
|
-
throw new Error(`unsafe mysql identifier: ${identifier}`);
|
|
5316
|
-
}
|
|
5317
|
-
return `\`${identifier}\``;
|
|
5318
|
-
}
|
|
5319
|
-
function buildMysqlUpdate(job) {
|
|
5320
|
-
validateMysqlPatch(job);
|
|
5321
|
-
const values = [];
|
|
5322
|
-
const setFragments = Object.entries(job.patch).map(([column, value]) => {
|
|
5323
|
-
values.push(value);
|
|
5324
|
-
return `${quoteMysqlIdentifier(column)} = ?`;
|
|
5325
|
-
});
|
|
5326
|
-
values.push(job.target.primary_key.value, job.target.tenant_guard.value);
|
|
5327
|
-
const where = [
|
|
5328
|
-
`${quoteMysqlIdentifier(job.target.primary_key.column)} = ?`,
|
|
5329
|
-
`${quoteMysqlIdentifier(job.target.tenant_guard.column)} = ?`
|
|
5330
|
-
];
|
|
5331
|
-
if (job.conflict_guard.kind === "version_column") {
|
|
5332
|
-
values.push(job.conflict_guard.expected_value);
|
|
5333
|
-
where.push(`${quoteMysqlIdentifier(job.conflict_guard.column)} = ?`);
|
|
5334
|
-
}
|
|
5335
|
-
const sql = `UPDATE ${quoteMysqlIdentifier(job.target.schema)}.${quoteMysqlIdentifier(job.target.table)}
|
|
5336
|
-
SET ${setFragments.join(", ")}
|
|
5337
|
-
WHERE ${where.join(" AND ")}`;
|
|
5338
|
-
return { sql, values };
|
|
5339
|
-
}
|
|
5340
|
-
function validateMysqlPatch(job) {
|
|
5341
|
-
const patchColumns = Object.keys(job.patch || {});
|
|
5342
|
-
if (!patchColumns.length) {
|
|
5343
|
-
throw new Error("mysql writeback patch must not be empty");
|
|
5344
|
-
}
|
|
5345
|
-
const allowedColumns = new Set(Array.isArray(job.allowed_columns) ? job.allowed_columns : []);
|
|
5346
|
-
if (allowedColumns.has(job.target.primary_key.column)) {
|
|
5347
|
-
throw new Error("mysql primary key column must not be patch-allowlisted");
|
|
5348
|
-
}
|
|
5349
|
-
if (allowedColumns.has(job.target.tenant_guard.column)) {
|
|
5350
|
-
throw new Error("mysql tenant guard column must not be patch-allowlisted");
|
|
5351
|
-
}
|
|
5352
|
-
for (const column of patchColumns) {
|
|
5353
|
-
if (!allowedColumns.has(column)) {
|
|
5354
|
-
throw new Error(`mysql patch column not allowlisted: ${column}`);
|
|
5355
|
-
}
|
|
5356
|
-
}
|
|
5357
|
-
}
|
|
5358
|
-
function resultHash(job, status, version) {
|
|
5359
|
-
return `sha256:${crypto2.createHash("sha256").update(JSON.stringify({ job_id: job.job_id, status, version })).digest("hex")}`;
|
|
5360
|
-
}
|
|
5361
|
-
function normalizeVersionValue(value) {
|
|
5362
|
-
if (value instanceof Date) return normalizeVersionValue(value.toISOString());
|
|
5363
|
-
const text = String(value ?? "").trim();
|
|
5364
|
-
const timestamp = text.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}(?::?\d{2})?)?$/i);
|
|
5365
|
-
if (timestamp) {
|
|
5366
|
-
const fraction = (timestamp[3] ?? "").padEnd(6, "0").slice(0, 6);
|
|
5367
|
-
const offset = timestamp[4];
|
|
5368
|
-
if (offset && offset.toUpperCase() !== "Z") {
|
|
5369
|
-
const dateParts = timestamp[1].split("-");
|
|
5370
|
-
const timeParts = timestamp[2].split(":");
|
|
5371
|
-
const year = Number(dateParts[0]);
|
|
5372
|
-
const month = Number(dateParts[1]);
|
|
5373
|
-
const day = Number(dateParts[2]);
|
|
5374
|
-
const hour = Number(timeParts[0]);
|
|
5375
|
-
const minute = Number(timeParts[1]);
|
|
5376
|
-
const second = Number(timeParts[2]);
|
|
5377
|
-
const utc = new Date(Date.UTC(year, month - 1, day, hour, minute, second) - offsetMinutes(offset) * 6e4);
|
|
5378
|
-
return `${utc.getUTCFullYear()}-${pad2(utc.getUTCMonth() + 1)}-${pad2(utc.getUTCDate())} ${pad2(utc.getUTCHours())}:${pad2(utc.getUTCMinutes())}:${pad2(utc.getUTCSeconds())}.${fraction}`;
|
|
5379
|
-
}
|
|
5380
|
-
return `${timestamp[1]} ${timestamp[2]}.${fraction}`;
|
|
5381
|
-
}
|
|
5382
|
-
return text;
|
|
5383
|
-
}
|
|
5384
|
-
function offsetMinutes(offset) {
|
|
5385
|
-
const sign = offset.startsWith("-") ? -1 : 1;
|
|
5386
|
-
const compact = offset.slice(1).replace(":", "");
|
|
5387
|
-
const hours = Number(compact.slice(0, 2));
|
|
5388
|
-
const minutes = Number(compact.slice(2, 4) || "0");
|
|
5389
|
-
return sign * (hours * 60 + minutes);
|
|
5390
|
-
}
|
|
5391
|
-
function pad2(value) {
|
|
5392
|
-
return String(value).padStart(2, "0");
|
|
5393
|
-
}
|
|
5394
|
-
function versionValuesMatch(actual, expected) {
|
|
5395
|
-
return normalizeVersionValue(actual) === normalizeVersionValue(expected);
|
|
5396
|
-
}
|
|
5397
|
-
async function applyMysqlJob(job, config) {
|
|
5398
|
-
if (config.dryRun) {
|
|
5399
|
-
buildMysqlUpdate(job);
|
|
5400
|
-
return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "applied", affected_rows: 0, result_hash: resultHash(job, "dry-run"), completed_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5401
|
-
}
|
|
5402
|
-
if (!config.databaseUrl) return failed(job, config, "DATABASE_UNAVAILABLE");
|
|
5403
|
-
const connection = await mysql2.createConnection({ uri: config.databaseUrl, dateStrings: true });
|
|
5404
|
-
try {
|
|
5405
|
-
return await applyMysqlJobWithConnection(job, config, connection);
|
|
5406
|
-
} catch (error) {
|
|
5407
|
-
const message = error instanceof Error ? error.message : "TRANSACTION_FAILED";
|
|
5408
|
-
return failed(job, config, message.includes("MULTI_ROW") ? "MULTI_ROW_WRITE_BLOCKED" : message.includes("VERSION") ? "VERSION_CONFLICT" : "TRANSACTION_FAILED");
|
|
5409
|
-
} finally {
|
|
5410
|
-
await connection.end();
|
|
5411
|
-
}
|
|
5412
|
-
}
|
|
5413
|
-
async function applyMysqlJobWithConnection(job, config, connection) {
|
|
5414
|
-
await connection.beginTransaction();
|
|
5415
|
-
try {
|
|
5416
|
-
await connection.query(mysqlReceiptMigration);
|
|
5417
|
-
const existing = await claimReceipt(connection, job, config);
|
|
5418
|
-
if (existing) {
|
|
5419
|
-
await connection.commit();
|
|
5420
|
-
return existing;
|
|
5421
|
-
}
|
|
5422
|
-
const [rows] = await connection.query(
|
|
5423
|
-
`SELECT * FROM ${quoteMysqlIdentifier(job.target.schema)}.${quoteMysqlIdentifier(job.target.table)}
|
|
5424
|
-
WHERE ${quoteMysqlIdentifier(job.target.primary_key.column)} = ?
|
|
5425
|
-
AND ${quoteMysqlIdentifier(job.target.tenant_guard.column)} = ?
|
|
5426
|
-
FOR UPDATE`,
|
|
5427
|
-
[job.target.primary_key.value, job.target.tenant_guard.value]
|
|
5428
|
-
);
|
|
5429
|
-
if (!rows[0]) {
|
|
5430
|
-
const hash2 = resultHash(job, "ROW_NOT_FOUND");
|
|
5431
|
-
await recordReceipt(connection, job, "conflict", hash2);
|
|
5432
|
-
await connection.commit();
|
|
5433
|
-
return conflict(job, config, "ROW_NOT_FOUND", hash2);
|
|
5434
|
-
}
|
|
5435
|
-
if (job.conflict_guard.kind === "version_column" && !versionValuesMatch(rows[0][job.conflict_guard.column], job.conflict_guard.expected_value)) {
|
|
5436
|
-
const hash2 = resultHash(job, "VERSION_CONFLICT", rows[0][job.conflict_guard.column]);
|
|
5437
|
-
await recordReceipt(connection, job, "conflict", hash2);
|
|
5438
|
-
await connection.commit();
|
|
5439
|
-
return conflict(job, config, "VERSION_CONFLICT", hash2);
|
|
5440
|
-
}
|
|
5441
|
-
const update = buildMysqlUpdate(job);
|
|
5442
|
-
const [result] = await connection.query(update.sql, update.values);
|
|
5443
|
-
if (result.affectedRows !== 1) throw new Error(result.affectedRows === 0 ? "VERSION_CONFLICT" : "MULTI_ROW_WRITE_BLOCKED");
|
|
5444
|
-
const hash = resultHash(job, "applied", job.conflict_guard.kind === "version_column" ? job.conflict_guard.expected_value : void 0);
|
|
5445
|
-
await recordReceipt(connection, job, "applied", hash);
|
|
5446
|
-
await connection.commit();
|
|
5447
|
-
return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "applied", affected_rows: 1, result_hash: hash, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5448
|
-
} catch (error) {
|
|
5449
|
-
await connection.rollback().catch(() => void 0);
|
|
5450
|
-
throw error;
|
|
5451
|
-
}
|
|
5452
|
-
}
|
|
5453
|
-
async function claimReceipt(connection, job, config) {
|
|
5454
|
-
const [claim] = await connection.query(
|
|
5455
|
-
`INSERT IGNORE INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash)
|
|
5456
|
-
VALUES (?, ?, ?, 'in_progress', NULL)`,
|
|
5457
|
-
[job.idempotency_key, job.job_id, job.proposal_id]
|
|
5458
|
-
);
|
|
5459
|
-
if (claim.affectedRows === 1) return void 0;
|
|
5460
|
-
const [receiptRows] = await connection.query(
|
|
5461
|
-
"SELECT status, result_hash FROM synapsor_writeback_receipts WHERE idempotency_key = ? FOR UPDATE",
|
|
5462
|
-
[job.idempotency_key]
|
|
5463
|
-
);
|
|
5464
|
-
const row = receiptRows[0];
|
|
5465
|
-
if (!row) return failed(job, config, "IDEMPOTENCY_RECEIPT_UNAVAILABLE");
|
|
5466
|
-
return resultFromExistingReceipt(job, config, row.status, row.result_hash);
|
|
5467
|
-
}
|
|
5468
|
-
function resultFromExistingReceipt(job, config, status, hash) {
|
|
5469
|
-
const result_hash = typeof hash === "string" && hash ? hash : resultHash(job, "idempotent");
|
|
5470
|
-
if (status === "applied" || status === "already_applied") {
|
|
5471
|
-
return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "already_applied", affected_rows: 0, result_hash, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5472
|
-
}
|
|
5473
|
-
if (status === "conflict") return conflict(job, config, "IDEMPOTENT_CONFLICT", result_hash);
|
|
5474
|
-
if (status === "failed") return failed(job, config, "IDEMPOTENT_FAILED");
|
|
5475
|
-
return failed(job, config, "IDEMPOTENCY_RECEIPT_IN_PROGRESS");
|
|
5476
|
-
}
|
|
5477
|
-
async function recordReceipt(connection, job, status, hash) {
|
|
5478
|
-
const [result] = await connection.query(
|
|
5479
|
-
`UPDATE synapsor_writeback_receipts
|
|
5480
|
-
SET status = ?, result_hash = ?, completed_at = CURRENT_TIMESTAMP
|
|
5481
|
-
WHERE idempotency_key = ?`,
|
|
5482
|
-
[status, hash, job.idempotency_key]
|
|
5622
|
+
function proposalAlreadyExists(existing) {
|
|
5623
|
+
process.stderr.write(`${JSON.stringify({
|
|
5624
|
+
level: "warn",
|
|
5625
|
+
event: "proposal_already_exists",
|
|
5626
|
+
proposal_id: existing.proposal_id,
|
|
5627
|
+
state: existing.state,
|
|
5628
|
+
capability: existing.action,
|
|
5629
|
+
object_id: existing.object_id
|
|
5630
|
+
})}
|
|
5631
|
+
`);
|
|
5632
|
+
return new McpRuntimeError(
|
|
5633
|
+
"PROPOSAL_ALREADY_EXISTS",
|
|
5634
|
+
`Active proposal ${existing.proposal_id} is already ${existing.state} for this object. Inspect or resolve it before proposing again.`
|
|
5483
5635
|
);
|
|
5484
|
-
if (result.affectedRows !== 1) throw new Error("IDEMPOTENCY_RECEIPT_UNAVAILABLE");
|
|
5485
5636
|
}
|
|
5486
|
-
function
|
|
5487
|
-
return {
|
|
5637
|
+
function stableId(prefix, input) {
|
|
5638
|
+
return `${prefix}_${crypto2.createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 20)}`;
|
|
5488
5639
|
}
|
|
5489
|
-
function
|
|
5490
|
-
return {
|
|
5640
|
+
function hashJson(input) {
|
|
5641
|
+
return `sha256:${crypto2.createHash("sha256").update(JSON.stringify(input)).digest("hex")}`;
|
|
5491
5642
|
}
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
await connection.beginTransaction();
|
|
5500
|
-
try {
|
|
5501
|
-
const doctorId = `doctor-${Date.now()}`;
|
|
5502
|
-
await connection.query(
|
|
5503
|
-
`INSERT INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash, completed_at)
|
|
5504
|
-
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
|
5505
|
-
[doctorId, doctorId, "doctor", "doctor", "sha256:doctor"]
|
|
5506
|
-
);
|
|
5507
|
-
await connection.rollback();
|
|
5508
|
-
} catch (error) {
|
|
5509
|
-
await connection.rollback().catch(() => void 0);
|
|
5510
|
-
throw error;
|
|
5511
|
-
}
|
|
5512
|
-
return {
|
|
5513
|
-
ok: true,
|
|
5514
|
-
details: {
|
|
5515
|
-
version: rows[0]?.version,
|
|
5516
|
-
receipt_table: "ready",
|
|
5517
|
-
write_permission_rollback: true,
|
|
5518
|
-
dry_run: config.dryRun
|
|
5519
|
-
}
|
|
5520
|
-
};
|
|
5521
|
-
} finally {
|
|
5522
|
-
await connection.end();
|
|
5643
|
+
function isRecord4(value) {
|
|
5644
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5645
|
+
}
|
|
5646
|
+
function toolErrorPayload(error) {
|
|
5647
|
+
if (error instanceof McpRuntimeError) {
|
|
5648
|
+
if (error.code === "LOCAL_STORE_UNAVAILABLE") {
|
|
5649
|
+
return { ok: false, code: "TEMPORARILY_UNAVAILABLE", error: "The local runner store is temporarily unavailable. Restart the runner or recreate the store before retrying." };
|
|
5523
5650
|
}
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
};
|
|
5651
|
+
return { ok: false, code: error.code, error: error.message };
|
|
5652
|
+
}
|
|
5653
|
+
return { ok: false, code: "MCP_TOOL_FAILED", error: error instanceof Error ? error.message : String(error) };
|
|
5654
|
+
}
|
|
5527
5655
|
|
|
5528
|
-
// packages/
|
|
5656
|
+
// packages/mysql/src/index.ts
|
|
5529
5657
|
import crypto3 from "node:crypto";
|
|
5530
|
-
import
|
|
5531
|
-
var
|
|
5532
|
-
idempotency_key
|
|
5533
|
-
job_id
|
|
5534
|
-
proposal_id
|
|
5535
|
-
status
|
|
5536
|
-
result_hash
|
|
5537
|
-
created_at
|
|
5538
|
-
completed_at
|
|
5658
|
+
import mysql2 from "mysql2/promise";
|
|
5659
|
+
var mysqlReceiptMigration = `CREATE TABLE IF NOT EXISTS synapsor_writeback_receipts (
|
|
5660
|
+
idempotency_key varchar(255) PRIMARY KEY,
|
|
5661
|
+
job_id varchar(255) UNIQUE NOT NULL,
|
|
5662
|
+
proposal_id varchar(512) NOT NULL,
|
|
5663
|
+
status varchar(64) NOT NULL,
|
|
5664
|
+
result_hash varchar(128),
|
|
5665
|
+
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
5666
|
+
completed_at timestamp NULL
|
|
5539
5667
|
)`;
|
|
5540
|
-
function
|
|
5668
|
+
function quoteMysqlIdentifier(identifier) {
|
|
5541
5669
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
5542
|
-
throw new Error(`unsafe
|
|
5670
|
+
throw new Error(`unsafe mysql identifier: ${identifier}`);
|
|
5543
5671
|
}
|
|
5544
|
-
return
|
|
5672
|
+
return `\`${identifier}\``;
|
|
5545
5673
|
}
|
|
5546
|
-
function
|
|
5547
|
-
|
|
5674
|
+
function buildMysqlUpdate(job) {
|
|
5675
|
+
validateMysqlPatch(job);
|
|
5548
5676
|
const values = [];
|
|
5549
5677
|
const setFragments = Object.entries(job.patch).map(([column, value]) => {
|
|
5550
5678
|
values.push(value);
|
|
5551
|
-
return `${
|
|
5679
|
+
return `${quoteMysqlIdentifier(column)} = ?`;
|
|
5552
5680
|
});
|
|
5553
|
-
values.push(job.target.primary_key.value);
|
|
5554
|
-
const pkParam = `$${values.length}`;
|
|
5555
|
-
values.push(job.target.tenant_guard.value);
|
|
5556
|
-
const tenantParam = `$${values.length}`;
|
|
5681
|
+
values.push(job.target.primary_key.value, job.target.tenant_guard.value);
|
|
5557
5682
|
const where = [
|
|
5558
|
-
`${
|
|
5559
|
-
`${
|
|
5683
|
+
`${quoteMysqlIdentifier(job.target.primary_key.column)} = ?`,
|
|
5684
|
+
`${quoteMysqlIdentifier(job.target.tenant_guard.column)} = ?`
|
|
5560
5685
|
];
|
|
5561
5686
|
if (job.conflict_guard.kind === "version_column") {
|
|
5562
5687
|
values.push(job.conflict_guard.expected_value);
|
|
5563
|
-
where.push(`${
|
|
5688
|
+
where.push(`${quoteMysqlIdentifier(job.conflict_guard.column)} = ?`);
|
|
5564
5689
|
}
|
|
5565
|
-
const sql = `UPDATE ${
|
|
5690
|
+
const sql = `UPDATE ${quoteMysqlIdentifier(job.target.schema)}.${quoteMysqlIdentifier(job.target.table)}
|
|
5566
5691
|
SET ${setFragments.join(", ")}
|
|
5567
5692
|
WHERE ${where.join(" AND ")}`;
|
|
5568
5693
|
return { sql, values };
|
|
5569
5694
|
}
|
|
5570
|
-
function
|
|
5695
|
+
function validateMysqlPatch(job) {
|
|
5571
5696
|
const patchColumns = Object.keys(job.patch || {});
|
|
5572
5697
|
if (!patchColumns.length) {
|
|
5573
|
-
throw new Error("
|
|
5698
|
+
throw new Error("mysql writeback patch must not be empty");
|
|
5574
5699
|
}
|
|
5575
5700
|
const allowedColumns = new Set(Array.isArray(job.allowed_columns) ? job.allowed_columns : []);
|
|
5576
5701
|
if (allowedColumns.has(job.target.primary_key.column)) {
|
|
5577
|
-
throw new Error("
|
|
5702
|
+
throw new Error("mysql primary key column must not be patch-allowlisted");
|
|
5578
5703
|
}
|
|
5579
5704
|
if (allowedColumns.has(job.target.tenant_guard.column)) {
|
|
5580
|
-
throw new Error("
|
|
5705
|
+
throw new Error("mysql tenant guard column must not be patch-allowlisted");
|
|
5581
5706
|
}
|
|
5582
5707
|
for (const column of patchColumns) {
|
|
5583
5708
|
if (!allowedColumns.has(column)) {
|
|
5584
|
-
throw new Error(`
|
|
5709
|
+
throw new Error(`mysql patch column not allowlisted: ${column}`);
|
|
5585
5710
|
}
|
|
5586
5711
|
}
|
|
5587
5712
|
}
|
|
@@ -5624,129 +5749,93 @@ function pad22(value) {
|
|
|
5624
5749
|
function versionValuesMatch2(actual, expected) {
|
|
5625
5750
|
return normalizeVersionValue2(actual) === normalizeVersionValue2(expected);
|
|
5626
5751
|
}
|
|
5627
|
-
async function
|
|
5628
|
-
await client.query("BEGIN");
|
|
5629
|
-
try {
|
|
5630
|
-
const result = await fn();
|
|
5631
|
-
await client.query("COMMIT");
|
|
5632
|
-
return result;
|
|
5633
|
-
} catch (error) {
|
|
5634
|
-
await client.query("ROLLBACK").catch(() => void 0);
|
|
5635
|
-
throw error;
|
|
5636
|
-
}
|
|
5637
|
-
}
|
|
5638
|
-
async function applyPostgresJob(job, config) {
|
|
5752
|
+
async function applyMysqlJob(job, config) {
|
|
5639
5753
|
if (config.dryRun) {
|
|
5640
|
-
|
|
5641
|
-
return {
|
|
5642
|
-
protocol_version: "1.0",
|
|
5643
|
-
job_id: job.job_id,
|
|
5644
|
-
runner_id: config.runnerId,
|
|
5645
|
-
status: "applied",
|
|
5646
|
-
affected_rows: 0,
|
|
5647
|
-
result_hash: resultHash2(job, "dry-run"),
|
|
5648
|
-
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
5649
|
-
};
|
|
5650
|
-
}
|
|
5651
|
-
if (!config.databaseUrl) {
|
|
5652
|
-
return failed2(job, config, "DATABASE_UNAVAILABLE");
|
|
5754
|
+
buildMysqlUpdate(job);
|
|
5755
|
+
return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "applied", affected_rows: 0, result_hash: resultHash2(job, "dry-run"), completed_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5653
5756
|
}
|
|
5654
|
-
|
|
5655
|
-
const
|
|
5757
|
+
if (!config.databaseUrl) return failed2(job, config, "DATABASE_UNAVAILABLE");
|
|
5758
|
+
const connection = await mysql2.createConnection({ uri: config.databaseUrl, dateStrings: true });
|
|
5656
5759
|
try {
|
|
5657
|
-
return await
|
|
5760
|
+
return await applyMysqlJobWithConnection(job, config, connection);
|
|
5658
5761
|
} catch (error) {
|
|
5659
5762
|
const message = error instanceof Error ? error.message : "TRANSACTION_FAILED";
|
|
5660
5763
|
return failed2(job, config, message.includes("MULTI_ROW") ? "MULTI_ROW_WRITE_BLOCKED" : message.includes("VERSION") ? "VERSION_CONFLICT" : "TRANSACTION_FAILED");
|
|
5661
5764
|
} finally {
|
|
5662
|
-
|
|
5663
|
-
await pool.end();
|
|
5765
|
+
await connection.end();
|
|
5664
5766
|
}
|
|
5665
5767
|
}
|
|
5666
|
-
async function
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
const existing = await claimReceipt2(
|
|
5670
|
-
if (existing)
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5768
|
+
async function applyMysqlJobWithConnection(job, config, connection) {
|
|
5769
|
+
await connection.beginTransaction();
|
|
5770
|
+
try {
|
|
5771
|
+
const existing = await claimReceipt2(connection, job, config);
|
|
5772
|
+
if (existing) {
|
|
5773
|
+
await connection.commit();
|
|
5774
|
+
return existing;
|
|
5775
|
+
}
|
|
5776
|
+
const [rows] = await connection.query(
|
|
5777
|
+
`SELECT * FROM ${quoteMysqlIdentifier(job.target.schema)}.${quoteMysqlIdentifier(job.target.table)}
|
|
5778
|
+
WHERE ${quoteMysqlIdentifier(job.target.primary_key.column)} = ?
|
|
5779
|
+
AND ${quoteMysqlIdentifier(job.target.tenant_guard.column)} = ?
|
|
5676
5780
|
FOR UPDATE`,
|
|
5677
5781
|
[job.target.primary_key.value, job.target.tenant_guard.value]
|
|
5678
5782
|
);
|
|
5679
|
-
if (!
|
|
5783
|
+
if (!rows[0]) {
|
|
5680
5784
|
const hash2 = resultHash2(job, "ROW_NOT_FOUND");
|
|
5681
|
-
await recordReceipt2(
|
|
5785
|
+
await recordReceipt2(connection, job, "conflict", hash2);
|
|
5786
|
+
await connection.commit();
|
|
5682
5787
|
return conflict2(job, config, "ROW_NOT_FOUND", hash2);
|
|
5683
5788
|
}
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
await
|
|
5789
|
+
if (job.conflict_guard.kind === "version_column" && !versionValuesMatch2(rows[0][job.conflict_guard.column], job.conflict_guard.expected_value)) {
|
|
5790
|
+
const hash2 = resultHash2(job, "VERSION_CONFLICT", rows[0][job.conflict_guard.column]);
|
|
5791
|
+
await recordReceipt2(connection, job, "conflict", hash2);
|
|
5792
|
+
await connection.commit();
|
|
5688
5793
|
return conflict2(job, config, "VERSION_CONFLICT", hash2);
|
|
5689
5794
|
}
|
|
5690
|
-
const update =
|
|
5691
|
-
const
|
|
5692
|
-
if (
|
|
5693
|
-
throw new Error(applied.rowCount === 0 ? "VERSION_CONFLICT" : "MULTI_ROW_WRITE_BLOCKED");
|
|
5694
|
-
}
|
|
5795
|
+
const update = buildMysqlUpdate(job);
|
|
5796
|
+
const [result] = await connection.query(update.sql, update.values);
|
|
5797
|
+
if (result.affectedRows !== 1) throw new Error(result.affectedRows === 0 ? "VERSION_CONFLICT" : "MULTI_ROW_WRITE_BLOCKED");
|
|
5695
5798
|
const hash = resultHash2(job, "applied", job.conflict_guard.kind === "version_column" ? job.conflict_guard.expected_value : void 0);
|
|
5696
|
-
await recordReceipt2(
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
result_hash: hash,
|
|
5704
|
-
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
5705
|
-
};
|
|
5706
|
-
});
|
|
5799
|
+
await recordReceipt2(connection, job, "applied", hash);
|
|
5800
|
+
await connection.commit();
|
|
5801
|
+
return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "applied", affected_rows: 1, result_hash: hash, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5802
|
+
} catch (error) {
|
|
5803
|
+
await connection.rollback().catch(() => void 0);
|
|
5804
|
+
throw error;
|
|
5805
|
+
}
|
|
5707
5806
|
}
|
|
5708
|
-
async function claimReceipt2(
|
|
5709
|
-
const
|
|
5710
|
-
`INSERT INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash)
|
|
5711
|
-
VALUES (
|
|
5712
|
-
ON CONFLICT (idempotency_key) DO NOTHING
|
|
5713
|
-
RETURNING status, result_hash`,
|
|
5807
|
+
async function claimReceipt2(connection, job, config) {
|
|
5808
|
+
const [claim] = await connection.query(
|
|
5809
|
+
`INSERT IGNORE INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash)
|
|
5810
|
+
VALUES (?, ?, ?, 'in_progress', NULL)`,
|
|
5714
5811
|
[job.idempotency_key, job.job_id, job.proposal_id]
|
|
5715
5812
|
);
|
|
5716
|
-
if (
|
|
5717
|
-
const
|
|
5718
|
-
"SELECT status, result_hash FROM synapsor_writeback_receipts WHERE idempotency_key =
|
|
5813
|
+
if (claim.affectedRows === 1) return void 0;
|
|
5814
|
+
const [receiptRows] = await connection.query(
|
|
5815
|
+
"SELECT status, result_hash FROM synapsor_writeback_receipts WHERE idempotency_key = ? FOR UPDATE",
|
|
5719
5816
|
[job.idempotency_key]
|
|
5720
5817
|
);
|
|
5721
|
-
const row =
|
|
5818
|
+
const row = receiptRows[0];
|
|
5722
5819
|
if (!row) return failed2(job, config, "IDEMPOTENCY_RECEIPT_UNAVAILABLE");
|
|
5723
5820
|
return resultFromExistingReceipt2(job, config, row.status, row.result_hash);
|
|
5724
5821
|
}
|
|
5725
5822
|
function resultFromExistingReceipt2(job, config, status, hash) {
|
|
5726
5823
|
const result_hash = typeof hash === "string" && hash ? hash : resultHash2(job, "idempotent");
|
|
5727
5824
|
if (status === "applied" || status === "already_applied") {
|
|
5728
|
-
return {
|
|
5729
|
-
protocol_version: "1.0",
|
|
5730
|
-
job_id: job.job_id,
|
|
5731
|
-
runner_id: config.runnerId,
|
|
5732
|
-
status: "already_applied",
|
|
5733
|
-
affected_rows: 0,
|
|
5734
|
-
result_hash,
|
|
5735
|
-
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
5736
|
-
};
|
|
5825
|
+
return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "already_applied", affected_rows: 0, result_hash, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5737
5826
|
}
|
|
5738
5827
|
if (status === "conflict") return conflict2(job, config, "IDEMPOTENT_CONFLICT", result_hash);
|
|
5739
5828
|
if (status === "failed") return failed2(job, config, "IDEMPOTENT_FAILED");
|
|
5740
5829
|
return failed2(job, config, "IDEMPOTENCY_RECEIPT_IN_PROGRESS");
|
|
5741
5830
|
}
|
|
5742
|
-
async function recordReceipt2(
|
|
5743
|
-
const result = await
|
|
5831
|
+
async function recordReceipt2(connection, job, status, hash) {
|
|
5832
|
+
const [result] = await connection.query(
|
|
5744
5833
|
`UPDATE synapsor_writeback_receipts
|
|
5745
|
-
SET status =
|
|
5746
|
-
WHERE idempotency_key =
|
|
5747
|
-
[
|
|
5834
|
+
SET status = ?, result_hash = ?, completed_at = CURRENT_TIMESTAMP
|
|
5835
|
+
WHERE idempotency_key = ?`,
|
|
5836
|
+
[status, hash, job.idempotency_key]
|
|
5748
5837
|
);
|
|
5749
|
-
if (result.
|
|
5838
|
+
if (result.affectedRows !== 1) throw new Error("IDEMPOTENCY_RECEIPT_UNAVAILABLE");
|
|
5750
5839
|
}
|
|
5751
5840
|
function conflict2(job, config, code, result_hash) {
|
|
5752
5841
|
return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "conflict", affected_rows: 0, error_code: code, result_hash, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
@@ -5754,47 +5843,44 @@ function conflict2(job, config, code, result_hash) {
|
|
|
5754
5843
|
function failed2(job, config, code) {
|
|
5755
5844
|
return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "failed", error_code: code, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5756
5845
|
}
|
|
5757
|
-
var
|
|
5846
|
+
var mysqlAdapter = {
|
|
5758
5847
|
async doctor(config) {
|
|
5759
5848
|
if (!config.databaseUrl) return { ok: false, details: { error: "database URL required; local config apply reads source.write_url_env, legacy workers use SYNAPSOR_DATABASE_URL" } };
|
|
5760
|
-
const
|
|
5761
|
-
const client = await pool.connect();
|
|
5849
|
+
const connection = await mysql2.createConnection({ uri: config.databaseUrl, dateStrings: true });
|
|
5762
5850
|
try {
|
|
5763
|
-
const
|
|
5764
|
-
await
|
|
5765
|
-
await client.query("BEGIN");
|
|
5851
|
+
const [rows] = await connection.query("SELECT VERSION() AS version");
|
|
5852
|
+
await connection.beginTransaction();
|
|
5766
5853
|
try {
|
|
5767
5854
|
const doctorId = `doctor-${Date.now()}`;
|
|
5768
|
-
await
|
|
5855
|
+
await connection.query(
|
|
5769
5856
|
`INSERT INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash, completed_at)
|
|
5770
|
-
VALUES (
|
|
5857
|
+
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
|
5771
5858
|
[doctorId, doctorId, "doctor", "doctor", "sha256:doctor"]
|
|
5772
5859
|
);
|
|
5773
|
-
await
|
|
5860
|
+
await connection.rollback();
|
|
5774
5861
|
} catch (error) {
|
|
5775
|
-
await
|
|
5862
|
+
await connection.rollback().catch(() => void 0);
|
|
5776
5863
|
throw error;
|
|
5777
5864
|
}
|
|
5778
5865
|
return {
|
|
5779
5866
|
ok: true,
|
|
5780
5867
|
details: {
|
|
5781
|
-
version:
|
|
5868
|
+
version: rows[0]?.version,
|
|
5782
5869
|
receipt_table: "ready",
|
|
5783
5870
|
write_permission_rollback: true,
|
|
5784
5871
|
dry_run: config.dryRun
|
|
5785
5872
|
}
|
|
5786
5873
|
};
|
|
5787
5874
|
} finally {
|
|
5788
|
-
|
|
5789
|
-
await pool.end();
|
|
5875
|
+
await connection.end();
|
|
5790
5876
|
}
|
|
5791
5877
|
},
|
|
5792
|
-
apply:
|
|
5878
|
+
apply: applyMysqlJob
|
|
5793
5879
|
};
|
|
5794
5880
|
|
|
5795
5881
|
// packages/schema-inspector/src/index.ts
|
|
5796
5882
|
import mysql3 from "mysql2/promise";
|
|
5797
|
-
import { Pool as
|
|
5883
|
+
import { Pool as Pool2 } from "pg";
|
|
5798
5884
|
var TENANT_COLUMNS = /* @__PURE__ */ new Set(["tenant_id", "account_id", "organization_id", "org_id", "workspace_id", "customer_id"]);
|
|
5799
5885
|
var CONFLICT_COLUMNS = /* @__PURE__ */ new Set(["updated_at", "modified_at", "row_version", "version", "lock_version", "etag"]);
|
|
5800
5886
|
var IMMUTABLE_COLUMNS = /* @__PURE__ */ new Set(["id", "uuid", "created_at", "created_by"]);
|
|
@@ -5977,7 +6063,7 @@ function summarizeInspection(inspection) {
|
|
|
5977
6063
|
`;
|
|
5978
6064
|
}
|
|
5979
6065
|
async function inspectPostgres(options) {
|
|
5980
|
-
const pool = new
|
|
6066
|
+
const pool = new Pool2({ connectionString: options.url, connectionTimeoutMillis: options.statementTimeoutMs ?? 3e3 });
|
|
5981
6067
|
const client = await pool.connect();
|
|
5982
6068
|
try {
|
|
5983
6069
|
await client.query("BEGIN READ ONLY");
|
|
@@ -7220,8 +7306,7 @@ function parseCapabilityBlock(block) {
|
|
|
7220
7306
|
}
|
|
7221
7307
|
const lookup = item.text.match(/^LOOKUP\s+([A-Za-z_][A-Za-z0-9_]*)\s+BY\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
|
7222
7308
|
if (lookup?.[1] && lookup[2]) {
|
|
7223
|
-
capability.lookup = { arg: lookup[1], column: lookup[2] };
|
|
7224
|
-
capability.primaryKey = lookup[2];
|
|
7309
|
+
capability.lookup = { arg: lookup[1], column: lookup[2], line: item.line };
|
|
7225
7310
|
if (!capability.args[lookup[1]])
|
|
7226
7311
|
capability.args[lookup[1]] = { type: "string", required: true, max_length: 128 };
|
|
7227
7312
|
continue;
|
|
@@ -7317,6 +7402,9 @@ function parseCapabilityBlock(block) {
|
|
|
7317
7402
|
throw dslError(block.line, 1, "CAPABILITY_SUBJECT_REQUIRED", `${block.name} requires ON schema.table`);
|
|
7318
7403
|
if (!capability.tenantKey)
|
|
7319
7404
|
throw dslError(block.line, 1, "CAPABILITY_TENANT_REQUIRED", `${block.name} requires TENANT KEY for 0.1 DSL`);
|
|
7405
|
+
if (capability.lookup && capability.lookup.column !== capability.primaryKey) {
|
|
7406
|
+
throw dslError(capability.lookup.line ?? block.line, 1, "LOOKUP_COLUMN_UNSUPPORTED", `${block.name} LOOKUP BY ${capability.lookup.column} cannot be represented by spec 0.1; use declared PRIMARY KEY ${capability.primaryKey}`);
|
|
7407
|
+
}
|
|
7320
7408
|
if (capability.visibleFields.length === 0)
|
|
7321
7409
|
throw dslError(block.line, 1, "CAPABILITY_VISIBLE_FIELDS_REQUIRED", `${block.name} requires ALLOW READ`);
|
|
7322
7410
|
if (Object.keys(capability.args).length === 0 && capability.lookup)
|
|
@@ -8439,7 +8527,7 @@ function stringOrDefault(value, fallback) {
|
|
|
8439
8527
|
// apps/runner/package.json
|
|
8440
8528
|
var package_default = {
|
|
8441
8529
|
name: "@synapsor/runner",
|
|
8442
|
-
version: "0.1.
|
|
8530
|
+
version: "0.1.16",
|
|
8443
8531
|
description: "Stop giving AI agents execute_sql; expose reviewed Postgres/MySQL MCP actions with proposals, approval, writeback, and replay.",
|
|
8444
8532
|
license: "Apache-2.0",
|
|
8445
8533
|
type: "module",
|
|
@@ -8526,7 +8614,7 @@ var package_default = {
|
|
|
8526
8614
|
// packages/dsl/package.json
|
|
8527
8615
|
var package_default2 = {
|
|
8528
8616
|
name: "@synapsor/dsl",
|
|
8529
|
-
version: "0.1.
|
|
8617
|
+
version: "0.1.6",
|
|
8530
8618
|
description: "Preview SQL-like Synapsor authoring DSL compiler for canonical @synapsor/spec contracts.",
|
|
8531
8619
|
license: "Apache-2.0",
|
|
8532
8620
|
type: "module",
|
|
@@ -8538,6 +8626,7 @@ var package_default2 = {
|
|
|
8538
8626
|
files: [
|
|
8539
8627
|
"dist/**",
|
|
8540
8628
|
"examples/**",
|
|
8629
|
+
"fixtures/**",
|
|
8541
8630
|
"README.md",
|
|
8542
8631
|
"package.json"
|
|
8543
8632
|
],
|
|
@@ -9865,7 +9954,7 @@ function formatPostgresReceiptMigration(schema) {
|
|
|
9865
9954
|
if (!schema) {
|
|
9866
9955
|
return [
|
|
9867
9956
|
"-- Synapsor Runner direct SQL writeback receipt table.",
|
|
9868
|
-
"-- Run this as a database owner
|
|
9957
|
+
"-- Run this once as a database owner before doctor/apply. The steady-state writer does not need schema CREATE.",
|
|
9869
9958
|
`${postgresReceiptMigration};`,
|
|
9870
9959
|
""
|
|
9871
9960
|
].join("\n");
|
|
@@ -9874,7 +9963,7 @@ function formatPostgresReceiptMigration(schema) {
|
|
|
9874
9963
|
const qualified = `${quotedSchema}.synapsor_writeback_receipts`;
|
|
9875
9964
|
return [
|
|
9876
9965
|
"-- Synapsor Runner direct SQL writeback receipt table.",
|
|
9877
|
-
"-- If you use a dedicated schema, ensure the writer connection search_path includes it.",
|
|
9966
|
+
"-- Run this once as a database owner. If you use a dedicated schema, ensure the writer connection search_path includes it.",
|
|
9878
9967
|
`CREATE SCHEMA IF NOT EXISTS ${quotedSchema};`,
|
|
9879
9968
|
`${postgresReceiptMigration.replace("synapsor_writeback_receipts", qualified)};`,
|
|
9880
9969
|
"",
|
|
@@ -9901,9 +9990,6 @@ function formatPostgresReceiptGrants(schema, writerRole) {
|
|
|
9901
9990
|
`GRANT USAGE ON SCHEMA ${quotedSchema} TO ${quotedRole};`,
|
|
9902
9991
|
`GRANT SELECT, INSERT, UPDATE ON TABLE ${table} TO ${quotedRole};`,
|
|
9903
9992
|
"",
|
|
9904
|
-
"-- If you want Runner to create the receipt table itself, also grant CREATE on the schema:",
|
|
9905
|
-
`-- GRANT CREATE ON SCHEMA ${quotedSchema} TO ${quotedRole};`,
|
|
9906
|
-
"",
|
|
9907
9993
|
"-- If the schema is not public, make sure the writer connection search_path includes it.",
|
|
9908
9994
|
`-- ALTER ROLE ${quotedRole} SET search_path = ${schema}, public;`,
|
|
9909
9995
|
""
|
|
@@ -9915,9 +10001,6 @@ function formatMysqlReceiptGrants(database, writerRole) {
|
|
|
9915
10001
|
return [
|
|
9916
10002
|
"-- Least-privilege grants for a pre-created Synapsor Runner receipt table.",
|
|
9917
10003
|
`GRANT SELECT, INSERT, UPDATE ON ${quotedDatabase}.synapsor_writeback_receipts TO ${account};`,
|
|
9918
|
-
"",
|
|
9919
|
-
"-- If you want Runner to create the receipt table itself, also grant CREATE on the database:",
|
|
9920
|
-
`-- GRANT CREATE ON ${quotedDatabase}.* TO ${account};`,
|
|
9921
10004
|
""
|
|
9922
10005
|
].join("\n");
|
|
9923
10006
|
}
|
|
@@ -10139,7 +10222,7 @@ async function dslCommand(args) {
|
|
|
10139
10222
|
}
|
|
10140
10223
|
async function dslValidate(args) {
|
|
10141
10224
|
const target2 = firstPositional(args);
|
|
10142
|
-
if (!target2) throw new Error("dsl validate requires
|
|
10225
|
+
if (!target2) throw new Error("dsl validate requires a DSL source file such as contract.synapsor.sql or contract.synapsor");
|
|
10143
10226
|
const source = await fs3.readFile(target2, "utf8");
|
|
10144
10227
|
const strict = args.includes("--strict");
|
|
10145
10228
|
const result = validateAgentDsl(source);
|
|
@@ -10161,7 +10244,7 @@ async function dslValidate(args) {
|
|
|
10161
10244
|
}
|
|
10162
10245
|
async function dslCompile(args) {
|
|
10163
10246
|
const target2 = firstPositional(args);
|
|
10164
|
-
if (!target2) throw new Error("dsl compile requires
|
|
10247
|
+
if (!target2) throw new Error("dsl compile requires a DSL source file such as contract.synapsor.sql or contract.synapsor");
|
|
10165
10248
|
const source = await fs3.readFile(target2, "utf8");
|
|
10166
10249
|
const strict = args.includes("--strict");
|
|
10167
10250
|
const result = compileAgentDslWithWarnings(source);
|
|
@@ -10833,6 +10916,9 @@ async function doctor(args = []) {
|
|
|
10833
10916
|
if (configPath || await fileExists("synapsor.runner.json")) {
|
|
10834
10917
|
return localDoctor(args);
|
|
10835
10918
|
}
|
|
10919
|
+
if (!process2.env.SYNAPSOR_CONTROL_PLANE_URL) {
|
|
10920
|
+
throw new Error(`Local doctor requires --config ./synapsor.runner.json. Cloud worker doctor requires SYNAPSOR_CONTROL_PLANE_URL and the scoped worker environment.`);
|
|
10921
|
+
}
|
|
10836
10922
|
const config = loadConfig();
|
|
10837
10923
|
const logger = createLogger(config);
|
|
10838
10924
|
const report = await doctorChecks(config, adapters[config.engine]);
|
|
@@ -11777,6 +11863,9 @@ function formatApplyResult(job, result, dryRun, storePath) {
|
|
|
11777
11863
|
"",
|
|
11778
11864
|
"Why:",
|
|
11779
11865
|
errorCode === "VERSION_CONFLICT" ? "conflict/version guard did not match" : errorCode || "guarded writeback returned conflict",
|
|
11866
|
+
"",
|
|
11867
|
+
"Next:",
|
|
11868
|
+
"Re-inspect the current source row and create a fresh proposal. The conflicted proposal and receipt remain in replay history.",
|
|
11780
11869
|
""
|
|
11781
11870
|
);
|
|
11782
11871
|
}
|
|
@@ -14242,7 +14331,7 @@ async function readMcpAuditTarget(target2, args, timeoutMs) {
|
|
|
14242
14331
|
}
|
|
14243
14332
|
const parsed = JSON.parse(await fs3.readFile(target2, "utf8"));
|
|
14244
14333
|
if (isRunnerConfigLike(parsed)) {
|
|
14245
|
-
const runtime = createMcpRuntime(
|
|
14334
|
+
const runtime = createMcpRuntime(loadRuntimeConfigFromFile(target2), { storePath: ":memory:" });
|
|
14246
14335
|
try {
|
|
14247
14336
|
return { tools: runtime.listTools() };
|
|
14248
14337
|
} finally {
|
|
@@ -16593,6 +16682,12 @@ function proposalNextCommands(proposal, proposalRef, storeSuffix) {
|
|
|
16593
16682
|
`${cliCommandName()} replay show --proposal ${proposal.proposal_id}${storeSuffix}`
|
|
16594
16683
|
];
|
|
16595
16684
|
}
|
|
16685
|
+
if (proposal.state === "conflict") {
|
|
16686
|
+
return [
|
|
16687
|
+
`${cliCommandName()} propose ${proposal.action} --json '<fresh reviewed input>'${storeSuffix}`,
|
|
16688
|
+
`${cliCommandName()} replay show --proposal ${proposal.proposal_id}${storeSuffix}`
|
|
16689
|
+
];
|
|
16690
|
+
}
|
|
16596
16691
|
return [
|
|
16597
16692
|
`${cliCommandName()} replay show --proposal ${proposal.proposal_id}${storeSuffix}`
|
|
16598
16693
|
];
|
|
@@ -16818,6 +16913,7 @@ Commands:
|
|
|
16818
16913
|
start Start guided own-database setup, or no-arg legacy worker polling
|
|
16819
16914
|
up Bring up local review mode guidance/server
|
|
16820
16915
|
init Generate a Synapsor capability contract
|
|
16916
|
+
config Validate local synapsor.runner.json wiring
|
|
16821
16917
|
mcp Serve safe semantic tools over MCP
|
|
16822
16918
|
contract Validate and normalize canonical Synapsor contract files
|
|
16823
16919
|
dsl Compile SQL-like Synapsor authoring DSL to contract JSON
|
|
@@ -16847,9 +16943,10 @@ Examples:
|
|
|
16847
16943
|
${cmd} onboard db --from-env DATABASE_URL
|
|
16848
16944
|
${cmd} inspect --from-env DATABASE_URL
|
|
16849
16945
|
${cmd} init --wizard --from-env DATABASE_URL
|
|
16946
|
+
${cmd} config validate --config ./synapsor.runner.json
|
|
16850
16947
|
${cmd} contract validate ./synapsor.contract.json
|
|
16851
16948
|
${cmd} contract normalize ./synapsor.contract.json --out ./synapsor.contract.normalized.json
|
|
16852
|
-
${cmd} dsl compile ./contract.synapsor --out ./synapsor.contract.json
|
|
16949
|
+
${cmd} dsl compile ./contract.synapsor.sql --out ./synapsor.contract.json
|
|
16853
16950
|
${cmd} cloud push ./synapsor.contract.json --dry-run
|
|
16854
16951
|
${cmd} smoke call --config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
16855
16952
|
${cmd} tools list --aliases --config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
@@ -16857,6 +16954,14 @@ Examples:
|
|
|
16857
16954
|
${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
16858
16955
|
${cmd} propose billing.propose_late_fee_waiver --sample
|
|
16859
16956
|
${cmd} audit ./synapsor.runner.json
|
|
16957
|
+
`,
|
|
16958
|
+
config: `Usage:
|
|
16959
|
+
${cmd} config validate --config ./synapsor.runner.json
|
|
16960
|
+
${cmd} config migrate --config ./synapsor.runner.json --out ./synapsor.runner.migrated.json
|
|
16961
|
+
|
|
16962
|
+
Validate local Runner wiring before tools preview, doctor, smoke, or MCP serve.
|
|
16963
|
+
Contract paths are resolved relative to the config file. SQLite store paths are
|
|
16964
|
+
resolved by the Runner process working directory.
|
|
16860
16965
|
`,
|
|
16861
16966
|
contract: `Usage:
|
|
16862
16967
|
${cmd} contract validate ./synapsor.contract.json [--json]
|
|
@@ -16867,8 +16972,10 @@ contexts, capabilities, workflows, evidence, proposal, receipt, and replay
|
|
|
16867
16972
|
semantics. Local database URLs, ports, and store paths stay in runner config.
|
|
16868
16973
|
`,
|
|
16869
16974
|
dsl: `Usage:
|
|
16870
|
-
${cmd} dsl validate ./contract.synapsor [--json]
|
|
16871
|
-
${cmd} dsl compile ./contract.synapsor --out ./synapsor.contract.json
|
|
16975
|
+
${cmd} dsl validate ./contract.synapsor.sql [--json]
|
|
16976
|
+
${cmd} dsl compile ./contract.synapsor.sql --out ./synapsor.contract.json
|
|
16977
|
+
|
|
16978
|
+
Both .synapsor.sql and legacy .synapsor source files are supported.
|
|
16872
16979
|
|
|
16873
16980
|
Compile the preview SQL-like Synapsor authoring DSL into canonical
|
|
16874
16981
|
@synapsor/spec JSON. Unsupported Cloud-only/generated clauses fail explicitly
|
|
@@ -17105,7 +17212,8 @@ Static MCP/database risk review only. This is not a security guarantee.
|
|
|
17105
17212
|
${cmd} doctor --first-run
|
|
17106
17213
|
|
|
17107
17214
|
Validate local config, environment bindings, semantic tool boundary, source metadata when reachable, handler signing/reachability, direct SQL writeback readiness, and local store stats. Reports are redacted; do not paste secrets into issues.
|
|
17108
|
-
Use --check-writeback only after
|
|
17215
|
+
Use --check-writeback only after an administrator applies the receipt-table migration and grants. It verifies the pre-created table with the trusted writer inside a rolled-back transaction and does not require schema CREATE.
|
|
17216
|
+
Without --config, doctor is the legacy Cloud worker check and requires SYNAPSOR_CONTROL_PLANE_URL plus the scoped worker environment.
|
|
17109
17217
|
`,
|
|
17110
17218
|
proposals: `Usage:
|
|
17111
17219
|
${cmd} proposals list [--tenant acme] [--capability billing.propose_late_fee_waiver] [--object invoice:INV-3001] [--status applied]
|
|
@@ -17141,6 +17249,7 @@ Inspect local query fingerprints, table names, row counts, and redacted-paramete
|
|
|
17141
17249
|
Inspect guarded writeback receipts recorded by the trusted runner path. Use --details for idempotency keys, receipt hashes, and runner metadata.
|
|
17142
17250
|
`,
|
|
17143
17251
|
apply: `Usage:
|
|
17252
|
+
${cmd} apply <proposal-id> [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
|
|
17144
17253
|
${cmd} apply latest [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
|
|
17145
17254
|
${cmd} apply --job job.json --config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
17146
17255
|
|
|
@@ -17150,9 +17259,9 @@ With --config, the writer connection comes from source.write_url_env, such as
|
|
|
17150
17259
|
SYNAPSOR_DATABASE_WRITE_URL. SYNAPSOR_DATABASE_URL is only the legacy fallback
|
|
17151
17260
|
for direct worker/apply flows without a local config.
|
|
17152
17261
|
|
|
17153
|
-
Direct SQL writeback
|
|
17154
|
-
idempotency and replay
|
|
17155
|
-
|
|
17262
|
+
Direct SQL writeback writes the administrator-created
|
|
17263
|
+
synapsor_writeback_receipts table for idempotency and replay. The trusted writer
|
|
17264
|
+
needs SELECT/INSERT/UPDATE on that table, but does not need schema CREATE.
|
|
17156
17265
|
`,
|
|
17157
17266
|
replay: `Usage:
|
|
17158
17267
|
${cmd} replay list [--tenant acme] [--object invoice:INV-3001]
|