@synapsor/runner 0.1.0-alpha.9 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +162 -0
- package/README.md +388 -41
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/runner.mjs +2982 -238
- package/docs/README.md +40 -0
- package/docs/app-owned-executors.md +38 -0
- package/docs/capability-authoring.md +265 -0
- package/docs/cloud-mode.md +24 -0
- package/docs/current-scope.md +29 -0
- package/docs/dependency-license-inventory.md +35 -0
- package/docs/doctor.md +98 -0
- package/docs/getting-started-own-database.md +131 -46
- package/docs/handler-helper.md +228 -0
- package/docs/http-mcp.md +85 -17
- package/docs/licensing.md +36 -0
- package/docs/local-mode.md +44 -25
- package/docs/mcp-audit.md +8 -8
- package/docs/mcp-client-setup.md +59 -21
- package/docs/openai-agents-sdk.md +57 -0
- package/docs/recipes.md +6 -6
- package/docs/release-notes.md +327 -0
- package/docs/release-policy.md +125 -0
- package/docs/result-envelope-v2.md +151 -0
- package/docs/rfcs/001-result-envelope-v2.md +143 -0
- package/docs/rfcs/002-app-owned-handler-helper.md +161 -0
- package/docs/rfcs/003-integrator-feedback-teardown.md +97 -0
- package/docs/store-lifecycle.md +83 -0
- package/docs/troubleshooting-first-run.md +6 -6
- package/docs/use-your-own-database.md +18 -0
- package/docs/writeback-executors.md +92 -1
- package/examples/app-owned-writeback/README.md +128 -0
- package/examples/app-owned-writeback/business-actions.md +221 -0
- package/examples/app-owned-writeback/command-handler.mjs +55 -0
- package/examples/app-owned-writeback/node-fastify-handler.mjs +64 -0
- package/examples/app-owned-writeback/python-fastapi-handler.py +66 -0
- package/examples/mcp-postgres-billing-app-handler/README.md +94 -0
- package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +123 -0
- package/examples/mcp-postgres-billing-app-handler/docker-compose.yml +13 -0
- package/examples/mcp-postgres-billing-app-handler/schema.sql +59 -0
- package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +100 -0
- package/examples/mcp-postgres-billing-app-handler/seed.sql +39 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +437 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +158 -0
- package/examples/openai-agents-http/README.md +19 -12
- package/examples/openai-agents-http/agent.py +29 -65
- package/examples/openai-agents-stdio/README.md +10 -6
- package/examples/openai-agents-stdio/agent.py +4 -2
- package/examples/reference-support-billing-app/README.md +16 -16
- package/examples/reference-support-billing-app/mcp-client.generic.json +1 -1
- package/fixtures/benchmark/mcp-efficiency.json +53 -0
- package/fixtures/benchmark/mcp-efficiency.txt +25 -0
- package/fixtures/protocol/MANIFEST.json +54 -0
- package/fixtures/protocol/change-set.late-fee-waiver.v1.json +72 -0
- package/fixtures/protocol/execution-receipt.applied.v1.json +14 -0
- package/fixtures/protocol/execution-receipt.conflict.v1.json +15 -0
- package/fixtures/protocol/runner-registration.v1.json +22 -0
- package/fixtures/protocol/writeback-job.late-fee-waiver.v1.json +44 -0
- package/package.json +6 -1
- package/schemas/change-set.v1.schema.json +140 -0
- package/schemas/execution-receipt.v1.schema.json +34 -0
- package/schemas/onboarding-selection.v1.schema.json +132 -0
- package/schemas/runner-registration.v1.schema.json +48 -0
- package/schemas/synapsor.app-handler-receipt.v1.json +39 -0
- package/schemas/synapsor.app-handler-request.v1.json +119 -0
- package/schemas/synapsor.runner.schema.json +415 -0
- package/schemas/writeback-job.v1.schema.json +121 -0
package/dist/runner.mjs
CHANGED
|
@@ -444,23 +444,26 @@ function sleep(ms) {
|
|
|
444
444
|
}
|
|
445
445
|
|
|
446
446
|
// packages/config/src/index.ts
|
|
447
|
-
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "mode", "storage", "sources", "trusted_context", "contexts", "executors", "capabilities", "cloud", "strict"]);
|
|
447
|
+
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "mode", "storage", "sources", "trusted_context", "contexts", "executors", "capabilities", "cloud", "strict", "result_format"]);
|
|
448
448
|
var STORAGE_KEYS = /* @__PURE__ */ new Set(["sqlite_path"]);
|
|
449
449
|
var CLOUD_KEYS = /* @__PURE__ */ new Set(["base_url_env", "runner_token_env", "runner_id", "runner_version", "project_id", "adapter_id", "source_id", "engines", "capabilities", "session"]);
|
|
450
450
|
var SOURCE_KEYS = /* @__PURE__ */ new Set([
|
|
451
451
|
"engine",
|
|
452
452
|
"read_url_env",
|
|
453
453
|
"write_url_env",
|
|
454
|
+
"read_only",
|
|
454
455
|
"statement_timeout_ms",
|
|
455
456
|
"ssl"
|
|
456
457
|
]);
|
|
457
458
|
var TRUSTED_CONTEXT_KEYS = /* @__PURE__ */ new Set(["provider", "values"]);
|
|
458
459
|
var CONTEXT_KEYS = TRUSTED_CONTEXT_KEYS;
|
|
459
|
-
var EXECUTOR_KEYS = /* @__PURE__ */ new Set(["type", "url_env", "method", "auth", "timeout_ms", "command_env"]);
|
|
460
|
+
var EXECUTOR_KEYS = /* @__PURE__ */ new Set(["type", "url_env", "method", "auth", "signing_secret_env", "timeout_ms", "command_env"]);
|
|
460
461
|
var EXECUTOR_AUTH_KEYS = /* @__PURE__ */ new Set(["type", "token_env"]);
|
|
461
462
|
var CAPABILITY_KEYS = /* @__PURE__ */ new Set([
|
|
462
463
|
"name",
|
|
463
464
|
"kind",
|
|
465
|
+
"description",
|
|
466
|
+
"returns_hint",
|
|
464
467
|
"source",
|
|
465
468
|
"context",
|
|
466
469
|
"executor",
|
|
@@ -480,7 +483,7 @@ var CAPABILITY_KEYS = /* @__PURE__ */ new Set([
|
|
|
480
483
|
]);
|
|
481
484
|
var TARGET_KEYS = /* @__PURE__ */ new Set(["schema", "table", "primary_key", "tenant_key", "single_tenant_dev"]);
|
|
482
485
|
var LOOKUP_KEYS = /* @__PURE__ */ new Set(["id_from_arg"]);
|
|
483
|
-
var ARG_KEYS = /* @__PURE__ */ new Set(["type", "required", "max_length", "minimum", "maximum", "enum"]);
|
|
486
|
+
var ARG_KEYS = /* @__PURE__ */ new Set(["type", "description", "required", "max_length", "minimum", "maximum", "enum"]);
|
|
484
487
|
var PATCH_BINDING_KEYS = /* @__PURE__ */ new Set(["fixed", "from_arg"]);
|
|
485
488
|
var NUMERIC_BOUND_KEYS = /* @__PURE__ */ new Set(["minimum", "maximum"]);
|
|
486
489
|
var TRANSITION_GUARD_KEYS = /* @__PURE__ */ new Set(["from_column", "allowed"]);
|
|
@@ -549,6 +552,9 @@ function validateRunnerCapabilityConfig(input) {
|
|
|
549
552
|
if (input.version !== 1) {
|
|
550
553
|
errors.push({ path: "$.version", code: "UNSUPPORTED_CONFIG_VERSION", message: "Runner config version must be 1." });
|
|
551
554
|
}
|
|
555
|
+
if (input.result_format !== void 0 && input.result_format !== 1 && input.result_format !== 2) {
|
|
556
|
+
errors.push({ path: "$.result_format", code: "INVALID_RESULT_FORMAT", message: "result_format must be 1 or 2." });
|
|
557
|
+
}
|
|
552
558
|
if (!isRunnerMode(input.mode)) {
|
|
553
559
|
errors.push({ path: "$.mode", code: "INVALID_MODE", message: "mode must be read_only, shadow, review, or cloud." });
|
|
554
560
|
}
|
|
@@ -559,6 +565,7 @@ function validateRunnerCapabilityConfig(input) {
|
|
|
559
565
|
validateTrustedContext(input.trusted_context, input.contexts, input.capabilities, input.mode, strict, errors, warnings);
|
|
560
566
|
validateExecutors(input.executors, input.mode, strict, errors);
|
|
561
567
|
validateCapabilities(input.capabilities, input.sources, input.contexts, input.executors, input.mode, strict, errors, warnings);
|
|
568
|
+
validateWritebackReadiness(input.sources, input.capabilities, input.mode, errors, warnings);
|
|
562
569
|
scanForForbiddenFields(input, "$", errors);
|
|
563
570
|
return { ok: errors.length === 0, errors, warnings };
|
|
564
571
|
}
|
|
@@ -617,14 +624,37 @@ function validateSources(value, mode, strict, errors, warnings) {
|
|
|
617
624
|
if (source.statement_timeout_ms !== void 0 && !isPositiveInteger(source.statement_timeout_ms)) {
|
|
618
625
|
errors.push({ path: `${path4}.statement_timeout_ms`, code: "INVALID_TIMEOUT", message: "statement_timeout_ms must be a positive integer." });
|
|
619
626
|
}
|
|
627
|
+
if (source.read_only !== void 0 && typeof source.read_only !== "boolean") {
|
|
628
|
+
errors.push({ path: `${path4}.read_only`, code: "INVALID_SOURCE_READ_ONLY", message: "read_only must be true or false when provided." });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function validateWritebackReadiness(sources, capabilities, mode, errors, warnings) {
|
|
633
|
+
if (mode !== "review" || !isRecord(sources) || !Array.isArray(capabilities)) return;
|
|
634
|
+
capabilities.forEach((capability, index) => {
|
|
635
|
+
if (!isRecord(capability) || capability.kind !== "proposal") return;
|
|
636
|
+
const sourceName = isNonEmptyString(capability.source) ? capability.source : void 0;
|
|
637
|
+
const source = sourceName ? sources[sourceName] : void 0;
|
|
638
|
+
if (!sourceName || !isRecord(source)) return;
|
|
639
|
+
const executor = isNonEmptyString(capability.executor) ? capability.executor : "sql_update";
|
|
640
|
+
const directSql = executor === "sql_update";
|
|
641
|
+
if (!directSql) return;
|
|
642
|
+
if (source.read_only === true) {
|
|
643
|
+
errors.push({
|
|
644
|
+
path: `$.capabilities[${index}].executor`,
|
|
645
|
+
code: "READ_ONLY_SOURCE_DIRECT_WRITEBACK",
|
|
646
|
+
message: `Proposal capability ${String(capability.name ?? index)} uses direct SQL writeback, but source ${sourceName} is marked read_only.`
|
|
647
|
+
});
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
620
650
|
if (source.write_url_env === void 0) {
|
|
621
651
|
warnings.push({
|
|
622
|
-
path:
|
|
652
|
+
path: `$.sources.${sourceName}.write_url_env`,
|
|
623
653
|
code: "WRITEBACK_DISABLED",
|
|
624
|
-
message: "No write_url_env is configured; review-mode proposal execution cannot apply external DB changes."
|
|
654
|
+
message: "No write_url_env is configured; direct SQL review-mode proposal execution cannot apply external DB changes."
|
|
625
655
|
});
|
|
626
656
|
}
|
|
627
|
-
}
|
|
657
|
+
});
|
|
628
658
|
}
|
|
629
659
|
function validateCloud(value, mode, strict, errors) {
|
|
630
660
|
if (mode !== "cloud") {
|
|
@@ -797,6 +827,9 @@ function validateExecutors(value, mode, strict, errors) {
|
|
|
797
827
|
errors.push({ path: `${path4}.method`, code: "INVALID_HANDLER_METHOD", message: "http_handler.method must be POST, PUT, or PATCH." });
|
|
798
828
|
}
|
|
799
829
|
validateExecutorAuth(executor.auth, `${path4}.auth`, strict, errors);
|
|
830
|
+
if (executor.signing_secret_env !== void 0 && !isEnvName(executor.signing_secret_env)) {
|
|
831
|
+
errors.push({ path: `${path4}.signing_secret_env`, code: "HANDLER_SIGNING_SECRET_ENV_INVALID", message: "http_handler.signing_secret_env must name an environment variable containing the HMAC signing secret." });
|
|
832
|
+
}
|
|
800
833
|
if (executor.timeout_ms !== void 0 && !isPositiveInteger(executor.timeout_ms)) {
|
|
801
834
|
errors.push({ path: `${path4}.timeout_ms`, code: "INVALID_HANDLER_TIMEOUT", message: "http_handler.timeout_ms must be a positive integer." });
|
|
802
835
|
}
|
|
@@ -852,6 +885,12 @@ function validateCapability(value, index, sourceNames, contextNames, executorNam
|
|
|
852
885
|
if (!isCapabilityKind(value.kind)) {
|
|
853
886
|
errors.push({ path: `${path4}.kind`, code: "INVALID_CAPABILITY_KIND", message: "kind must be read or proposal." });
|
|
854
887
|
}
|
|
888
|
+
if (value.description !== void 0 && !isNonEmptyString(value.description)) {
|
|
889
|
+
errors.push({ path: `${path4}.description`, code: "INVALID_CAPABILITY_DESCRIPTION", message: "description must be a non-empty string when provided." });
|
|
890
|
+
}
|
|
891
|
+
if (value.returns_hint !== void 0 && !isNonEmptyString(value.returns_hint)) {
|
|
892
|
+
errors.push({ path: `${path4}.returns_hint`, code: "INVALID_RETURNS_HINT", message: "returns_hint must be a non-empty string when provided." });
|
|
893
|
+
}
|
|
855
894
|
if (!isNonEmptyString(value.source) || !sourceNames.has(value.source)) {
|
|
856
895
|
errors.push({ path: `${path4}.source`, code: "UNKNOWN_SOURCE", message: "Capability source must reference a configured source." });
|
|
857
896
|
}
|
|
@@ -926,6 +965,9 @@ function validateArgs(value, path4, strict, errors) {
|
|
|
926
965
|
if (!["string", "number", "boolean"].includes(String(arg.type))) {
|
|
927
966
|
errors.push({ path: `${argPath}.type`, code: "INVALID_ARG_TYPE", message: "Argument type must be string, number, or boolean." });
|
|
928
967
|
}
|
|
968
|
+
if (arg.description !== void 0 && !isNonEmptyString(arg.description)) {
|
|
969
|
+
errors.push({ path: `${argPath}.description`, code: "INVALID_ARG_DESCRIPTION", message: "Argument description must be a non-empty string when provided." });
|
|
970
|
+
}
|
|
929
971
|
if (arg.max_length !== void 0 && !isPositiveInteger(arg.max_length)) {
|
|
930
972
|
errors.push({ path: `${argPath}.max_length`, code: "INVALID_MAX_LENGTH", message: "max_length must be a positive integer." });
|
|
931
973
|
}
|
|
@@ -1184,6 +1226,8 @@ import { createServer } from "node:http";
|
|
|
1184
1226
|
import path from "node:path";
|
|
1185
1227
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1186
1228
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1229
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
1230
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
1187
1231
|
|
|
1188
1232
|
// packages/proposal-store/src/index.ts
|
|
1189
1233
|
import { DatabaseSync } from "node:sqlite";
|
|
@@ -2071,6 +2115,11 @@ var ProposalStore = class {
|
|
|
2071
2115
|
const rows = this.db.prepare("SELECT * FROM proposal_events WHERE proposal_id = ? ORDER BY event_id ASC").all(proposalId);
|
|
2072
2116
|
return rows.map(rowToEvent).filter((event) => event !== void 0);
|
|
2073
2117
|
}
|
|
2118
|
+
listEvents(filters = {}) {
|
|
2119
|
+
const query = buildEventQuery(filters);
|
|
2120
|
+
const rows = this.db.prepare(query.sql).all(...query.params);
|
|
2121
|
+
return rows.map(rowToEvent).filter((event) => event !== void 0);
|
|
2122
|
+
}
|
|
2074
2123
|
receipts(proposalId) {
|
|
2075
2124
|
const rows = this.db.prepare("SELECT * FROM writeback_receipts WHERE proposal_id = ? ORDER BY receipt_id ASC").all(proposalId);
|
|
2076
2125
|
return rows.map(rowToReceipt).filter((receipt) => receipt !== void 0);
|
|
@@ -2385,6 +2434,15 @@ function buildReceiptQuery(filters) {
|
|
|
2385
2434
|
addTimeRange(clauses, params, "created_at", filters.from, filters.to);
|
|
2386
2435
|
return finishQuery("SELECT * FROM writeback_receipts", clauses, params, filters.limit);
|
|
2387
2436
|
}
|
|
2437
|
+
function buildEventQuery(filters) {
|
|
2438
|
+
const clauses = [];
|
|
2439
|
+
const params = [];
|
|
2440
|
+
addEqual(clauses, params, "proposal_id", filters.proposal);
|
|
2441
|
+
addEqual(clauses, params, "kind", filters.kind);
|
|
2442
|
+
addEqual(clauses, params, "actor", filters.actor);
|
|
2443
|
+
addTimeRange(clauses, params, "created_at", filters.from, filters.to);
|
|
2444
|
+
return finishQuery("SELECT * FROM proposal_events", clauses, params, filters.limit);
|
|
2445
|
+
}
|
|
2388
2446
|
function addEqual(clauses, params, column, value) {
|
|
2389
2447
|
if (!value) return;
|
|
2390
2448
|
clauses.push(`${column} = ?`);
|
|
@@ -2668,74 +2726,118 @@ function loadRuntimeConfigFromFile(configPath = process.env.SYNAPSOR_MCP_CONFIG
|
|
|
2668
2726
|
function createMcpRuntime(config, options = {}) {
|
|
2669
2727
|
assertValidRunnerCapabilityConfig(config);
|
|
2670
2728
|
const env = options.env ?? process.env;
|
|
2671
|
-
const
|
|
2729
|
+
const storePath = options.storePath ?? config.storage?.sqlite_path ?? "./.synapsor/local.db";
|
|
2730
|
+
const ownsStore = !options.store;
|
|
2731
|
+
const store = options.store ?? new ProposalStore(storePath);
|
|
2672
2732
|
const readRow = options.readRow ?? readCurrentRow;
|
|
2673
2733
|
const cloudClient = options.controlPlaneClient ?? (config.mode === "cloud" ? createCloudClient(config, env) : void 0);
|
|
2674
2734
|
const cloudTools = options.cloudTools ?? [];
|
|
2735
|
+
const resultFormat = options.resultFormat ?? config.result_format ?? 1;
|
|
2736
|
+
const assertStoreAvailable = () => {
|
|
2737
|
+
if (ownsStore) assertPersistentStoreAvailable(storePath);
|
|
2738
|
+
};
|
|
2675
2739
|
return {
|
|
2676
2740
|
config,
|
|
2677
2741
|
store,
|
|
2678
2742
|
listTools: () => config.mode === "cloud" ? cloudTools : listedLocalCapabilities(config).map((capability) => toolMetadata(capability)),
|
|
2679
|
-
callTool: async (name, args) =>
|
|
2680
|
-
|
|
2743
|
+
callTool: async (name, args) => {
|
|
2744
|
+
if (resultFormat === 2) {
|
|
2745
|
+
try {
|
|
2746
|
+
assertStoreAvailable();
|
|
2747
|
+
return await callConfiguredToolV2({ config, env, store, readRow, cloudClient, name, args });
|
|
2748
|
+
} catch (error) {
|
|
2749
|
+
const capability = config.mode === "cloud" ? void 0 : localCapabilities(config).find((item) => item.name === name);
|
|
2750
|
+
return errorEnvelopeFromError(error, capability, name);
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
assertStoreAvailable();
|
|
2754
|
+
return callConfiguredTool({ config, env, store, readRow, cloudClient, name, args });
|
|
2755
|
+
},
|
|
2756
|
+
readResource: (uri) => {
|
|
2757
|
+
assertStoreAvailable();
|
|
2758
|
+
return readLocalResource(store, uri);
|
|
2759
|
+
},
|
|
2681
2760
|
close: () => {
|
|
2682
2761
|
if (!options.store) store.close();
|
|
2683
2762
|
}
|
|
2684
2763
|
};
|
|
2685
2764
|
}
|
|
2686
|
-
function
|
|
2765
|
+
function assertPersistentStoreAvailable(storePath) {
|
|
2766
|
+
if (storePath === ":memory:") return;
|
|
2767
|
+
if (fs.existsSync(storePath)) return;
|
|
2768
|
+
throw new McpRuntimeError(
|
|
2769
|
+
"LOCAL_STORE_UNAVAILABLE",
|
|
2770
|
+
"The local Synapsor store is temporarily unavailable. Restart the runner or recreate the store before retrying."
|
|
2771
|
+
);
|
|
2772
|
+
}
|
|
2773
|
+
function createSynapsorMcpServer(runtime, options = {}) {
|
|
2687
2774
|
const server = new McpServer(
|
|
2688
|
-
{ name: "synapsor-runner", version: "0.1.0
|
|
2775
|
+
{ name: "synapsor-runner", version: "0.1.0" },
|
|
2689
2776
|
{ capabilities: { tools: {}, resources: {} } }
|
|
2690
2777
|
);
|
|
2778
|
+
const toolNameStyle = options.toolNameStyle ?? "canonical";
|
|
2691
2779
|
if (runtime.config.mode === "cloud") {
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2780
|
+
const tools2 = runtime.listTools();
|
|
2781
|
+
const exposedNames = toolNameExposureMap(tools2.map((tool) => tool.name), toolNameStyle);
|
|
2782
|
+
for (const tool of tools2) {
|
|
2783
|
+
for (const exposedName of exposedNames.get(tool.name) ?? [tool.name]) {
|
|
2784
|
+
server.registerTool(
|
|
2785
|
+
exposedName,
|
|
2786
|
+
{
|
|
2787
|
+
title: tool.title,
|
|
2788
|
+
description: toolDescriptionWithCanonical(tool.description, tool.name, exposedName),
|
|
2789
|
+
inputSchema: zodInputShapeFromJsonSchema(tool.input_schema),
|
|
2790
|
+
annotations: {
|
|
2791
|
+
readOnlyHint: Boolean(tool.annotations.readOnlyHint),
|
|
2792
|
+
destructiveHint: false,
|
|
2793
|
+
idempotentHint: Boolean(tool.annotations.idempotentHint),
|
|
2794
|
+
openWorldHint: false
|
|
2795
|
+
},
|
|
2796
|
+
_meta: {
|
|
2797
|
+
...tool.annotations,
|
|
2798
|
+
"synapsor.cloud_delegated": true,
|
|
2799
|
+
"synapsor.canonical_tool_name": tool.name,
|
|
2800
|
+
"synapsor.exposed_tool_name": exposedName,
|
|
2801
|
+
"synapsor.tool_name_style": toolNameStyle,
|
|
2802
|
+
"synapsor.raw_sql_exposed": false,
|
|
2803
|
+
"synapsor.approval_tool": false
|
|
2804
|
+
}
|
|
2704
2805
|
},
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
"synapsor.raw_sql_exposed": false,
|
|
2709
|
-
"synapsor.approval_tool": false
|
|
2710
|
-
}
|
|
2711
|
-
},
|
|
2712
|
-
async (args) => toolCallResult(runtime, tool.name, args)
|
|
2713
|
-
);
|
|
2806
|
+
async (args) => toolCallResult(runtime, tool.name, args)
|
|
2807
|
+
);
|
|
2808
|
+
}
|
|
2714
2809
|
}
|
|
2715
2810
|
} else {
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2811
|
+
const capabilities = listedLocalCapabilities(runtime.config);
|
|
2812
|
+
const exposedNames = toolNameExposureMap(capabilities.map((capability) => capability.name), toolNameStyle);
|
|
2813
|
+
for (const capability of capabilities) {
|
|
2814
|
+
for (const exposedName of exposedNames.get(capability.name) ?? [capability.name]) {
|
|
2815
|
+
server.registerTool(
|
|
2816
|
+
exposedName,
|
|
2817
|
+
{
|
|
2818
|
+
title: capability.name,
|
|
2819
|
+
description: capabilityDescription(capability, exposedName),
|
|
2820
|
+
inputSchema: zodInputShape(capability),
|
|
2821
|
+
annotations: {
|
|
2822
|
+
readOnlyHint: capability.kind === "read",
|
|
2823
|
+
destructiveHint: false,
|
|
2824
|
+
idempotentHint: capability.kind === "read",
|
|
2825
|
+
openWorldHint: false
|
|
2826
|
+
},
|
|
2827
|
+
_meta: {
|
|
2828
|
+
"synapsor.kind": capability.kind,
|
|
2829
|
+
"synapsor.source": capability.source,
|
|
2830
|
+
"synapsor.target": `${capability.target.schema}.${capability.target.table}`,
|
|
2831
|
+
"synapsor.canonical_tool_name": capability.name,
|
|
2832
|
+
"synapsor.exposed_tool_name": exposedName,
|
|
2833
|
+
"synapsor.tool_name_style": toolNameStyle,
|
|
2834
|
+
"synapsor.raw_sql_exposed": false,
|
|
2835
|
+
"synapsor.approval_tool": false
|
|
2836
|
+
}
|
|
2728
2837
|
},
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
"synapsor.target": `${capability.target.schema}.${capability.target.table}`,
|
|
2733
|
-
"synapsor.raw_sql_exposed": false,
|
|
2734
|
-
"synapsor.approval_tool": false
|
|
2735
|
-
}
|
|
2736
|
-
},
|
|
2737
|
-
async (args) => toolCallResult(runtime, capability.name, args)
|
|
2738
|
-
);
|
|
2838
|
+
async (args) => toolCallResult(runtime, capability.name, args)
|
|
2839
|
+
);
|
|
2840
|
+
}
|
|
2739
2841
|
}
|
|
2740
2842
|
}
|
|
2741
2843
|
server.registerResource(
|
|
@@ -2761,8 +2863,8 @@ function createSynapsorMcpServer(runtime) {
|
|
|
2761
2863
|
async function serveStdio(options = {}) {
|
|
2762
2864
|
const config = options.config ?? loadRuntimeConfigFromFile(options.configPath);
|
|
2763
2865
|
const cloudTools = config.mode === "cloud" ? await fetchCloudToolMetadata(config, process.env) : void 0;
|
|
2764
|
-
const runtime = createMcpRuntime(config, { storePath: options.storePath, cloudTools });
|
|
2765
|
-
const server = createSynapsorMcpServer(runtime);
|
|
2866
|
+
const runtime = createMcpRuntime(config, { storePath: options.storePath, resultFormat: options.resultFormat, cloudTools });
|
|
2867
|
+
const server = createSynapsorMcpServer(runtime, { toolNameStyle: options.toolNameStyle });
|
|
2766
2868
|
const transport = new StdioServerTransport();
|
|
2767
2869
|
await server.connect(transport);
|
|
2768
2870
|
await new Promise((resolve) => {
|
|
@@ -2797,6 +2899,7 @@ async function startHttpMcpServer(options = {}) {
|
|
|
2797
2899
|
const runtime = createMcpRuntime(config, {
|
|
2798
2900
|
env,
|
|
2799
2901
|
storePath: options.storePath,
|
|
2902
|
+
resultFormat: options.resultFormat,
|
|
2800
2903
|
readRow: options.readRow,
|
|
2801
2904
|
cloudTools
|
|
2802
2905
|
});
|
|
@@ -2844,6 +2947,152 @@ async function startHttpMcpServer(options = {}) {
|
|
|
2844
2947
|
close: () => closeHttpServer(server, runtime)
|
|
2845
2948
|
};
|
|
2846
2949
|
}
|
|
2950
|
+
async function startStreamableHttpMcpServer(options = {}) {
|
|
2951
|
+
const host = options.host ?? "127.0.0.1";
|
|
2952
|
+
const port = options.port ?? 8766;
|
|
2953
|
+
const authTokenEnv = options.authTokenEnv ?? "SYNAPSOR_RUNNER_HTTP_TOKEN";
|
|
2954
|
+
const env = options.env ?? process.env;
|
|
2955
|
+
const devNoAuth = options.devNoAuth === true;
|
|
2956
|
+
if (devNoAuth && !isLoopbackHost(host)) {
|
|
2957
|
+
throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
|
|
2958
|
+
}
|
|
2959
|
+
const authToken = devNoAuth ? void 0 : env[authTokenEnv];
|
|
2960
|
+
if (!devNoAuth && !authToken) {
|
|
2961
|
+
throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${authTokenEnv} is not set. Streamable HTTP MCP requires bearer auth by default.`);
|
|
2962
|
+
}
|
|
2963
|
+
const config = options.config ?? loadRuntimeConfigFromFile(options.configPath);
|
|
2964
|
+
const cloudTools = config.mode === "cloud" ? await fetchCloudToolMetadata(config, env) : void 0;
|
|
2965
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
2966
|
+
const openSessions = /* @__PURE__ */ new Set();
|
|
2967
|
+
const server = createServer((request, response) => {
|
|
2968
|
+
void handleStreamableHttpMcpRequest({
|
|
2969
|
+
request,
|
|
2970
|
+
response,
|
|
2971
|
+
config,
|
|
2972
|
+
storePath: options.storePath,
|
|
2973
|
+
readRow: options.readRow,
|
|
2974
|
+
cloudTools,
|
|
2975
|
+
env,
|
|
2976
|
+
toolNameStyle: options.toolNameStyle,
|
|
2977
|
+
resultFormat: options.resultFormat,
|
|
2978
|
+
authToken,
|
|
2979
|
+
devNoAuth,
|
|
2980
|
+
corsOrigin: options.corsOrigin,
|
|
2981
|
+
sessions,
|
|
2982
|
+
openSessions
|
|
2983
|
+
});
|
|
2984
|
+
});
|
|
2985
|
+
try {
|
|
2986
|
+
await new Promise((resolve, reject) => {
|
|
2987
|
+
server.once("error", reject);
|
|
2988
|
+
server.listen(port, host, () => {
|
|
2989
|
+
server.off("error", reject);
|
|
2990
|
+
resolve();
|
|
2991
|
+
});
|
|
2992
|
+
});
|
|
2993
|
+
} catch (error) {
|
|
2994
|
+
await closeStreamableSessions(openSessions);
|
|
2995
|
+
throw error;
|
|
2996
|
+
}
|
|
2997
|
+
const address = server.address();
|
|
2998
|
+
const actualHost = address.address === "::" ? host : address.address;
|
|
2999
|
+
const actualPort = address.port;
|
|
3000
|
+
const url = `http://${actualHost}:${actualPort}/mcp`;
|
|
3001
|
+
if (options.log !== false) {
|
|
3002
|
+
const log = options.log ?? process.stderr;
|
|
3003
|
+
log.write(`Synapsor Runner Streamable HTTP MCP listening on ${url}
|
|
3004
|
+
`);
|
|
3005
|
+
log.write(devNoAuth ? "Auth: disabled for localhost development only\n" : `Auth: bearer token from ${authTokenEnv}
|
|
3006
|
+
`);
|
|
3007
|
+
log.write(`Config: ${options.configPath ?? "synapsor.runner.json"}
|
|
3008
|
+
`);
|
|
3009
|
+
log.write(`Store: ${options.storePath ?? config.storage?.sqlite_path ?? "./.synapsor/local.db"}
|
|
3010
|
+
`);
|
|
3011
|
+
}
|
|
3012
|
+
return {
|
|
3013
|
+
host: actualHost,
|
|
3014
|
+
port: actualPort,
|
|
3015
|
+
url,
|
|
3016
|
+
close: () => closeStreamableHttpServer(server, openSessions)
|
|
3017
|
+
};
|
|
3018
|
+
}
|
|
3019
|
+
async function handleStreamableHttpMcpRequest(input) {
|
|
3020
|
+
const { request, response, config, storePath, readRow, cloudTools, env, toolNameStyle, resultFormat, authToken, devNoAuth, corsOrigin, sessions, openSessions } = input;
|
|
3021
|
+
try {
|
|
3022
|
+
setCorsHeaders(response, corsOrigin);
|
|
3023
|
+
if (request.method === "OPTIONS" && corsOrigin) {
|
|
3024
|
+
response.statusCode = 204;
|
|
3025
|
+
response.end();
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
3029
|
+
if (request.method === "GET" && url.pathname === "/healthz") {
|
|
3030
|
+
writeJson(response, 200, {
|
|
3031
|
+
ok: true,
|
|
3032
|
+
transport: "streamable-http",
|
|
3033
|
+
sessions: sessions.size,
|
|
3034
|
+
tools: config.mode === "cloud" ? (cloudTools ?? []).length : listedLocalCapabilities(config).length,
|
|
3035
|
+
mode: config.mode
|
|
3036
|
+
});
|
|
3037
|
+
return;
|
|
3038
|
+
}
|
|
3039
|
+
if (url.pathname !== "/mcp") {
|
|
3040
|
+
writeJson(response, 404, { ok: false, error: "not_found" });
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
if (!devNoAuth && !validBearerToken(request.headers.authorization, authToken ?? "")) {
|
|
3044
|
+
writeJson(response, 401, { ok: false, error: "unauthorized" });
|
|
3045
|
+
return;
|
|
3046
|
+
}
|
|
3047
|
+
const sessionId = headerValue(request.headers["mcp-session-id"]);
|
|
3048
|
+
if (sessionId) {
|
|
3049
|
+
const existing = sessions.get(sessionId);
|
|
3050
|
+
if (!existing) {
|
|
3051
|
+
writeJson(response, 404, jsonRpcError(null, -32e3, "MCP session not found."));
|
|
3052
|
+
return;
|
|
3053
|
+
}
|
|
3054
|
+
await existing.transport.handleRequest(request, response);
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
if (request.method !== "POST") {
|
|
3058
|
+
writeJson(response, 400, jsonRpcError(null, -32e3, "MCP initialize request is required before using this Streamable HTTP session."));
|
|
3059
|
+
return;
|
|
3060
|
+
}
|
|
3061
|
+
const parsedBody = JSON.parse(await readRequestBody(request));
|
|
3062
|
+
if (!containsInitializeRequest(parsedBody)) {
|
|
3063
|
+
writeJson(response, 400, jsonRpcError(requestIdFromPayload(parsedBody), -32e3, "First Streamable HTTP MCP request must be initialize."));
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
let session;
|
|
3067
|
+
const transport = new StreamableHTTPServerTransport({
|
|
3068
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
3069
|
+
onsessioninitialized: (newSessionId) => {
|
|
3070
|
+
if (session) {
|
|
3071
|
+
session.sessionId = newSessionId;
|
|
3072
|
+
sessions.set(newSessionId, session);
|
|
3073
|
+
}
|
|
3074
|
+
},
|
|
3075
|
+
onsessionclosed: (closedSessionId) => {
|
|
3076
|
+
const closed = sessions.get(closedSessionId);
|
|
3077
|
+
if (closed) {
|
|
3078
|
+
disposeStreamableSession(closed, sessions, openSessions);
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
});
|
|
3082
|
+
const runtime = createMcpRuntime(config, { env, storePath, resultFormat, readRow, cloudTools });
|
|
3083
|
+
session = { transport, runtime };
|
|
3084
|
+
openSessions.add(session);
|
|
3085
|
+
transport.onclose = () => {
|
|
3086
|
+
if (session) disposeStreamableSession(session, sessions, openSessions);
|
|
3087
|
+
};
|
|
3088
|
+
await createSynapsorMcpServer(runtime, { toolNameStyle }).connect(transport);
|
|
3089
|
+
await transport.handleRequest(request, response, parsedBody);
|
|
3090
|
+
} catch (error) {
|
|
3091
|
+
const message = sanitizeHttpError(error, authToken);
|
|
3092
|
+
if (!response.headersSent) writeJson(response, 200, jsonRpcError(null, -32e3, message));
|
|
3093
|
+
else response.end();
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
2847
3096
|
async function handleHttpMcpRequest(input) {
|
|
2848
3097
|
const { request, response, runtime, authToken, devNoAuth, corsOrigin } = input;
|
|
2849
3098
|
try {
|
|
@@ -2943,6 +3192,71 @@ function validBearerToken(header, expected) {
|
|
|
2943
3192
|
const expectedBuffer = Buffer.from(expected);
|
|
2944
3193
|
return actualBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(actualBuffer, expectedBuffer);
|
|
2945
3194
|
}
|
|
3195
|
+
function headerValue(value) {
|
|
3196
|
+
return Array.isArray(value) ? value[0] : value;
|
|
3197
|
+
}
|
|
3198
|
+
function containsInitializeRequest(payload) {
|
|
3199
|
+
if (Array.isArray(payload)) return payload.some((message) => isInitializeRequest(message));
|
|
3200
|
+
return isInitializeRequest(payload);
|
|
3201
|
+
}
|
|
3202
|
+
function requestIdFromPayload(payload) {
|
|
3203
|
+
if (Array.isArray(payload)) {
|
|
3204
|
+
const request = payload.find((message) => isRecord3(message) && "id" in message);
|
|
3205
|
+
return isRecord3(request) ? request.id ?? null : null;
|
|
3206
|
+
}
|
|
3207
|
+
return isRecord3(payload) ? payload.id ?? null : null;
|
|
3208
|
+
}
|
|
3209
|
+
function openaiToolNameAlias(canonicalName) {
|
|
3210
|
+
const sanitized = canonicalName.replace(/[^A-Za-z0-9_-]+/g, "__").replace(/_{3,}/g, "__").replace(/^_+|_+$/g, "");
|
|
3211
|
+
const base = sanitized.length > 0 ? sanitized : `tool_${shortToolHash(canonicalName)}`;
|
|
3212
|
+
if (base.length <= 64) return base;
|
|
3213
|
+
const suffix = shortToolHash(canonicalName);
|
|
3214
|
+
return `${base.slice(0, Math.max(1, 63 - suffix.length)).replace(/_+$/g, "")}_${suffix}`;
|
|
3215
|
+
}
|
|
3216
|
+
function toolNameExposures(canonicalNames, style) {
|
|
3217
|
+
const exposedNames = toolNameExposureMap(canonicalNames, style);
|
|
3218
|
+
return canonicalNames.flatMap((canonicalName) => {
|
|
3219
|
+
return (exposedNames.get(canonicalName) ?? [canonicalName]).map((exposedName) => ({
|
|
3220
|
+
canonicalName,
|
|
3221
|
+
exposedName,
|
|
3222
|
+
isAlias: exposedName !== canonicalName,
|
|
3223
|
+
style
|
|
3224
|
+
}));
|
|
3225
|
+
});
|
|
3226
|
+
}
|
|
3227
|
+
function toolNameExposureMap(canonicalNames, style) {
|
|
3228
|
+
const exposedByCanonical = /* @__PURE__ */ new Map();
|
|
3229
|
+
const canonicalByExposed = /* @__PURE__ */ new Map();
|
|
3230
|
+
if (style === "both") {
|
|
3231
|
+
for (const canonical of canonicalNames) canonicalByExposed.set(canonical, canonical);
|
|
3232
|
+
}
|
|
3233
|
+
for (const canonical of canonicalNames) {
|
|
3234
|
+
const names = /* @__PURE__ */ new Set();
|
|
3235
|
+
if (style === "canonical" || style === "both") names.add(canonical);
|
|
3236
|
+
if (style === "openai" || style === "both") {
|
|
3237
|
+
let alias = openaiToolNameAlias(canonical);
|
|
3238
|
+
const existing = canonicalByExposed.get(alias);
|
|
3239
|
+
if (existing && existing !== canonical) {
|
|
3240
|
+
const suffix = shortToolHash(canonical);
|
|
3241
|
+
alias = `${alias.slice(0, Math.max(1, 63 - suffix.length)).replace(/_+$/g, "")}_${suffix}`;
|
|
3242
|
+
}
|
|
3243
|
+
canonicalByExposed.set(alias, canonical);
|
|
3244
|
+
names.add(alias);
|
|
3245
|
+
}
|
|
3246
|
+
exposedByCanonical.set(canonical, [...names]);
|
|
3247
|
+
}
|
|
3248
|
+
return exposedByCanonical;
|
|
3249
|
+
}
|
|
3250
|
+
function shortToolHash(value) {
|
|
3251
|
+
return crypto.createHash("sha256").update(value).digest("hex").slice(0, 8);
|
|
3252
|
+
}
|
|
3253
|
+
function setCorsHeaders(response, corsOrigin) {
|
|
3254
|
+
if (corsOrigin) {
|
|
3255
|
+
response.setHeader("access-control-allow-origin", corsOrigin);
|
|
3256
|
+
response.setHeader("access-control-allow-methods", "POST, GET, DELETE, OPTIONS");
|
|
3257
|
+
response.setHeader("access-control-allow-headers", "authorization, content-type, mcp-session-id, mcp-protocol-version, last-event-id");
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
2946
3260
|
function setCommonHttpHeaders(response, corsOrigin) {
|
|
2947
3261
|
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
2948
3262
|
if (corsOrigin) {
|
|
@@ -3006,6 +3320,28 @@ async function closeHttpServer(server, runtime) {
|
|
|
3006
3320
|
runtime.close();
|
|
3007
3321
|
});
|
|
3008
3322
|
}
|
|
3323
|
+
async function closeStreamableHttpServer(server, sessions) {
|
|
3324
|
+
await new Promise((resolve, reject) => {
|
|
3325
|
+
server.close((error) => {
|
|
3326
|
+
if (error) reject(error);
|
|
3327
|
+
else resolve();
|
|
3328
|
+
});
|
|
3329
|
+
}).finally(() => closeStreamableSessions(sessions));
|
|
3330
|
+
}
|
|
3331
|
+
async function closeStreamableSessions(sessions) {
|
|
3332
|
+
for (const session of [...sessions]) {
|
|
3333
|
+
sessions.delete(session);
|
|
3334
|
+
await session.transport.close().catch(() => void 0);
|
|
3335
|
+
disposeStreamableSession(session);
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
function disposeStreamableSession(session, sessionMap, openSessions) {
|
|
3339
|
+
if (session.closed) return;
|
|
3340
|
+
session.closed = true;
|
|
3341
|
+
if (session.sessionId) sessionMap?.delete(session.sessionId);
|
|
3342
|
+
openSessions?.delete(session);
|
|
3343
|
+
session.runtime.close();
|
|
3344
|
+
}
|
|
3009
3345
|
async function toolCallResult(runtime, toolName, args) {
|
|
3010
3346
|
try {
|
|
3011
3347
|
const structuredContent = await runtime.callTool(toolName, args);
|
|
@@ -3081,6 +3417,11 @@ function cloudToolMetadata(tool) {
|
|
|
3081
3417
|
}
|
|
3082
3418
|
};
|
|
3083
3419
|
}
|
|
3420
|
+
function toolDescriptionWithCanonical(description, canonicalName, exposedName) {
|
|
3421
|
+
if (!exposedName || exposedName === canonicalName) return description;
|
|
3422
|
+
return `Canonical Synapsor capability: ${canonicalName}.
|
|
3423
|
+
${description}`;
|
|
3424
|
+
}
|
|
3084
3425
|
function zodInputShapeFromJsonSchema(schema) {
|
|
3085
3426
|
const properties = isRecord3(schema.properties) ? schema.properties : {};
|
|
3086
3427
|
const required = Array.isArray(schema.required) ? new Set(schema.required.map(String)) : /* @__PURE__ */ new Set();
|
|
@@ -3258,6 +3599,135 @@ async function callConfiguredTool(input) {
|
|
|
3258
3599
|
source_database_mutated: false
|
|
3259
3600
|
};
|
|
3260
3601
|
}
|
|
3602
|
+
async function callConfiguredToolV2(input) {
|
|
3603
|
+
const capability = input.config.mode === "cloud" ? void 0 : localCapabilities(input.config).find((item) => item.name === input.name);
|
|
3604
|
+
try {
|
|
3605
|
+
const legacy = await callConfiguredTool(input);
|
|
3606
|
+
return resultEnvelopeFromLegacy(legacy, capability, input.name);
|
|
3607
|
+
} catch (error) {
|
|
3608
|
+
return errorEnvelopeFromError(error, capability, input.name);
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
3612
|
+
const action = typeof legacy.action === "string" ? legacy.action : canonicalName;
|
|
3613
|
+
const kind = capability?.kind ?? (typeof legacy.proposal_id === "string" ? "proposal" : "read");
|
|
3614
|
+
const evidenceBundleId = typeof legacy.evidence_bundle_id === "string" ? legacy.evidence_bundle_id : void 0;
|
|
3615
|
+
const sourceChanged = Boolean(legacy.source_database_changed ?? legacy.source_database_mutated ?? false);
|
|
3616
|
+
const context = isRecord3(legacy.trusted_context) ? legacy.trusted_context : void 0;
|
|
3617
|
+
const target2 = isRecord3(legacy.target) ? legacy.target : void 0;
|
|
3618
|
+
if (kind === "proposal") {
|
|
3619
|
+
const proposalId = typeof legacy.proposal_id === "string" ? legacy.proposal_id : "wrp_unknown";
|
|
3620
|
+
const targetType = typeof target2?.type === "string" ? target2.type : capability?.target.table ?? "object";
|
|
3621
|
+
const targetId = target2?.id !== void 0 ? String(target2.id) : "unknown";
|
|
3622
|
+
const executor = writebackExecutorName(legacy.writeback);
|
|
3623
|
+
const writebackMode = executor && executor !== "sql_update" && executor !== "trusted_worker_required" ? "app_handler" : "direct_update";
|
|
3624
|
+
return {
|
|
3625
|
+
ok: true,
|
|
3626
|
+
summary: `Created proposal ${proposalId} for ${targetType} ${targetId}. Source database changed: no.`,
|
|
3627
|
+
action,
|
|
3628
|
+
kind,
|
|
3629
|
+
data: null,
|
|
3630
|
+
proposal: {
|
|
3631
|
+
id: proposalId,
|
|
3632
|
+
state: typeof legacy.status === "string" ? legacy.status : "review_required",
|
|
3633
|
+
target: `${targetType}:${targetId}`,
|
|
3634
|
+
diff: isRecord3(legacy.diff) ? legacy.diff : {},
|
|
3635
|
+
approval_required: legacy.approval_required !== false,
|
|
3636
|
+
writeback: {
|
|
3637
|
+
mode: writebackMode,
|
|
3638
|
+
applied: false
|
|
3639
|
+
},
|
|
3640
|
+
next: "A human must approve outside this model-facing tool surface; nothing is committed yet."
|
|
3641
|
+
},
|
|
3642
|
+
error: null,
|
|
3643
|
+
evidence: evidenceBundleId ? evidenceHandle(evidenceBundleId) : null,
|
|
3644
|
+
source_database_changed: sourceChanged,
|
|
3645
|
+
_meta: {
|
|
3646
|
+
tenant_id: typeof target2?.tenant_id === "string" ? target2.tenant_id : void 0,
|
|
3647
|
+
principal: typeof context?.principal === "string" ? context.principal : void 0,
|
|
3648
|
+
provenance: typeof context?.provenance === "string" ? context.provenance : void 0,
|
|
3649
|
+
canonical_capability: action
|
|
3650
|
+
}
|
|
3651
|
+
};
|
|
3652
|
+
}
|
|
3653
|
+
const businessObject = isRecord3(legacy.business_object) ? legacy.business_object : void 0;
|
|
3654
|
+
const objectType = typeof businessObject?.type === "string" ? businessObject.type : capability?.target.table ?? "record";
|
|
3655
|
+
const objectId = businessObject?.id !== void 0 ? String(businessObject.id) : String(legacy.action ?? action);
|
|
3656
|
+
return {
|
|
3657
|
+
ok: true,
|
|
3658
|
+
summary: `Read ${objectType} ${objectId} through ${action}. Source database changed: no.`,
|
|
3659
|
+
action,
|
|
3660
|
+
kind: "read",
|
|
3661
|
+
data: isRecord3(legacy.data) ? legacy.data : {},
|
|
3662
|
+
proposal: null,
|
|
3663
|
+
error: null,
|
|
3664
|
+
evidence: evidenceBundleId ? evidenceHandle(evidenceBundleId) : null,
|
|
3665
|
+
source_database_changed: sourceChanged,
|
|
3666
|
+
_meta: {
|
|
3667
|
+
tenant_id: typeof context?.tenant_id === "string" ? context.tenant_id : void 0,
|
|
3668
|
+
principal: typeof context?.principal === "string" ? context.principal : void 0,
|
|
3669
|
+
provenance: typeof context?.provenance === "string" ? context.provenance : void 0,
|
|
3670
|
+
canonical_capability: action
|
|
3671
|
+
}
|
|
3672
|
+
};
|
|
3673
|
+
}
|
|
3674
|
+
function writebackExecutorName(value) {
|
|
3675
|
+
if (!isRecord3(value)) return void 0;
|
|
3676
|
+
return typeof value.executor === "string" ? value.executor : typeof value.mode === "string" ? value.mode : void 0;
|
|
3677
|
+
}
|
|
3678
|
+
function evidenceHandle(bundleId) {
|
|
3679
|
+
return {
|
|
3680
|
+
bundle_id: bundleId,
|
|
3681
|
+
note: "audit/replay handle; you do not need to act on it during this turn"
|
|
3682
|
+
};
|
|
3683
|
+
}
|
|
3684
|
+
function errorEnvelopeFromError(error, capability, canonicalName) {
|
|
3685
|
+
const safe = safeToolError(error);
|
|
3686
|
+
const action = capability?.name ?? canonicalName;
|
|
3687
|
+
return {
|
|
3688
|
+
ok: false,
|
|
3689
|
+
summary: safe.message,
|
|
3690
|
+
action,
|
|
3691
|
+
kind: capability?.kind ?? "read",
|
|
3692
|
+
data: null,
|
|
3693
|
+
proposal: null,
|
|
3694
|
+
error: safe,
|
|
3695
|
+
evidence: null,
|
|
3696
|
+
source_database_changed: false,
|
|
3697
|
+
_meta: {
|
|
3698
|
+
canonical_capability: action
|
|
3699
|
+
}
|
|
3700
|
+
};
|
|
3701
|
+
}
|
|
3702
|
+
function safeToolError(error) {
|
|
3703
|
+
const runtimeCode = error instanceof McpRuntimeError ? error.code : void 0;
|
|
3704
|
+
if (runtimeCode === "ROW_NOT_FOUND") {
|
|
3705
|
+
return { code: "NOT_FOUND_IN_TENANT", message: "No authorized row was found in the trusted tenant scope.", retryable: false };
|
|
3706
|
+
}
|
|
3707
|
+
if (runtimeCode === "MCP_TOOL_NOT_FOUND") {
|
|
3708
|
+
return { code: "CAPABILITY_NOT_FOUND", message: "The requested Synapsor capability is not available.", retryable: false };
|
|
3709
|
+
}
|
|
3710
|
+
if (runtimeCode === "PROPOSALS_DISABLED") {
|
|
3711
|
+
return { code: "APPROVAL_REQUIRED", message: "Proposal tools are disabled for this runner mode.", retryable: false };
|
|
3712
|
+
}
|
|
3713
|
+
if (runtimeCode && (runtimeCode.startsWith("ARGUMENT_") || runtimeCode === "LOOKUP_ARG_MISSING" || runtimeCode === "MODEL_CANNOT_OVERRIDE_BINDING" || runtimeCode === "TRUSTED_BINDING_MISSING" || runtimeCode === "TRUSTED_CONTEXT_MISSING")) {
|
|
3714
|
+
return { code: "INVALID_ARGUMENT", message: "The tool input or trusted context binding is invalid.", retryable: false };
|
|
3715
|
+
}
|
|
3716
|
+
if (runtimeCode && (runtimeCode.startsWith("PATCH_") || runtimeCode === "CONFLICT_GUARD_MISSING")) {
|
|
3717
|
+
return { code: "POLICY_VIOLATION", message: "The requested change is outside the reviewed capability policy.", retryable: false };
|
|
3718
|
+
}
|
|
3719
|
+
if (runtimeCode === "LOCAL_STORE_UNAVAILABLE") {
|
|
3720
|
+
return { code: "TEMPORARILY_UNAVAILABLE", message: "The local runner store is temporarily unavailable. Restart the runner or recreate the store before retrying.", retryable: true };
|
|
3721
|
+
}
|
|
3722
|
+
if (runtimeCode === "SOURCE_CREDENTIAL_MISSING" || looksLikeInfraError(error)) {
|
|
3723
|
+
return { code: "TEMPORARILY_UNAVAILABLE", message: "The database is temporarily unavailable. Retry later.", retryable: true };
|
|
3724
|
+
}
|
|
3725
|
+
return { code: "INTERNAL", message: "The capability failed safely. Check the local runner logs for details.", retryable: false };
|
|
3726
|
+
}
|
|
3727
|
+
function looksLikeInfraError(error) {
|
|
3728
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
3729
|
+
return /\b(ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|timeout|connect|connection|database|authentication|certificate)\b/i.test(message);
|
|
3730
|
+
}
|
|
3261
3731
|
function buildChangeSet(input) {
|
|
3262
3732
|
const patch = buildPatch(input.capability, input.args);
|
|
3263
3733
|
const before = scalarRecord(input.currentRow);
|
|
@@ -3459,7 +3929,7 @@ function zodInputShape(capability) {
|
|
|
3459
3929
|
if (spec.type === "number" && spec.maximum !== void 0) schema = schema.max(spec.maximum);
|
|
3460
3930
|
if (spec.enum && spec.enum.length > 0) schema = schema.refine((value) => spec.enum?.includes(value), "value is not allowlisted");
|
|
3461
3931
|
if (spec.required === false) schema = schema.optional();
|
|
3462
|
-
shape[name] = schema.describe(`${name} business argument`);
|
|
3932
|
+
shape[name] = schema.describe(spec.description ?? `${name} business argument`);
|
|
3463
3933
|
}
|
|
3464
3934
|
return shape;
|
|
3465
3935
|
}
|
|
@@ -3472,6 +3942,7 @@ function toolMetadata(capability) {
|
|
|
3472
3942
|
input_schema: Object.fromEntries(Object.entries(capability.args).map(([name, spec]) => [name, {
|
|
3473
3943
|
type: spec.type,
|
|
3474
3944
|
required: spec.required !== false,
|
|
3945
|
+
...spec.description !== void 0 ? { description: spec.description } : {},
|
|
3475
3946
|
...spec.max_length !== void 0 ? { max_length: spec.max_length } : {},
|
|
3476
3947
|
...spec.minimum !== void 0 ? { minimum: spec.minimum } : {},
|
|
3477
3948
|
...spec.maximum !== void 0 ? { maximum: spec.maximum } : {},
|
|
@@ -3487,11 +3958,23 @@ function toolMetadata(capability) {
|
|
|
3487
3958
|
}
|
|
3488
3959
|
};
|
|
3489
3960
|
}
|
|
3490
|
-
function capabilityDescription(capability) {
|
|
3491
|
-
|
|
3492
|
-
|
|
3961
|
+
function capabilityDescription(capability, exposedName) {
|
|
3962
|
+
const lines = [];
|
|
3963
|
+
if (exposedName && exposedName !== capability.name) {
|
|
3964
|
+
lines.push(`Canonical Synapsor capability: ${capability.name}.`);
|
|
3965
|
+
}
|
|
3966
|
+
if (capability.description) {
|
|
3967
|
+
lines.push(capability.description);
|
|
3968
|
+
} else if (capability.kind === "read") {
|
|
3969
|
+
lines.push(`Read ${capability.target.schema}.${capability.target.table} through a reviewed Synapsor capability with trusted tenant context and evidence.`);
|
|
3970
|
+
} else {
|
|
3971
|
+
lines.push(`Create an evidence-backed Synapsor proposal for ${capability.target.schema}.${capability.target.table}; the source database is not mutated by this tool.`);
|
|
3972
|
+
}
|
|
3973
|
+
if (capability.returns_hint) {
|
|
3974
|
+
lines.push(capability.returns_hint);
|
|
3493
3975
|
}
|
|
3494
|
-
|
|
3976
|
+
lines.push("Evidence handles are audit/replay handles; the model does not need to call them during this turn.");
|
|
3977
|
+
return lines.join("\n");
|
|
3495
3978
|
}
|
|
3496
3979
|
function buildPatch(capability, args) {
|
|
3497
3980
|
if (!capability.patch) throw new McpRuntimeError("PATCH_REQUIRED", "Proposal capability has no patch mapping.");
|
|
@@ -3608,6 +4091,9 @@ function isRecord3(value) {
|
|
|
3608
4091
|
}
|
|
3609
4092
|
function toolErrorPayload(error) {
|
|
3610
4093
|
if (error instanceof McpRuntimeError) {
|
|
4094
|
+
if (error.code === "LOCAL_STORE_UNAVAILABLE") {
|
|
4095
|
+
return { ok: false, code: "TEMPORARILY_UNAVAILABLE", error: "The local runner store is temporarily unavailable. Restart the runner or recreate the store before retrying." };
|
|
4096
|
+
}
|
|
3611
4097
|
return { ok: false, code: error.code, error: error.message };
|
|
3612
4098
|
}
|
|
3613
4099
|
return { ok: false, code: "MCP_TOOL_FAILED", error: error instanceof Error ? error.message : String(error) };
|
|
@@ -4005,6 +4491,7 @@ import { Pool as Pool3 } from "pg";
|
|
|
4005
4491
|
var TENANT_COLUMNS = /* @__PURE__ */ new Set(["tenant_id", "account_id", "organization_id", "org_id", "workspace_id", "customer_id"]);
|
|
4006
4492
|
var CONFLICT_COLUMNS = /* @__PURE__ */ new Set(["updated_at", "modified_at", "row_version", "version", "lock_version", "etag"]);
|
|
4007
4493
|
var IMMUTABLE_COLUMNS = /* @__PURE__ */ new Set(["id", "uuid", "created_at", "created_by"]);
|
|
4494
|
+
var DEFAULT_RESULT_FORMAT = 2;
|
|
4008
4495
|
var SENSITIVE_PATTERNS = [
|
|
4009
4496
|
/password/i,
|
|
4010
4497
|
/password_hash/i,
|
|
@@ -4056,22 +4543,26 @@ function generateRunnerConfigFromSpec(spec) {
|
|
|
4056
4543
|
const mode = spec.mode ?? "shadow";
|
|
4057
4544
|
const sourceName = spec.source_name ?? (spec.engine === "postgres" ? "local_postgres" : "local_mysql");
|
|
4058
4545
|
const readUrlEnv = spec.read_url_env ?? spec.database_url_env ?? "SYNAPSOR_DATABASE_READ_URL";
|
|
4059
|
-
const
|
|
4546
|
+
const writeback2 = normalizedWriteback(spec);
|
|
4547
|
+
const writeUrlEnv = writeback2.executor === "sql_update" ? spec.write_url_env ?? "SYNAPSOR_DATABASE_WRITE_URL" : void 0;
|
|
4060
4548
|
const tenantEnv = spec.trusted_context?.tenant_id_env ?? "SYNAPSOR_TENANT_ID";
|
|
4061
4549
|
const principalEnv = spec.trusted_context?.principal_env ?? "SYNAPSOR_PRINCIPAL";
|
|
4062
4550
|
const objectName = spec.object_name ?? singularize(safeName(spec.table));
|
|
4063
4551
|
const lookupArg = spec.lookup_arg ?? `${objectName}_id`;
|
|
4064
4552
|
const inspectToolName = spec.inspect_tool_name ?? `${spec.namespace}.inspect_${objectName}`;
|
|
4065
4553
|
const proposalToolName = spec.proposal_tool_name ?? `${spec.namespace}.propose_${objectName}_update`;
|
|
4554
|
+
const objectLabel = objectName.replace(/_/g, " ");
|
|
4066
4555
|
const visibleColumns = unique([spec.primary_key, spec.tenant_key, spec.conflict_column, ...spec.visible_columns].filter((value) => Boolean(value)));
|
|
4067
4556
|
const readCapability = {
|
|
4068
4557
|
name: inspectToolName,
|
|
4069
4558
|
kind: "read",
|
|
4559
|
+
description: spec.inspect_description ?? `Inspect one ${objectLabel} in trusted tenant scope before answering or proposing a change.`,
|
|
4560
|
+
returns_hint: spec.inspect_returns_hint ?? `Returns reviewed ${objectLabel} fields, evidence handle, query audit, and source_database_changed:false.`,
|
|
4070
4561
|
source: sourceName,
|
|
4071
4562
|
context: "local_operator",
|
|
4072
4563
|
target: target(spec),
|
|
4073
4564
|
args: {
|
|
4074
|
-
[lookupArg]: { type: "string", required: true, max_length: 128 }
|
|
4565
|
+
[lookupArg]: { type: "string", required: true, max_length: 128, description: `${capitalize(objectLabel)} id from the user request or trusted app context.` }
|
|
4075
4566
|
},
|
|
4076
4567
|
lookup: { id_from_arg: lookupArg },
|
|
4077
4568
|
visible_columns: visibleColumns,
|
|
@@ -4084,8 +4575,11 @@ function generateRunnerConfigFromSpec(spec) {
|
|
|
4084
4575
|
capabilities.push({
|
|
4085
4576
|
name: proposalToolName,
|
|
4086
4577
|
kind: "proposal",
|
|
4578
|
+
description: spec.proposal_description ?? `Create a review-required proposal to update one ${objectLabel}. The source database remains unchanged until approval and writeback.`,
|
|
4579
|
+
returns_hint: spec.proposal_returns_hint ?? "Returns a proposal id, exact before/after diff, evidence handle, approval status, and source_database_changed:false.",
|
|
4087
4580
|
source: sourceName,
|
|
4088
4581
|
context: "local_operator",
|
|
4582
|
+
...writeback2.executor !== "sql_update" ? { executor: writeback2.executorName } : {},
|
|
4089
4583
|
target: target(spec),
|
|
4090
4584
|
args: {
|
|
4091
4585
|
[lookupArg]: { type: "string", required: true, max_length: 128 },
|
|
@@ -4106,12 +4600,14 @@ function generateRunnerConfigFromSpec(spec) {
|
|
|
4106
4600
|
const config = {
|
|
4107
4601
|
version: 1,
|
|
4108
4602
|
mode,
|
|
4603
|
+
result_format: spec.result_format ?? DEFAULT_RESULT_FORMAT,
|
|
4109
4604
|
storage: { sqlite_path: "./.synapsor/local.db" },
|
|
4110
4605
|
sources: {
|
|
4111
4606
|
[sourceName]: {
|
|
4112
4607
|
engine: spec.engine,
|
|
4113
4608
|
read_url_env: readUrlEnv,
|
|
4114
|
-
...mode === "review" ? { write_url_env: writeUrlEnv } : {},
|
|
4609
|
+
...mode === "review" && writeUrlEnv ? { write_url_env: writeUrlEnv } : {},
|
|
4610
|
+
...mode === "review" && writeback2.executor !== "sql_update" && !writeUrlEnv ? { read_only: true } : {},
|
|
4115
4611
|
statement_timeout_ms: spec.statement_timeout_ms ?? 3e3
|
|
4116
4612
|
}
|
|
4117
4613
|
},
|
|
@@ -4131,11 +4627,20 @@ function generateRunnerConfigFromSpec(spec) {
|
|
|
4131
4627
|
}
|
|
4132
4628
|
}
|
|
4133
4629
|
},
|
|
4630
|
+
...mode === "review" && writeback2.executor !== "sql_update" ? { executors: writeback2.executors } : {},
|
|
4134
4631
|
capabilities
|
|
4135
4632
|
};
|
|
4136
4633
|
return {
|
|
4137
4634
|
config,
|
|
4138
|
-
envExample: envExample({
|
|
4635
|
+
envExample: envExample({
|
|
4636
|
+
readUrlEnv,
|
|
4637
|
+
writeUrlEnv,
|
|
4638
|
+
tenantEnv,
|
|
4639
|
+
principalEnv,
|
|
4640
|
+
mode,
|
|
4641
|
+
engine: spec.engine,
|
|
4642
|
+
extraEnv: writeback2.extraEnv
|
|
4643
|
+
}),
|
|
4139
4644
|
mcpSnippets: mcpSnippets()
|
|
4140
4645
|
};
|
|
4141
4646
|
}
|
|
@@ -4450,6 +4955,52 @@ function target(spec) {
|
|
|
4450
4955
|
...spec.tenant_key ? { tenant_key: spec.tenant_key } : { single_tenant_dev: Boolean(spec.single_tenant_dev) }
|
|
4451
4956
|
};
|
|
4452
4957
|
}
|
|
4958
|
+
function normalizedWriteback(spec) {
|
|
4959
|
+
const executor = spec.writeback?.executor ?? "sql_update";
|
|
4960
|
+
if (executor === "sql_update") {
|
|
4961
|
+
return { executor, extraEnv: [] };
|
|
4962
|
+
}
|
|
4963
|
+
const executorName = spec.writeback?.executor_name ?? `${safeName(spec.namespace)}_${executor === "http_handler" ? "http_handler" : "command_handler"}`;
|
|
4964
|
+
if (executor === "http_handler") {
|
|
4965
|
+
const urlEnv = spec.writeback?.handler_url_env ?? "SYNAPSOR_APP_WRITEBACK_URL";
|
|
4966
|
+
const tokenEnv = spec.writeback?.handler_token_env;
|
|
4967
|
+
const signingSecretEnv = spec.writeback?.handler_signing_secret_env;
|
|
4968
|
+
return {
|
|
4969
|
+
executor,
|
|
4970
|
+
executorName,
|
|
4971
|
+
executors: {
|
|
4972
|
+
[executorName]: {
|
|
4973
|
+
type: "http_handler",
|
|
4974
|
+
url_env: urlEnv,
|
|
4975
|
+
method: "POST",
|
|
4976
|
+
...tokenEnv ? { auth: { type: "bearer_env", token_env: tokenEnv } } : {},
|
|
4977
|
+
...signingSecretEnv ? { signing_secret_env: signingSecretEnv } : {},
|
|
4978
|
+
timeout_ms: spec.writeback?.timeout_ms ?? 5e3
|
|
4979
|
+
}
|
|
4980
|
+
},
|
|
4981
|
+
extraEnv: [
|
|
4982
|
+
{ name: urlEnv, value: "http://127.0.0.1:8787/synapsor/writeback", comment: "App-owned writeback handler endpoint." },
|
|
4983
|
+
...tokenEnv ? [{ name: tokenEnv, value: "<handler-bearer-token>", comment: "Optional handler bearer token." }] : [],
|
|
4984
|
+
...signingSecretEnv ? [{ name: signingSecretEnv, value: "<handler-hmac-signing-secret>", comment: "Optional HMAC signing secret for Runner-to-handler requests." }] : []
|
|
4985
|
+
]
|
|
4986
|
+
};
|
|
4987
|
+
}
|
|
4988
|
+
const commandEnv = spec.writeback?.handler_command_env ?? "SYNAPSOR_APP_WRITEBACK_COMMAND";
|
|
4989
|
+
return {
|
|
4990
|
+
executor,
|
|
4991
|
+
executorName,
|
|
4992
|
+
executors: {
|
|
4993
|
+
[executorName]: {
|
|
4994
|
+
type: "command_handler",
|
|
4995
|
+
command_env: commandEnv,
|
|
4996
|
+
timeout_ms: spec.writeback?.timeout_ms ?? 5e3
|
|
4997
|
+
}
|
|
4998
|
+
},
|
|
4999
|
+
extraEnv: [
|
|
5000
|
+
{ name: commandEnv, value: "node ./examples/app-owned-writeback/command-handler.mjs", comment: "Command receives the structured handler proposal JSON on stdin." }
|
|
5001
|
+
]
|
|
5002
|
+
};
|
|
5003
|
+
}
|
|
4453
5004
|
function inferPatchArgs(patch, explicit, numericBounds, transitionGuards) {
|
|
4454
5005
|
const args = {};
|
|
4455
5006
|
for (const [column, binding] of Object.entries(patch)) {
|
|
@@ -4475,14 +5026,18 @@ function envExample(input) {
|
|
|
4475
5026
|
"# Synapsor Runner local environment.",
|
|
4476
5027
|
"# Replace examples locally. Do not commit real credentials.",
|
|
4477
5028
|
`${input.readUrlEnv}="${readExample}"`,
|
|
4478
|
-
...input.mode === "review" ? [`${input.writeUrlEnv}="${writeExample}"`] : [],
|
|
5029
|
+
...input.mode === "review" && input.writeUrlEnv ? [`${input.writeUrlEnv}="${writeExample}"`] : [],
|
|
5030
|
+
...(input.extraEnv ?? []).flatMap((item) => [
|
|
5031
|
+
...item.comment ? [`# ${item.comment}`] : [],
|
|
5032
|
+
`${item.name}="${item.value}"`
|
|
5033
|
+
]),
|
|
4479
5034
|
`${input.tenantEnv}="acme"`,
|
|
4480
5035
|
`${input.principalEnv}="local_operator"`,
|
|
4481
5036
|
""
|
|
4482
5037
|
].join("\n");
|
|
4483
5038
|
}
|
|
4484
5039
|
function mcpSnippets() {
|
|
4485
|
-
const command = "synapsor";
|
|
5040
|
+
const command = "synapsor-runner";
|
|
4486
5041
|
const args = ["mcp", "serve", "--config", "./synapsor.runner.json", "--store", "./.synapsor/local.db"];
|
|
4487
5042
|
return {
|
|
4488
5043
|
"generic-stdio.json": { command, args },
|
|
@@ -4495,6 +5050,16 @@ function validateSelectionSpec(spec) {
|
|
|
4495
5050
|
if (spec.version !== void 0 && spec.version !== 1) throw new Error("onboarding selection version must be 1.");
|
|
4496
5051
|
if (spec.engine !== "postgres" && spec.engine !== "mysql") throw new Error("selection engine must be postgres or mysql.");
|
|
4497
5052
|
if (!["read_only", "shadow", "review", void 0].includes(spec.mode)) throw new Error("selection mode must be read_only, shadow, or review.");
|
|
5053
|
+
if (spec.result_format !== void 0 && spec.result_format !== 1 && spec.result_format !== 2) throw new Error("selection result_format must be 1 or 2.");
|
|
5054
|
+
if (spec.writeback?.executor && !["sql_update", "http_handler", "command_handler"].includes(spec.writeback.executor)) {
|
|
5055
|
+
throw new Error("selection writeback.executor must be sql_update, http_handler, or command_handler.");
|
|
5056
|
+
}
|
|
5057
|
+
if (spec.mode !== "review" && spec.writeback?.executor && spec.writeback.executor !== "sql_update") {
|
|
5058
|
+
throw new Error("app-owned writeback executors are only valid in review mode.");
|
|
5059
|
+
}
|
|
5060
|
+
if (spec.writeback?.timeout_ms !== void 0 && (!Number.isInteger(spec.writeback.timeout_ms) || spec.writeback.timeout_ms <= 0)) {
|
|
5061
|
+
throw new Error("selection writeback.timeout_ms must be a positive integer.");
|
|
5062
|
+
}
|
|
4498
5063
|
for (const [label, value] of Object.entries({
|
|
4499
5064
|
schema: spec.schema,
|
|
4500
5065
|
table: spec.table,
|
|
@@ -4540,6 +5105,7 @@ function validateSelectionSpec(spec) {
|
|
|
4540
5105
|
spec.tenant_key,
|
|
4541
5106
|
spec.conflict_column,
|
|
4542
5107
|
spec.lookup_arg,
|
|
5108
|
+
spec.writeback?.executor_name,
|
|
4543
5109
|
...spec.visible_columns ?? [],
|
|
4544
5110
|
...spec.allowed_columns ?? [],
|
|
4545
5111
|
...Object.keys(spec.numeric_bounds ?? {}),
|
|
@@ -4552,6 +5118,10 @@ function validateSelectionSpec(spec) {
|
|
|
4552
5118
|
spec.read_url_env,
|
|
4553
5119
|
spec.database_url_env,
|
|
4554
5120
|
spec.write_url_env,
|
|
5121
|
+
spec.writeback?.handler_url_env,
|
|
5122
|
+
spec.writeback?.handler_token_env,
|
|
5123
|
+
spec.writeback?.handler_signing_secret_env,
|
|
5124
|
+
spec.writeback?.handler_command_env,
|
|
4555
5125
|
spec.trusted_context?.tenant_id_env,
|
|
4556
5126
|
spec.trusted_context?.principal_env
|
|
4557
5127
|
].filter(Boolean)) {
|
|
@@ -4585,6 +5155,10 @@ function singularize(value) {
|
|
|
4585
5155
|
function safeName(value) {
|
|
4586
5156
|
return value.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "record";
|
|
4587
5157
|
}
|
|
5158
|
+
function capitalize(value) {
|
|
5159
|
+
if (!value) return value;
|
|
5160
|
+
return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
|
|
5161
|
+
}
|
|
4588
5162
|
function assertSafeIdentifier(identifier) {
|
|
4589
5163
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
4590
5164
|
throw new Error(`unsafe identifier in selection: ${identifier}`);
|
|
@@ -6036,43 +6610,248 @@ var dangerousDatabaseMcpAuditExample = {
|
|
|
6036
6610
|
var defaultConfigPath = "synapsor.runner.json";
|
|
6037
6611
|
var defaultStorePath = "./.synapsor/local.db";
|
|
6038
6612
|
var quickDemoStorePath = "./.synapsor/quick-demo.db";
|
|
6039
|
-
var
|
|
6040
|
-
var
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6058
|
-
|
|
6059
|
-
|
|
6613
|
+
var generatedSmokeInputPath = "./.synapsor/smoke-input.json";
|
|
6614
|
+
var handlerSecurityWarning = [
|
|
6615
|
+
"IMPORTANT: your app handler owns the final business write.",
|
|
6616
|
+
"Runner creates the proposal and calls your handler only after approval, but your handler must still enforce:",
|
|
6617
|
+
"- tenant/scope check;",
|
|
6618
|
+
"- expected-version or conflict guard;",
|
|
6619
|
+
"- idempotency key;",
|
|
6620
|
+
"- allowed business action;",
|
|
6621
|
+
"- transaction/rollback;",
|
|
6622
|
+
"- safe error receipt.",
|
|
6623
|
+
"",
|
|
6624
|
+
"If you skip those checks, you can reintroduce cross-tenant writes, lost updates, or duplicate writes.",
|
|
6625
|
+
"Use the generated template/helper pattern and keep handler credentials out of MCP."
|
|
6626
|
+
].join("\n");
|
|
6627
|
+
var handlerTemplateDefinitions = {
|
|
6628
|
+
"node-fastify": {
|
|
6629
|
+
aliases: ["node", "fastify"],
|
|
6630
|
+
fileName: "synapsor-writeback-handler.mjs",
|
|
6631
|
+
description: "HTTP handler template for a Node/Fastify application service.",
|
|
6632
|
+
content: `import Fastify from "fastify";
|
|
6633
|
+
|
|
6634
|
+
const port = Number(process.env.PORT || 8787);
|
|
6635
|
+
const expectedToken = process.env.SYNAPSOR_APP_WRITEBACK_TOKEN || "dev-handler-token";
|
|
6636
|
+
|
|
6637
|
+
const app = Fastify({ logger: true });
|
|
6638
|
+
|
|
6639
|
+
app.post("/synapsor/writeback", async (request, reply) => {
|
|
6640
|
+
const auth = request.headers.authorization || "";
|
|
6641
|
+
if (auth !== \`Bearer \${expectedToken}\`) {
|
|
6642
|
+
return reply.code(401).send({ status: "failed", safe_error_code: "UNAUTHORIZED" });
|
|
6060
6643
|
}
|
|
6061
|
-
if (!isKnownTopLevelCommand(command)) {
|
|
6062
|
-
process2.stderr.write(`Unknown command: ${cliCommandName()} ${command}
|
|
6063
6644
|
|
|
6064
|
-
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6645
|
+
const body = request.body || {};
|
|
6646
|
+
const changeSet = body.change_set || {};
|
|
6647
|
+
|
|
6648
|
+
if (!body.proposal_id || !body.idempotency_key || !changeSet.scope?.tenant_id) {
|
|
6649
|
+
return reply.code(400).send({ status: "failed", safe_error_code: "BAD_WRITEBACK_REQUEST" });
|
|
6068
6650
|
}
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
return
|
|
6651
|
+
|
|
6652
|
+
if (body.dry_run) {
|
|
6653
|
+
return {
|
|
6654
|
+
status: "applied",
|
|
6655
|
+
rows_affected: 0,
|
|
6656
|
+
source_database_mutated: false,
|
|
6657
|
+
details: { dry_run: true },
|
|
6658
|
+
};
|
|
6072
6659
|
}
|
|
6073
|
-
|
|
6074
|
-
|
|
6075
|
-
|
|
6660
|
+
|
|
6661
|
+
/*
|
|
6662
|
+
* IMPORTANT: your app handler owns the final business write.
|
|
6663
|
+
* Runner creates the proposal and calls your handler only after approval,
|
|
6664
|
+
* but your handler must still enforce tenant/scope, expected-version or
|
|
6665
|
+
* conflict guard, idempotency key, allowed business action,
|
|
6666
|
+
* transaction/rollback, and safe error receipt.
|
|
6667
|
+
*
|
|
6668
|
+
* If you skip those checks, you can reintroduce cross-tenant writes,
|
|
6669
|
+
* lost updates, or duplicate writes. Keep handler credentials out of MCP.
|
|
6670
|
+
*
|
|
6671
|
+
* Put your app-owned transaction here.
|
|
6672
|
+
*
|
|
6673
|
+
* Examples:
|
|
6674
|
+
* - insert a refund_review row;
|
|
6675
|
+
* - insert an account_credit row;
|
|
6676
|
+
* - open a support_ticket row;
|
|
6677
|
+
* - update multiple related rows in one app transaction.
|
|
6678
|
+
*
|
|
6679
|
+
* Re-check tenant/principal authorization, idempotency, row/version guards,
|
|
6680
|
+
* and business policy before mutating application state.
|
|
6681
|
+
*/
|
|
6682
|
+
|
|
6683
|
+
return {
|
|
6684
|
+
status: "applied",
|
|
6685
|
+
rows_affected: 1,
|
|
6686
|
+
previous_version: String(changeSet.guards?.expected_version?.value || ""),
|
|
6687
|
+
new_version: new Date().toISOString(),
|
|
6688
|
+
source_database_mutated: true,
|
|
6689
|
+
};
|
|
6690
|
+
});
|
|
6691
|
+
|
|
6692
|
+
app.listen({ host: "127.0.0.1", port });
|
|
6693
|
+
`
|
|
6694
|
+
},
|
|
6695
|
+
"python-fastapi": {
|
|
6696
|
+
aliases: ["python", "fastapi"],
|
|
6697
|
+
fileName: "synapsor_writeback_handler.py",
|
|
6698
|
+
description: "HTTP handler template for a Python/FastAPI application service.",
|
|
6699
|
+
content: `import os
|
|
6700
|
+
from datetime import datetime, timezone
|
|
6701
|
+
|
|
6702
|
+
from fastapi import FastAPI, Header, HTTPException
|
|
6703
|
+
|
|
6704
|
+
app = FastAPI()
|
|
6705
|
+
expected_token = os.getenv("SYNAPSOR_APP_WRITEBACK_TOKEN", "dev-handler-token")
|
|
6706
|
+
|
|
6707
|
+
|
|
6708
|
+
@app.post("/synapsor/writeback")
|
|
6709
|
+
async def synapsor_writeback(body: dict, authorization: str | None = Header(default=None)):
|
|
6710
|
+
if authorization != f"Bearer {expected_token}":
|
|
6711
|
+
raise HTTPException(status_code=401, detail={"status": "failed", "safe_error_code": "UNAUTHORIZED"})
|
|
6712
|
+
|
|
6713
|
+
change_set = body.get("change_set") or {}
|
|
6714
|
+
scope = change_set.get("scope") or {}
|
|
6715
|
+
if not body.get("proposal_id") or not body.get("idempotency_key") or not scope.get("tenant_id"):
|
|
6716
|
+
raise HTTPException(status_code=400, detail={"status": "failed", "safe_error_code": "BAD_WRITEBACK_REQUEST"})
|
|
6717
|
+
|
|
6718
|
+
if body.get("dry_run"):
|
|
6719
|
+
return {
|
|
6720
|
+
"status": "applied",
|
|
6721
|
+
"rows_affected": 0,
|
|
6722
|
+
"source_database_mutated": False,
|
|
6723
|
+
"details": {"dry_run": True},
|
|
6724
|
+
}
|
|
6725
|
+
|
|
6726
|
+
# Put your app-owned transaction here.
|
|
6727
|
+
#
|
|
6728
|
+
# IMPORTANT: your app handler owns the final business write.
|
|
6729
|
+
# Runner creates the proposal and calls your handler only after approval,
|
|
6730
|
+
# but your handler must still enforce tenant/scope, expected-version or
|
|
6731
|
+
# conflict guard, idempotency key, allowed business action,
|
|
6732
|
+
# transaction/rollback, and safe error receipt.
|
|
6733
|
+
#
|
|
6734
|
+
# If you skip those checks, you can reintroduce cross-tenant writes,
|
|
6735
|
+
# lost updates, or duplicate writes. Keep handler credentials out of MCP.
|
|
6736
|
+
#
|
|
6737
|
+
# Examples:
|
|
6738
|
+
# - insert a refund_review row;
|
|
6739
|
+
# - insert an account_credit row;
|
|
6740
|
+
# - open a support_ticket row;
|
|
6741
|
+
# - update multiple related rows in one app transaction.
|
|
6742
|
+
#
|
|
6743
|
+
# Re-check tenant/principal authorization, idempotency, row/version guards,
|
|
6744
|
+
# and business policy before mutating application state.
|
|
6745
|
+
|
|
6746
|
+
expected_version = ((change_set.get("guards") or {}).get("expected_version") or {}).get("value", "")
|
|
6747
|
+
return {
|
|
6748
|
+
"status": "applied",
|
|
6749
|
+
"rows_affected": 1,
|
|
6750
|
+
"previous_version": str(expected_version),
|
|
6751
|
+
"new_version": datetime.now(timezone.utc).isoformat(),
|
|
6752
|
+
"source_database_mutated": True,
|
|
6753
|
+
}
|
|
6754
|
+
`
|
|
6755
|
+
},
|
|
6756
|
+
command: {
|
|
6757
|
+
aliases: ["script", "local-command"],
|
|
6758
|
+
fileName: "synapsor-command-handler.mjs",
|
|
6759
|
+
description: "Local command handler template for scripts or job runners.",
|
|
6760
|
+
content: `#!/usr/bin/env node
|
|
6761
|
+
|
|
6762
|
+
const chunks = [];
|
|
6763
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
6764
|
+
|
|
6765
|
+
const request = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
6766
|
+
const changeSet = request.change_set || {};
|
|
6767
|
+
|
|
6768
|
+
if (!request.proposal_id || !request.idempotency_key || !changeSet.scope?.tenant_id) {
|
|
6769
|
+
process.stdout.write(JSON.stringify({
|
|
6770
|
+
status: "failed",
|
|
6771
|
+
safe_error_code: "BAD_WRITEBACK_REQUEST",
|
|
6772
|
+
source_database_mutated: false,
|
|
6773
|
+
}));
|
|
6774
|
+
process.exit(0);
|
|
6775
|
+
}
|
|
6776
|
+
|
|
6777
|
+
if (request.dry_run) {
|
|
6778
|
+
process.stdout.write(JSON.stringify({
|
|
6779
|
+
status: "applied",
|
|
6780
|
+
rows_affected: 0,
|
|
6781
|
+
source_database_mutated: false,
|
|
6782
|
+
details: { dry_run: true },
|
|
6783
|
+
}));
|
|
6784
|
+
process.exit(0);
|
|
6785
|
+
}
|
|
6786
|
+
|
|
6787
|
+
/*
|
|
6788
|
+
* IMPORTANT: your app handler owns the final business write.
|
|
6789
|
+
* Runner creates the proposal and calls your handler only after approval,
|
|
6790
|
+
* but your handler must still enforce tenant/scope, expected-version or
|
|
6791
|
+
* conflict guard, idempotency key, allowed business action,
|
|
6792
|
+
* transaction/rollback, and safe error receipt.
|
|
6793
|
+
*
|
|
6794
|
+
* If you skip those checks, you can reintroduce cross-tenant writes,
|
|
6795
|
+
* lost updates, or duplicate writes. Keep handler credentials out of MCP.
|
|
6796
|
+
*
|
|
6797
|
+
* Put your app-owned command transaction here.
|
|
6798
|
+
*
|
|
6799
|
+
* Examples:
|
|
6800
|
+
* - call an internal service;
|
|
6801
|
+
* - enqueue a review job;
|
|
6802
|
+
* - run an app script that uses your normal ORM.
|
|
6803
|
+
*
|
|
6804
|
+
* Re-check tenant/principal authorization, idempotency, row/version guards,
|
|
6805
|
+
* and business policy before mutating application state.
|
|
6806
|
+
*/
|
|
6807
|
+
|
|
6808
|
+
process.stdout.write(JSON.stringify({
|
|
6809
|
+
status: "applied",
|
|
6810
|
+
rows_affected: 1,
|
|
6811
|
+
previous_version: String(changeSet.guards?.expected_version?.value || ""),
|
|
6812
|
+
new_version: new Date().toISOString(),
|
|
6813
|
+
source_database_mutated: true,
|
|
6814
|
+
}));
|
|
6815
|
+
`
|
|
6816
|
+
}
|
|
6817
|
+
};
|
|
6818
|
+
var referenceDemoDir = "examples/reference-support-billing-app";
|
|
6819
|
+
var referenceDemoConfigPath = `${referenceDemoDir}/synapsor.runner.json`;
|
|
6820
|
+
var referenceDemoContainer = "synapsor_runner_reference_support_billing";
|
|
6821
|
+
var referenceDemoDatabase = "synapsor_reference_support_billing";
|
|
6822
|
+
var referenceDemoEnv = {
|
|
6823
|
+
REFERENCE_POSTGRES_READ_URL: "postgresql://synapsor_reader:synapsor_reader_password@localhost:55435/synapsor_reference_support_billing",
|
|
6824
|
+
REFERENCE_POSTGRES_WRITE_URL: "postgresql://synapsor_writer:synapsor_writer_password@localhost:55435/synapsor_reference_support_billing",
|
|
6825
|
+
SYNAPSOR_TENANT_ID: "acme",
|
|
6826
|
+
SYNAPSOR_PRINCIPAL: "local_reviewer",
|
|
6827
|
+
SYNAPSOR_ENGINE: "postgres",
|
|
6828
|
+
SYNAPSOR_DATABASE_URL: "postgresql://synapsor_writer:synapsor_writer_password@localhost:55435/synapsor_reference_support_billing",
|
|
6829
|
+
SYNAPSOR_RUNNER_ID: "synapsor_demo_runner",
|
|
6830
|
+
SYNAPSOR_SOURCE_ID: "app_postgres",
|
|
6831
|
+
SYNAPSOR_CONTROL_PLANE_URL: "http://127.0.0.1:0",
|
|
6832
|
+
SYNAPSOR_RUNNER_TOKEN: "syn_wbr_demo_local"
|
|
6833
|
+
};
|
|
6834
|
+
async function main(argv) {
|
|
6835
|
+
const [command, ...rest] = argv;
|
|
6836
|
+
if (!command || command === "--help" || command === "-h") {
|
|
6837
|
+
usage([]);
|
|
6838
|
+
return 0;
|
|
6839
|
+
}
|
|
6840
|
+
if (!isKnownTopLevelCommand(command)) {
|
|
6841
|
+
process2.stderr.write(`Unknown command: ${cliCommandName()} ${command}
|
|
6842
|
+
|
|
6843
|
+
Try:
|
|
6844
|
+
${cliCommandName()} --help
|
|
6845
|
+
`);
|
|
6846
|
+
return 2;
|
|
6847
|
+
}
|
|
6848
|
+
if (isHelpRequest(rest)) {
|
|
6849
|
+
usage([command, ...rest.filter((arg) => arg !== "--help" && arg !== "-h")]);
|
|
6850
|
+
return 0;
|
|
6851
|
+
}
|
|
6852
|
+
if (command === "help") {
|
|
6853
|
+
usage(rest);
|
|
6854
|
+
return 0;
|
|
6076
6855
|
}
|
|
6077
6856
|
if (command === "init") return init(rest);
|
|
6078
6857
|
if (command === "inspect") return inspect(rest);
|
|
@@ -6082,11 +6861,15 @@ ${cliCommandName()} --help
|
|
|
6082
6861
|
if (command === "apply") return apply(rest);
|
|
6083
6862
|
if (command === "propose") return propose(rest);
|
|
6084
6863
|
if (command === "audit") return audit(rest);
|
|
6085
|
-
if (command === "start") return start();
|
|
6864
|
+
if (command === "start") return start(rest);
|
|
6865
|
+
if (command === "up") return up(rest);
|
|
6086
6866
|
if (command === "runner") return runnerCommand(rest);
|
|
6087
6867
|
if (command === "cloud") return cloud(rest);
|
|
6088
6868
|
if (command === "mcp") return mcp(rest);
|
|
6869
|
+
if (command === "smoke") return smoke(rest);
|
|
6089
6870
|
if (command === "tools") return tools(rest);
|
|
6871
|
+
if (command === "writeback") return writeback(rest);
|
|
6872
|
+
if (command === "handler") return handler(rest);
|
|
6090
6873
|
if (command === "onboard") return onboard(rest);
|
|
6091
6874
|
if (command === "demo") return demo(rest);
|
|
6092
6875
|
if (command === "recipes") return recipes(rest);
|
|
@@ -6097,6 +6880,7 @@ ${cliCommandName()} --help
|
|
|
6097
6880
|
if (command === "query-audit") return queryAudit(rest);
|
|
6098
6881
|
if (command === "receipts") return receipts(rest);
|
|
6099
6882
|
if (command === "activity") return activity(rest);
|
|
6883
|
+
if (command === "events") return events(rest);
|
|
6100
6884
|
if (command === "store") return storeCommand(rest);
|
|
6101
6885
|
if (command === "shadow") return shadow(rest);
|
|
6102
6886
|
if (command === "ui") return ui(rest);
|
|
@@ -6108,11 +6892,16 @@ ${cliCommandName()} --help
|
|
|
6108
6892
|
return 2;
|
|
6109
6893
|
}
|
|
6110
6894
|
async function init(args) {
|
|
6895
|
+
const answersPath = optionalArg(args, "--answers");
|
|
6896
|
+
if (answersPath) {
|
|
6897
|
+
return initFromAnswers(args, answersPath);
|
|
6898
|
+
}
|
|
6111
6899
|
const specPath = optionalArg(args, "--spec");
|
|
6112
6900
|
if (specPath) {
|
|
6113
6901
|
return initFromSpec(args, specPath);
|
|
6114
6902
|
}
|
|
6115
|
-
|
|
6903
|
+
const scripted = isScriptedOnboardingArgs(args);
|
|
6904
|
+
if (args.includes("--wizard") || process2.stdin.isTTY && process2.stdout.isTTY && !args.includes("--starter") && !scripted) {
|
|
6116
6905
|
return runInitWizard(args);
|
|
6117
6906
|
}
|
|
6118
6907
|
const inspectionJson = optionalArg(args, "--inspection-json");
|
|
@@ -6278,12 +7067,94 @@ async function runInitWizard(args, options = {}) {
|
|
|
6278
7067
|
if (transitionFromColumns.length > 0) ensureColumnsExist(transitionFromColumns, columns, "transition from");
|
|
6279
7068
|
}
|
|
6280
7069
|
}
|
|
6281
|
-
const
|
|
6282
|
-
const
|
|
7070
|
+
const inferredObjectName = recipeSpec?.object_name ?? safeObjectName(table.name);
|
|
7071
|
+
const namespace = await askDefault(ask, "Capability namespace", optionalArg(args, "--namespace") ?? recipeSpec?.namespace ?? inferCapabilityNamespace(table.name));
|
|
7072
|
+
const objectName = await askDefault(ask, "Business object name", optionalArg(args, "--object-name") ?? inferredObjectName);
|
|
6283
7073
|
const lookupArg = await askDefault(ask, "Model-visible object id argument", optionalArg(args, "--lookup-arg") ?? recipeSpec?.lookup_arg ?? `${objectName}_id`);
|
|
6284
|
-
const
|
|
7074
|
+
const defaultInspectToolName = recipeSpec?.inspect_tool_name ?? `${namespace}.inspect_${objectName}`;
|
|
7075
|
+
const inspectToolName = await askDefault(
|
|
7076
|
+
ask,
|
|
7077
|
+
"Read capability name",
|
|
7078
|
+
optionalArg(args, "--read-tool") ?? optionalArg(args, "--inspect-tool-name") ?? defaultInspectToolName
|
|
7079
|
+
);
|
|
7080
|
+
const defaultProposalToolName = recipeSpec?.proposal_tool_name ?? `${namespace}.propose_${objectName}_update`;
|
|
7081
|
+
const proposalToolName = mode === "read_only" ? void 0 : await askDefault(
|
|
7082
|
+
ask,
|
|
7083
|
+
"Proposal capability name",
|
|
7084
|
+
optionalArg(args, "--proposal-tool") ?? optionalArg(args, "--proposal-tool-name") ?? defaultProposalToolName
|
|
7085
|
+
);
|
|
7086
|
+
const smokeObjectId = await askDefault(ask, "Optional real object id for a first smoke call", optionalArg(args, "--smoke-id") ?? "");
|
|
7087
|
+
const objectLabel = objectName.replace(/_/g, " ");
|
|
7088
|
+
const inspectDescription = await askDefault(
|
|
7089
|
+
ask,
|
|
7090
|
+
"Read capability description",
|
|
7091
|
+
optionalArg(args, "--inspect-description") ?? `Inspect one ${objectLabel} in trusted tenant scope before answering or proposing a change.`
|
|
7092
|
+
);
|
|
7093
|
+
const inspectReturnsHint = await askDefault(
|
|
7094
|
+
ask,
|
|
7095
|
+
"Read capability returns hint",
|
|
7096
|
+
optionalArg(args, "--inspect-returns-hint") ?? `Returns reviewed ${objectLabel} fields, evidence handle, query audit, and source_database_changed:false.`
|
|
7097
|
+
);
|
|
7098
|
+
const proposalDescription = mode === "read_only" ? void 0 : await askDefault(
|
|
7099
|
+
ask,
|
|
7100
|
+
"Proposal capability description",
|
|
7101
|
+
optionalArg(args, "--proposal-description") ?? `Create a review-required proposal to update one ${objectLabel}. The source database remains unchanged until approval and writeback.`
|
|
7102
|
+
);
|
|
7103
|
+
const proposalReturnsHint = mode === "read_only" ? void 0 : await askDefault(
|
|
7104
|
+
ask,
|
|
7105
|
+
"Proposal capability returns hint",
|
|
7106
|
+
optionalArg(args, "--proposal-returns-hint") ?? "Returns a proposal id, exact before/after diff, evidence handle, approval status, and source_database_changed:false."
|
|
7107
|
+
);
|
|
7108
|
+
const resultFormatAnswer = await askChoice(ask, "MCP result envelope", optionalArg(args, "--result-format") ? normalizeResultFormatAnswer(optionalArg(args, "--result-format")) : "v2", ["v2", "v1", "default"]);
|
|
7109
|
+
const resultFormat = resultFormatAnswer === "v1" ? 1 : resultFormatAnswer === "v2" ? 2 : void 0;
|
|
7110
|
+
let writeUrlEnv = optionalArg(args, "--write-url-env");
|
|
7111
|
+
let writeback2;
|
|
7112
|
+
let generatedHandlerTemplate;
|
|
7113
|
+
if (mode === "review") {
|
|
7114
|
+
const writebackPath = await askChoice(
|
|
7115
|
+
ask,
|
|
7116
|
+
"Writeback path",
|
|
7117
|
+
optionalArg(args, "--writeback") ?? "sql_update",
|
|
7118
|
+
["sql_update", "http_handler", "command_handler"]
|
|
7119
|
+
);
|
|
7120
|
+
if (writebackPath === "sql_update") {
|
|
7121
|
+
writeUrlEnv = await askEnvName(ask, "Write URL env var for trusted direct SQL apply", writeUrlEnv ?? "SYNAPSOR_DATABASE_WRITE_URL");
|
|
7122
|
+
writeback2 = { executor: "sql_update" };
|
|
7123
|
+
} else if (writebackPath === "http_handler") {
|
|
7124
|
+
const urlEnv = await askEnvName(ask, "App-owned HTTP handler URL env var", optionalArg(args, "--handler-url-env") ?? "SYNAPSOR_APP_WRITEBACK_URL");
|
|
7125
|
+
const tokenEnv = await askOptionalEnvName(ask, "Optional HTTP handler bearer-token env var", optionalArg(args, "--handler-token-env") ?? "");
|
|
7126
|
+
const signingSecretEnv = await askOptionalEnvName(ask, "Optional HTTP handler HMAC signing-secret env var", optionalArg(args, "--handler-signing-secret-env") ?? "");
|
|
7127
|
+
writeback2 = {
|
|
7128
|
+
executor: "http_handler",
|
|
7129
|
+
executor_name: optionalArg(args, "--executor-name"),
|
|
7130
|
+
handler_url_env: urlEnv,
|
|
7131
|
+
...tokenEnv ? { handler_token_env: tokenEnv } : {},
|
|
7132
|
+
...signingSecretEnv ? { handler_signing_secret_env: signingSecretEnv } : {},
|
|
7133
|
+
timeout_ms: positiveIntegerOption(args, "--handler-timeout-ms")
|
|
7134
|
+
};
|
|
7135
|
+
const writeTemplate = await askChoice(ask, "Write starter app-owned handler template", args.includes("--skip-handler-template") ? "no" : "yes", ["yes", "no"]);
|
|
7136
|
+
if (writeTemplate === "yes") {
|
|
7137
|
+
const template = await askChoice(ask, "Handler template", optionalArg(args, "--handler-template") ?? "node-fastify", ["node-fastify", "python-fastapi"]);
|
|
7138
|
+
const output = await askDefault(ask, "Handler template output", optionalArg(args, "--handler-output") ?? optionalArg(args, "--handler-template-output") ?? handlerTemplateDefinitions[template].fileName);
|
|
7139
|
+
generatedHandlerTemplate = { name: template, output };
|
|
7140
|
+
}
|
|
7141
|
+
} else {
|
|
7142
|
+
const commandEnv = await askEnvName(ask, "App-owned command handler env var", optionalArg(args, "--handler-command-env") ?? "SYNAPSOR_APP_WRITEBACK_COMMAND");
|
|
7143
|
+
writeback2 = {
|
|
7144
|
+
executor: "command_handler",
|
|
7145
|
+
executor_name: optionalArg(args, "--executor-name"),
|
|
7146
|
+
handler_command_env: commandEnv,
|
|
7147
|
+
timeout_ms: positiveIntegerOption(args, "--handler-timeout-ms")
|
|
7148
|
+
};
|
|
7149
|
+
const writeTemplate = await askChoice(ask, "Write starter app-owned handler template", args.includes("--skip-handler-template") ? "no" : "yes", ["yes", "no"]);
|
|
7150
|
+
if (writeTemplate === "yes") {
|
|
7151
|
+
const output = await askDefault(ask, "Handler template output", optionalArg(args, "--handler-output") ?? optionalArg(args, "--handler-template-output") ?? handlerTemplateDefinitions.command.fileName);
|
|
7152
|
+
generatedHandlerTemplate = { name: "command", output };
|
|
7153
|
+
}
|
|
7154
|
+
}
|
|
7155
|
+
}
|
|
6285
7156
|
const approvalRole = mode === "read_only" ? "local_reviewer" : await askDefault(ask, "Required approval role", optionalArg(args, "--approval-role") ?? recipeSpec?.approval?.required_role ?? "local_reviewer");
|
|
6286
|
-
|
|
7157
|
+
let spec = {
|
|
6287
7158
|
version: 1,
|
|
6288
7159
|
engine: inspection.engine,
|
|
6289
7160
|
mode,
|
|
@@ -6298,9 +7169,14 @@ async function runInitWizard(args, options = {}) {
|
|
|
6298
7169
|
conflict_column: conflictAnswer || void 0,
|
|
6299
7170
|
namespace,
|
|
6300
7171
|
object_name: objectName,
|
|
6301
|
-
inspect_tool_name:
|
|
6302
|
-
proposal_tool_name:
|
|
7172
|
+
inspect_tool_name: inspectToolName,
|
|
7173
|
+
proposal_tool_name: proposalToolName,
|
|
7174
|
+
inspect_description: inspectDescription,
|
|
7175
|
+
inspect_returns_hint: inspectReturnsHint,
|
|
7176
|
+
proposal_description: proposalDescription,
|
|
7177
|
+
proposal_returns_hint: proposalReturnsHint,
|
|
6303
7178
|
lookup_arg: lookupArg,
|
|
7179
|
+
result_format: resultFormat,
|
|
6304
7180
|
visible_columns: visibleColumns,
|
|
6305
7181
|
allowed_columns: allowedColumns,
|
|
6306
7182
|
patch,
|
|
@@ -6313,27 +7189,115 @@ async function runInitWizard(args, options = {}) {
|
|
|
6313
7189
|
},
|
|
6314
7190
|
approval: {
|
|
6315
7191
|
required_role: approvalRole
|
|
6316
|
-
}
|
|
7192
|
+
},
|
|
7193
|
+
writeback: writeback2
|
|
6317
7194
|
};
|
|
6318
|
-
|
|
6319
|
-
const tools2 = generated.config.capabilities.map((capability) => `${capability.name} (${capability.kind})`);
|
|
7195
|
+
let generated = generateRunnerConfigFromSpec(spec);
|
|
6320
7196
|
stdout.write("\nPreview:\n");
|
|
6321
|
-
stdout
|
|
6322
|
-
|
|
6323
|
-
|
|
7197
|
+
printWizardContractPreview(stdout, { spec, generated, engine: inspection.engine, table });
|
|
7198
|
+
if (generatedHandlerTemplate) {
|
|
7199
|
+
stdout.write(` handler template: ${generatedHandlerTemplate.output}
|
|
6324
7200
|
`);
|
|
6325
|
-
|
|
7201
|
+
stdout.write(`${handlerSecurityWarning}
|
|
6326
7202
|
`);
|
|
6327
|
-
|
|
6328
|
-
|
|
6329
|
-
|
|
7203
|
+
}
|
|
7204
|
+
const editPreview = await askDefault(ask, "Edit visible fields or capability names before writing? Type yes to edit", "no");
|
|
7205
|
+
if (editPreview.toLowerCase() === "yes") {
|
|
7206
|
+
const updatedVisible = parseColumnList(await askDefault(
|
|
7207
|
+
ask,
|
|
7208
|
+
"Final visible columns",
|
|
7209
|
+
spec.visible_columns?.join(",") ?? visibleColumns.join(",")
|
|
7210
|
+
));
|
|
7211
|
+
ensureColumnsExist(updatedVisible, columns, "visible");
|
|
7212
|
+
const currentReadTool = spec.inspect_tool_name ?? generated.config.capabilities.find((capability) => capability.kind === "read")?.name ?? inspectToolName;
|
|
7213
|
+
const updatedReadTool = await askDefault(ask, "Final read capability name", currentReadTool);
|
|
7214
|
+
const currentProposalTool = spec.proposal_tool_name ?? generated.config.capabilities.find((capability) => capability.kind === "proposal")?.name ?? proposalToolName ?? "";
|
|
7215
|
+
const updatedProposalTool = spec.mode === "read_only" ? void 0 : await askDefault(ask, "Final proposal capability name", currentProposalTool);
|
|
7216
|
+
spec = {
|
|
7217
|
+
...spec,
|
|
7218
|
+
visible_columns: updatedVisible,
|
|
7219
|
+
inspect_tool_name: updatedReadTool,
|
|
7220
|
+
proposal_tool_name: updatedProposalTool
|
|
7221
|
+
};
|
|
7222
|
+
generated = generateRunnerConfigFromSpec(spec);
|
|
7223
|
+
stdout.write("\nUpdated preview:\n");
|
|
7224
|
+
printWizardContractPreview(stdout, { spec, generated, engine: inspection.engine, table });
|
|
7225
|
+
}
|
|
7226
|
+
const generatedCapabilities = generated.config.capabilities;
|
|
7227
|
+
const smokeToolName = generatedCapabilities[0]?.name ?? "<inspect_tool>";
|
|
6330
7228
|
const confirmed = await askDefault(ask, "Write generated config and MCP snippets? Type yes to continue", "no");
|
|
6331
7229
|
if (confirmed.toLowerCase() !== "yes") throw new Error("guided init canceled before writing files");
|
|
6332
|
-
|
|
6333
|
-
|
|
7230
|
+
const outputPath = outputArg(args) ?? "synapsor.runner.json";
|
|
7231
|
+
await writeGeneratedOnboardingFiles(outputPath, generated, args.includes("--force"), { printNext: false });
|
|
7232
|
+
if (generatedHandlerTemplate) {
|
|
7233
|
+
await writeHandlerTemplateFile(generatedHandlerTemplate.name, generatedHandlerTemplate.output, args.includes("--force"));
|
|
7234
|
+
stdout.write(`created ${generatedHandlerTemplate.output}
|
|
7235
|
+
`);
|
|
7236
|
+
}
|
|
7237
|
+
if (smokeObjectId) {
|
|
7238
|
+
await writeGeneratedSmokeInputFile(lookupArg, smokeObjectId, args.includes("--force"));
|
|
7239
|
+
stdout.write(`created ${generatedSmokeInputPath}
|
|
7240
|
+
`);
|
|
7241
|
+
const smoke2 = await maybeRunGeneratedSmokeCall({
|
|
7242
|
+
config: generated.config,
|
|
7243
|
+
env: options.env ?? process2.env,
|
|
7244
|
+
input: { [lookupArg]: smokeObjectId },
|
|
7245
|
+
readUrlEnv: configDatabaseUrlEnv,
|
|
7246
|
+
tenantEnv,
|
|
7247
|
+
principalEnv,
|
|
7248
|
+
readRow: options.readRow,
|
|
7249
|
+
storePath: defaultStorePath,
|
|
7250
|
+
toolName: smokeToolName
|
|
7251
|
+
});
|
|
7252
|
+
stdout.write(smoke2);
|
|
7253
|
+
}
|
|
7254
|
+
stdout.write("Next:\n");
|
|
7255
|
+
stdout.write(` 1. Set trusted env vars from .env.example, then run: ${cliCommandName()} doctor --config ${outputPath}
|
|
7256
|
+
`);
|
|
7257
|
+
if (smokeObjectId) {
|
|
7258
|
+
stdout.write(` 2. Smoke-call the read capability: ${cliCommandName()} smoke call ${smokeToolName} --input ${generatedSmokeInputPath} --config ${outputPath} --store ${defaultStorePath}
|
|
7259
|
+
`);
|
|
7260
|
+
} else {
|
|
7261
|
+
stdout.write(` 2. Smoke-call a real row: ${cliCommandName()} smoke call ${smokeToolName} --json '{"${lookupArg}":"<real_id>"}' --config ${outputPath} --store ${defaultStorePath}
|
|
7262
|
+
`);
|
|
7263
|
+
}
|
|
7264
|
+
stdout.write(` 3. Serve MCP tools: ${cliCommandName()} mcp serve --config ${outputPath} --store ${defaultStorePath}
|
|
7265
|
+
`);
|
|
7266
|
+
stdout.write(` OpenAI Agents SDK: use ${cliCommandName()} mcp serve-streamable-http --config ${outputPath} --store ${defaultStorePath} --alias-mode openai
|
|
6334
7267
|
`);
|
|
6335
7268
|
return 0;
|
|
6336
7269
|
}
|
|
7270
|
+
function printWizardContractPreview(stdout, input) {
|
|
7271
|
+
const capabilities = input.generated.config.capabilities;
|
|
7272
|
+
const tools2 = capabilities.map((capability) => `${capability.name} (${capability.kind})`);
|
|
7273
|
+
const readCapability = capabilities.find((capability) => capability.kind === "read")?.name ?? input.spec.inspect_tool_name ?? "<read_tool>";
|
|
7274
|
+
const proposalCapability = capabilities.find((capability) => capability.kind === "proposal")?.name ?? input.spec.proposal_tool_name;
|
|
7275
|
+
const visibleColumns = input.spec.visible_columns ?? [];
|
|
7276
|
+
const tenantEnv = input.spec.trusted_context?.tenant_id_env ?? "SYNAPSOR_TENANT_ID";
|
|
7277
|
+
const principalEnv = input.spec.trusted_context?.principal_env ?? "SYNAPSOR_PRINCIPAL";
|
|
7278
|
+
const visiblePreview = visibleColumns.length <= 12 ? visibleColumns.join(", ") : `${visibleColumns.slice(0, 12).join(", ")} (+${visibleColumns.length - 12} more)`;
|
|
7279
|
+
stdout.write(` trusted context: tenant from ${tenantEnv}${input.spec.single_tenant_dev ? " (single-tenant dev source)" : input.spec.tenant_key ? ` via ${input.spec.tenant_key}` : ""}; principal from ${principalEnv}
|
|
7280
|
+
`);
|
|
7281
|
+
stdout.write(` source: ${input.engine} ${input.table.schema}.${input.table.name}
|
|
7282
|
+
`);
|
|
7283
|
+
stdout.write(` primary key: ${input.spec.primary_key}${input.spec.conflict_column ? `; conflict guard: ${input.spec.conflict_column}` : ""}
|
|
7284
|
+
`);
|
|
7285
|
+
stdout.write(` visible fields: ${visiblePreview || "none"}
|
|
7286
|
+
`);
|
|
7287
|
+
stdout.write(` mode: ${input.spec.mode}
|
|
7288
|
+
`);
|
|
7289
|
+
stdout.write(` result envelope: ${input.spec.result_format ? `v${input.spec.result_format}` : "default"}
|
|
7290
|
+
`);
|
|
7291
|
+
stdout.write(` writeback path: ${input.spec.writeback?.executor ?? (input.spec.mode === "review" ? "sql_update" : "none")}
|
|
7292
|
+
`);
|
|
7293
|
+
stdout.write(` read capability: ${readCapability}
|
|
7294
|
+
`);
|
|
7295
|
+
if (proposalCapability) stdout.write(` proposal capability: ${proposalCapability}
|
|
7296
|
+
`);
|
|
7297
|
+
stdout.write(` exposed tools: ${tools2.join(", ")}
|
|
7298
|
+
`);
|
|
7299
|
+
stdout.write(" not exposed: execute_sql, approval tools, commit tools, database URLs, write credentials, model-controlled tenant authority\n");
|
|
7300
|
+
}
|
|
6337
7301
|
async function initFromSpec(args, specPath) {
|
|
6338
7302
|
if (!args.includes("--non-interactive")) {
|
|
6339
7303
|
throw new Error("init --spec requires --non-interactive so reviewed selections are explicit.");
|
|
@@ -6342,9 +7306,123 @@ async function initFromSpec(args, specPath) {
|
|
|
6342
7306
|
const force = args.includes("--force");
|
|
6343
7307
|
const spec = JSON.parse(await fs3.readFile(specPath, "utf8"));
|
|
6344
7308
|
const generated = generateRunnerConfigFromSpec(spec);
|
|
7309
|
+
if (args.includes("--dry-run")) {
|
|
7310
|
+
process2.stdout.write(`${JSON.stringify(generated.config, null, 2)}
|
|
7311
|
+
`);
|
|
7312
|
+
return 0;
|
|
7313
|
+
}
|
|
6345
7314
|
await writeGeneratedOnboardingFiles(output, generated, force);
|
|
6346
7315
|
return 0;
|
|
6347
7316
|
}
|
|
7317
|
+
async function initFromAnswers(args, answersPath) {
|
|
7318
|
+
const output = outputArg(args) ?? "synapsor.runner.json";
|
|
7319
|
+
const force = args.includes("--force");
|
|
7320
|
+
const raw = JSON.parse(await fs3.readFile(answersPath, "utf8"));
|
|
7321
|
+
const spec = answersToSelectionSpec(raw);
|
|
7322
|
+
const generated = generateRunnerConfigFromSpec(spec);
|
|
7323
|
+
if (args.includes("--dry-run")) {
|
|
7324
|
+
process2.stdout.write(`${JSON.stringify(generated.config, null, 2)}
|
|
7325
|
+
`);
|
|
7326
|
+
return 0;
|
|
7327
|
+
}
|
|
7328
|
+
await writeGeneratedOnboardingFiles(output, generated, force);
|
|
7329
|
+
await maybeWriteHandlerTemplateForArgs(args, spec.writeback);
|
|
7330
|
+
return 0;
|
|
7331
|
+
}
|
|
7332
|
+
function isScriptedOnboardingArgs(args) {
|
|
7333
|
+
return args.includes("--yes") || args.includes("--non-interactive") || args.includes("--dry-run") || Boolean(optionalArg(args, "--answers")) || Boolean(optionalArg(args, "--inspection-json")) || Boolean(optionalArg(args, "--table"));
|
|
7334
|
+
}
|
|
7335
|
+
function answersToSelectionSpec(raw) {
|
|
7336
|
+
if (!isRecord6(raw)) throw new Error("--answers file must contain a JSON object");
|
|
7337
|
+
const mode = stringValue(raw.mode) ?? "review";
|
|
7338
|
+
if (!["read_only", "shadow", "review"].includes(mode)) throw new Error("answers.mode must be read_only, shadow, or review");
|
|
7339
|
+
const engine = stringValue(raw.engine) ?? "postgres";
|
|
7340
|
+
if (engine !== "postgres" && engine !== "mysql") throw new Error("answers.engine must be postgres or mysql");
|
|
7341
|
+
const table = requiredAnswerString(raw.table, "table");
|
|
7342
|
+
const objectName = stringValue(raw.object_name) ?? safeObjectName(table);
|
|
7343
|
+
const namespace = stringValue(raw.namespace) ?? inferCapabilityNamespace(table);
|
|
7344
|
+
const writebackRaw = stringValue(raw.writeback) ?? "sql_update";
|
|
7345
|
+
if (!["sql_update", "http_handler", "command_handler"].includes(writebackRaw)) throw new Error("answers.writeback must be sql_update, http_handler, or command_handler");
|
|
7346
|
+
const writeback2 = writebackRaw === "sql_update" ? { executor: "sql_update" } : writebackRaw === "http_handler" ? {
|
|
7347
|
+
executor: "http_handler",
|
|
7348
|
+
handler_url_env: stringValue(raw.handler_url_env) ?? "SYNAPSOR_APP_WRITEBACK_URL",
|
|
7349
|
+
...stringValue(raw.handler_token_env) ? { handler_token_env: stringValue(raw.handler_token_env) } : {},
|
|
7350
|
+
...stringValue(raw.handler_signing_secret_env) ? { handler_signing_secret_env: stringValue(raw.handler_signing_secret_env) } : {}
|
|
7351
|
+
} : {
|
|
7352
|
+
executor: "command_handler",
|
|
7353
|
+
handler_command_env: stringValue(raw.handler_command_env) ?? "SYNAPSOR_APP_WRITEBACK_COMMAND"
|
|
7354
|
+
};
|
|
7355
|
+
const patch = parsePatchBindings(arrayOrStringList(raw.patch), "--answers.patch");
|
|
7356
|
+
const allowedColumns = arrayOrStringList(raw.allowed_columns);
|
|
7357
|
+
return {
|
|
7358
|
+
version: 1,
|
|
7359
|
+
engine,
|
|
7360
|
+
mode,
|
|
7361
|
+
source_name: stringValue(raw.source_name),
|
|
7362
|
+
read_url_env: stringValue(raw.read_url_env) ?? stringValue(raw.database_url_env) ?? "DATABASE_URL",
|
|
7363
|
+
write_url_env: writeback2.executor === "sql_update" ? stringValue(raw.write_url_env) ?? "SYNAPSOR_DATABASE_WRITE_URL" : stringValue(raw.write_url_env),
|
|
7364
|
+
schema: requiredAnswerString(raw.schema, "schema"),
|
|
7365
|
+
table,
|
|
7366
|
+
primary_key: requiredAnswerString(raw.primary_key, "primary_key"),
|
|
7367
|
+
tenant_key: stringValue(raw.tenant_column) ?? stringValue(raw.tenant_key),
|
|
7368
|
+
single_tenant_dev: raw.single_tenant_dev === true,
|
|
7369
|
+
conflict_column: stringValue(raw.conflict_column),
|
|
7370
|
+
namespace,
|
|
7371
|
+
object_name: objectName,
|
|
7372
|
+
inspect_tool_name: stringValue(raw.read_tool) ?? stringValue(raw.inspect_tool_name),
|
|
7373
|
+
proposal_tool_name: stringValue(raw.proposal_tool) ?? stringValue(raw.proposal_tool_name),
|
|
7374
|
+
lookup_arg: stringValue(raw.id_arg) ?? stringValue(raw.lookup_arg),
|
|
7375
|
+
inspect_description: stringValue(raw.read_description) ?? stringValue(raw.inspect_description),
|
|
7376
|
+
inspect_returns_hint: stringValue(raw.read_returns_hint) ?? stringValue(raw.inspect_returns_hint),
|
|
7377
|
+
proposal_description: stringValue(raw.proposal_description),
|
|
7378
|
+
proposal_returns_hint: stringValue(raw.proposal_returns_hint),
|
|
7379
|
+
result_format: resultFormatFromAnswerValue(raw.result_format),
|
|
7380
|
+
visible_columns: arrayOrStringList(raw.visible_columns),
|
|
7381
|
+
allowed_columns: allowedColumns.length > 0 ? allowedColumns : void 0,
|
|
7382
|
+
patch,
|
|
7383
|
+
numeric_bounds: parseNumericBoundsInput(arrayOrStringList(raw.patch_bounds ?? raw.numeric_bounds).join(",")),
|
|
7384
|
+
transition_guards: parseTransitionGuardsInput(arrayOrStringList(raw.status_guards ?? raw.transition_guards).join(",")),
|
|
7385
|
+
trusted_context: {
|
|
7386
|
+
tenant_id_env: stringValue(raw.tenant_env) ?? "SYNAPSOR_TENANT_ID",
|
|
7387
|
+
principal_env: stringValue(raw.principal_env) ?? "SYNAPSOR_PRINCIPAL"
|
|
7388
|
+
},
|
|
7389
|
+
approval: {
|
|
7390
|
+
required_role: stringValue(raw.approval_role) ?? "local_reviewer"
|
|
7391
|
+
},
|
|
7392
|
+
writeback: writeback2
|
|
7393
|
+
};
|
|
7394
|
+
}
|
|
7395
|
+
async function maybeWriteHandlerTemplateForArgs(args, writeback2) {
|
|
7396
|
+
if (!writeback2 || writeback2.executor === "sql_update" || args.includes("--no-emit-handler") || args.includes("--skip-handler-template")) return;
|
|
7397
|
+
if (!args.includes("--emit-handler") && !optionalArg(args, "--handler-template") && !optionalArg(args, "--handler-output") && !optionalArg(args, "--handler-template-output")) return;
|
|
7398
|
+
const defaultTemplate = writeback2.executor === "command_handler" ? "command" : "node-fastify";
|
|
7399
|
+
const template = resolveHandlerTemplateName(optionalArg(args, "--handler-template") ?? defaultTemplate);
|
|
7400
|
+
const output = optionalArg(args, "--handler-output") ?? optionalArg(args, "--handler-template-output") ?? handlerTemplateDefinitions[template].fileName;
|
|
7401
|
+
await writeHandlerTemplateFile(template, output, args.includes("--force"));
|
|
7402
|
+
process2.stdout.write(`created ${output}
|
|
7403
|
+
`);
|
|
7404
|
+
process2.stdout.write(`${handlerSecurityWarning}
|
|
7405
|
+
`);
|
|
7406
|
+
}
|
|
7407
|
+
function stringValue(value) {
|
|
7408
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
7409
|
+
}
|
|
7410
|
+
function requiredAnswerString(value, field) {
|
|
7411
|
+
const result = stringValue(value);
|
|
7412
|
+
if (!result) throw new Error(`--answers missing ${field}`);
|
|
7413
|
+
return result;
|
|
7414
|
+
}
|
|
7415
|
+
function arrayOrStringList(value) {
|
|
7416
|
+
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
|
|
7417
|
+
if (typeof value === "string") return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
7418
|
+
return [];
|
|
7419
|
+
}
|
|
7420
|
+
function resultFormatFromAnswerValue(value) {
|
|
7421
|
+
if (value === void 0 || value === null || value === "" || value === "default") return void 0;
|
|
7422
|
+
if (value === 1 || value === "1" || value === "v1") return 1;
|
|
7423
|
+
if (value === 2 || value === "2" || value === "v2") return 2;
|
|
7424
|
+
throw new Error("result_format must be default, v1, v2, 1, or 2");
|
|
7425
|
+
}
|
|
6348
7426
|
async function initFromInspection(args, inspection, databaseUrlEnv) {
|
|
6349
7427
|
const tableName = optionalArg(args, "--table");
|
|
6350
7428
|
if (!tableName) {
|
|
@@ -6368,7 +7446,7 @@ async function initFromInspection(args, inspection, databaseUrlEnv) {
|
|
|
6368
7446
|
process2.stderr.write(`warning: no database primary-key constraint detected for ${table.schema}.${table.name}; using candidate column ${primaryKey}. Verify uniqueness before enabling writeback.
|
|
6369
7447
|
`);
|
|
6370
7448
|
}
|
|
6371
|
-
const tenantKey = optionalArg(args, "--tenant-key") ?? table.suggestions.tenant_columns[0];
|
|
7449
|
+
const tenantKey = optionalArg(args, "--tenant-column") ?? optionalArg(args, "--tenant-key") ?? table.suggestions.tenant_columns[0];
|
|
6372
7450
|
const singleTenantDev = args.includes("--single-tenant-dev");
|
|
6373
7451
|
if (!tenantKey && !singleTenantDev) {
|
|
6374
7452
|
throw new Error(`--tenant-key is required for ${table.schema}.${table.name}, or pass --single-tenant-dev for a reviewed single-tenant dev source.`);
|
|
@@ -6389,22 +7467,33 @@ async function initFromInspection(args, inspection, databaseUrlEnv) {
|
|
|
6389
7467
|
const numericBounds = parseNumericBoundsFlags(args);
|
|
6390
7468
|
const transitionGuards = parseTransitionGuardFlags(args);
|
|
6391
7469
|
const allowedColumns = listArg(args, "--allowed-columns") ?? Object.keys(patch);
|
|
7470
|
+
const writeback2 = writebackSpecFromArgs(args);
|
|
7471
|
+
const sqlWriteback = (writeback2?.executor ?? "sql_update") === "sql_update";
|
|
7472
|
+
const objectName = optionalArg(args, "--object-name") ?? safeObjectName(table.name);
|
|
7473
|
+
const namespace = optionalArg(args, "--namespace") ?? inferCapabilityNamespace(table.name);
|
|
6392
7474
|
const spec = {
|
|
6393
7475
|
version: 1,
|
|
6394
7476
|
engine: inspection.engine,
|
|
6395
7477
|
mode,
|
|
6396
7478
|
source_name: optionalArg(args, "--source-name"),
|
|
6397
7479
|
read_url_env: databaseUrlEnv,
|
|
6398
|
-
write_url_env: optionalArg(args, "--write-url-env") ?? "SYNAPSOR_DATABASE_WRITE_URL",
|
|
7480
|
+
write_url_env: sqlWriteback ? optionalArg(args, "--write-url-env") ?? "SYNAPSOR_DATABASE_WRITE_URL" : optionalArg(args, "--write-url-env"),
|
|
6399
7481
|
schema: table.schema,
|
|
6400
7482
|
table: table.name,
|
|
6401
7483
|
primary_key: primaryKey,
|
|
6402
7484
|
tenant_key: tenantKey,
|
|
6403
7485
|
single_tenant_dev: singleTenantDev,
|
|
6404
7486
|
conflict_column: conflictColumn,
|
|
6405
|
-
namespace
|
|
6406
|
-
object_name:
|
|
6407
|
-
|
|
7487
|
+
namespace,
|
|
7488
|
+
object_name: objectName,
|
|
7489
|
+
inspect_tool_name: optionalArg(args, "--read-tool") ?? optionalArg(args, "--inspect-tool-name"),
|
|
7490
|
+
proposal_tool_name: optionalArg(args, "--proposal-tool") ?? optionalArg(args, "--proposal-tool-name"),
|
|
7491
|
+
lookup_arg: optionalArg(args, "--id-arg") ?? optionalArg(args, "--lookup-arg"),
|
|
7492
|
+
inspect_description: optionalArg(args, "--read-description") ?? optionalArg(args, "--inspect-description"),
|
|
7493
|
+
inspect_returns_hint: optionalArg(args, "--read-returns-hint") ?? optionalArg(args, "--inspect-returns-hint"),
|
|
7494
|
+
proposal_description: optionalArg(args, "--proposal-description"),
|
|
7495
|
+
proposal_returns_hint: optionalArg(args, "--proposal-returns-hint"),
|
|
7496
|
+
result_format: resultFormatOption(args),
|
|
6408
7497
|
visible_columns: visibleColumns,
|
|
6409
7498
|
allowed_columns: allowedColumns,
|
|
6410
7499
|
patch,
|
|
@@ -6416,17 +7505,24 @@ async function initFromInspection(args, inspection, databaseUrlEnv) {
|
|
|
6416
7505
|
},
|
|
6417
7506
|
approval: {
|
|
6418
7507
|
required_role: optionalArg(args, "--approval-role") ?? "local_reviewer"
|
|
6419
|
-
}
|
|
7508
|
+
},
|
|
7509
|
+
writeback: writeback2
|
|
6420
7510
|
};
|
|
6421
7511
|
const generated = generateRunnerConfigFromSpec(spec);
|
|
7512
|
+
if (args.includes("--dry-run")) {
|
|
7513
|
+
process2.stdout.write(`${JSON.stringify(generated.config, null, 2)}
|
|
7514
|
+
`);
|
|
7515
|
+
return 0;
|
|
7516
|
+
}
|
|
6422
7517
|
await writeGeneratedOnboardingFiles(outputArg(args) ?? "synapsor.runner.json", generated, args.includes("--force"));
|
|
7518
|
+
await maybeWriteHandlerTemplateForArgs(args, writeback2);
|
|
6423
7519
|
process2.stdout.write(`selected ${table.schema}.${table.name} from ${inspection.engine} inspection
|
|
6424
7520
|
`);
|
|
6425
7521
|
process2.stdout.write(`exposed tools: ${generated.config.capabilities.map((capability) => capability.name).join(", ")}
|
|
6426
7522
|
`);
|
|
6427
7523
|
return 0;
|
|
6428
7524
|
}
|
|
6429
|
-
async function writeGeneratedOnboardingFiles(output, generated, force) {
|
|
7525
|
+
async function writeGeneratedOnboardingFiles(output, generated, force, options = {}) {
|
|
6430
7526
|
await writeFileGuarded(output, `${JSON.stringify(generated.config, null, 2)}
|
|
6431
7527
|
`, force);
|
|
6432
7528
|
await writeFileGuarded(".env.example", generated.envExample, force);
|
|
@@ -6440,8 +7536,46 @@ async function writeGeneratedOnboardingFiles(output, generated, force) {
|
|
|
6440
7536
|
`);
|
|
6441
7537
|
process2.stdout.write("created .env.example\n");
|
|
6442
7538
|
process2.stdout.write("created MCP client snippets under .synapsor/mcp\n");
|
|
6443
|
-
|
|
7539
|
+
if (options.printNext !== false) {
|
|
7540
|
+
process2.stdout.write(`Next: set the referenced environment variables, run \`${cliCommandName()} config validate\`, then run \`${cliCommandName()} mcp serve\`.
|
|
6444
7541
|
`);
|
|
7542
|
+
}
|
|
7543
|
+
}
|
|
7544
|
+
async function writeGeneratedSmokeInputFile(lookupArg, objectId, force) {
|
|
7545
|
+
await fs3.mkdir(path3.dirname(path3.resolve(generatedSmokeInputPath)), { recursive: true });
|
|
7546
|
+
await writeFileGuarded(generatedSmokeInputPath, `${JSON.stringify({ [lookupArg]: objectId }, null, 2)}
|
|
7547
|
+
`, force);
|
|
7548
|
+
}
|
|
7549
|
+
async function maybeRunGeneratedSmokeCall(input) {
|
|
7550
|
+
const required = uniqueStrings([input.readUrlEnv, input.tenantEnv, input.principalEnv]).filter((envName) => !input.env[envName]);
|
|
7551
|
+
if (required.length > 0) {
|
|
7552
|
+
return [
|
|
7553
|
+
"Smoke call not run yet.",
|
|
7554
|
+
`Missing trusted/runtime env vars: ${required.join(", ")}`,
|
|
7555
|
+
"Set them from .env.example, then run the printed smoke command.",
|
|
7556
|
+
""
|
|
7557
|
+
].join("\n");
|
|
7558
|
+
}
|
|
7559
|
+
const store = new ProposalStore(input.storePath);
|
|
7560
|
+
const runtime = createMcpRuntime(input.config, { store, env: input.env, readRow: input.readRow });
|
|
7561
|
+
try {
|
|
7562
|
+
const result = await runtime.callTool(input.toolName, input.input);
|
|
7563
|
+
return [
|
|
7564
|
+
"Smoke call ran successfully.",
|
|
7565
|
+
"",
|
|
7566
|
+
formatSmokeCallResult(input.toolName, input.input, result, input.storePath)
|
|
7567
|
+
].join("\n");
|
|
7568
|
+
} catch (error) {
|
|
7569
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
7570
|
+
return [
|
|
7571
|
+
"Smoke call attempted but did not pass.",
|
|
7572
|
+
`Reason: ${message}`,
|
|
7573
|
+
"The generated config was written. Fix the trusted env values or object id, then rerun the printed smoke command.",
|
|
7574
|
+
""
|
|
7575
|
+
].join("\n");
|
|
7576
|
+
} finally {
|
|
7577
|
+
runtime.close();
|
|
7578
|
+
}
|
|
6445
7579
|
}
|
|
6446
7580
|
async function askTtyQuestion(question, defaultValue) {
|
|
6447
7581
|
const rl = readline.createInterface({ input: process2.stdin, output: process2.stderr });
|
|
@@ -6466,12 +7600,49 @@ async function askEnvName(ask, question, defaultValue) {
|
|
|
6466
7600
|
if (!/^[A-Z_][A-Z0-9_]*$/.test(answer)) throw new Error(`${question} must be an environment-variable name`);
|
|
6467
7601
|
return answer;
|
|
6468
7602
|
}
|
|
7603
|
+
async function askOptionalEnvName(ask, question, defaultValue) {
|
|
7604
|
+
const answer = await askDefault(ask, question, defaultValue);
|
|
7605
|
+
if (!answer) return void 0;
|
|
7606
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(answer)) throw new Error(`${question} must be an environment-variable name`);
|
|
7607
|
+
return answer;
|
|
7608
|
+
}
|
|
6469
7609
|
async function askColumn(ask, question, defaultValue, columns) {
|
|
6470
7610
|
const answer = await askDefault(ask, question, defaultValue);
|
|
6471
7611
|
if (!answer) throw new Error(`${question} is required`);
|
|
6472
7612
|
if (!columns.includes(answer)) throw new Error(`${question} ${answer} does not exist in selected table/view`);
|
|
6473
7613
|
return answer;
|
|
6474
7614
|
}
|
|
7615
|
+
function positiveIntegerOption(args, name) {
|
|
7616
|
+
const raw = optionalArg(args, name);
|
|
7617
|
+
if (!raw) return void 0;
|
|
7618
|
+
const value = Number(raw);
|
|
7619
|
+
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
|
7620
|
+
return value;
|
|
7621
|
+
}
|
|
7622
|
+
function writebackSpecFromArgs(args) {
|
|
7623
|
+
const raw = optionalArg(args, "--writeback");
|
|
7624
|
+
if (!raw) return void 0;
|
|
7625
|
+
if (!["sql_update", "http_handler", "command_handler"].includes(raw)) {
|
|
7626
|
+
throw new Error("--writeback must be sql_update, http_handler, or command_handler");
|
|
7627
|
+
}
|
|
7628
|
+
if (raw === "sql_update") return { executor: "sql_update" };
|
|
7629
|
+
if (raw === "http_handler") {
|
|
7630
|
+
return {
|
|
7631
|
+
executor: "http_handler",
|
|
7632
|
+
executor_name: optionalArg(args, "--executor-name"),
|
|
7633
|
+
handler_url_env: optionalArg(args, "--handler-url-env") ?? "SYNAPSOR_APP_WRITEBACK_URL",
|
|
7634
|
+
...optionalArg(args, "--handler-token-env") ? { handler_token_env: optionalArg(args, "--handler-token-env") } : {},
|
|
7635
|
+
...optionalArg(args, "--handler-signing-secret-env") ? { handler_signing_secret_env: optionalArg(args, "--handler-signing-secret-env") } : {},
|
|
7636
|
+
timeout_ms: positiveIntegerOption(args, "--handler-timeout-ms")
|
|
7637
|
+
};
|
|
7638
|
+
}
|
|
7639
|
+
return {
|
|
7640
|
+
executor: "command_handler",
|
|
7641
|
+
executor_name: optionalArg(args, "--executor-name"),
|
|
7642
|
+
handler_command_env: optionalArg(args, "--handler-command-env") ?? "SYNAPSOR_APP_WRITEBACK_COMMAND",
|
|
7643
|
+
timeout_ms: positiveIntegerOption(args, "--handler-timeout-ms")
|
|
7644
|
+
};
|
|
7645
|
+
}
|
|
6475
7646
|
function parseColumnList(value) {
|
|
6476
7647
|
return uniqueStrings(value.split(",").map((item) => item.trim()).filter(Boolean));
|
|
6477
7648
|
}
|
|
@@ -6560,6 +7731,82 @@ function safeObjectName(tableName) {
|
|
|
6560
7731
|
const base = tableName.replace(/[^A-Za-z0-9_]/g, "_").replace(/s$/, "");
|
|
6561
7732
|
return /^[A-Za-z_]/.test(base) ? base : `record_${base}`;
|
|
6562
7733
|
}
|
|
7734
|
+
function inferCapabilityNamespace(tableName) {
|
|
7735
|
+
const objectName = safeObjectName(tableName);
|
|
7736
|
+
const [firstPart] = objectName.split("_").filter(Boolean);
|
|
7737
|
+
return firstPart ?? objectName;
|
|
7738
|
+
}
|
|
7739
|
+
function requiredWritebackEngine(args) {
|
|
7740
|
+
const value = optionalArg(args, "--engine") ?? firstPositional(args);
|
|
7741
|
+
if (value === "postgres" || value === "mysql") return value;
|
|
7742
|
+
throw new Error("writeback command requires --engine postgres or --engine mysql");
|
|
7743
|
+
}
|
|
7744
|
+
function formatPostgresReceiptMigration(schema) {
|
|
7745
|
+
if (!schema) {
|
|
7746
|
+
return [
|
|
7747
|
+
"-- Synapsor Runner direct SQL writeback receipt table.",
|
|
7748
|
+
"-- Run this as a database owner, or grant CREATE on the target schema to the trusted writer.",
|
|
7749
|
+
`${postgresReceiptMigration};`,
|
|
7750
|
+
""
|
|
7751
|
+
].join("\n");
|
|
7752
|
+
}
|
|
7753
|
+
const quotedSchema = quoteSqlIdentifier(schema, "postgres");
|
|
7754
|
+
const qualified = `${quotedSchema}.synapsor_writeback_receipts`;
|
|
7755
|
+
return [
|
|
7756
|
+
"-- Synapsor Runner direct SQL writeback receipt table.",
|
|
7757
|
+
"-- If you use a dedicated schema, ensure the writer connection search_path includes it.",
|
|
7758
|
+
`CREATE SCHEMA IF NOT EXISTS ${quotedSchema};`,
|
|
7759
|
+
`${postgresReceiptMigration.replace("synapsor_writeback_receipts", qualified)};`,
|
|
7760
|
+
"",
|
|
7761
|
+
"-- Example writer URL option for this schema:",
|
|
7762
|
+
`-- postgresql://writer:...@host/db?options=-csearch_path%3D${encodeURIComponent(`${schema},public`)}`,
|
|
7763
|
+
""
|
|
7764
|
+
].join("\n");
|
|
7765
|
+
}
|
|
7766
|
+
function formatMysqlReceiptMigration(database) {
|
|
7767
|
+
return [
|
|
7768
|
+
"-- Synapsor Runner direct SQL writeback receipt table.",
|
|
7769
|
+
"-- Run this in the database/schema used by the trusted writer connection.",
|
|
7770
|
+
...database ? [`USE ${quoteSqlIdentifier(database, "mysql")};`] : [],
|
|
7771
|
+
`${mysqlReceiptMigration};`,
|
|
7772
|
+
""
|
|
7773
|
+
].join("\n");
|
|
7774
|
+
}
|
|
7775
|
+
function formatPostgresReceiptGrants(schema, writerRole) {
|
|
7776
|
+
const quotedSchema = quoteSqlIdentifier(schema, "postgres");
|
|
7777
|
+
const quotedRole = writerRole === "<writer_role>" ? writerRole : quoteSqlIdentifier(writerRole, "postgres");
|
|
7778
|
+
const table = `${quotedSchema}.synapsor_writeback_receipts`;
|
|
7779
|
+
return [
|
|
7780
|
+
"-- Least-privilege grants for a pre-created Synapsor Runner receipt table.",
|
|
7781
|
+
`GRANT USAGE ON SCHEMA ${quotedSchema} TO ${quotedRole};`,
|
|
7782
|
+
`GRANT SELECT, INSERT, UPDATE ON TABLE ${table} TO ${quotedRole};`,
|
|
7783
|
+
"",
|
|
7784
|
+
"-- If you want Runner to create the receipt table itself, also grant CREATE on the schema:",
|
|
7785
|
+
`-- GRANT CREATE ON SCHEMA ${quotedSchema} TO ${quotedRole};`,
|
|
7786
|
+
"",
|
|
7787
|
+
"-- If the schema is not public, make sure the writer connection search_path includes it.",
|
|
7788
|
+
`-- ALTER ROLE ${quotedRole} SET search_path = ${schema}, public;`,
|
|
7789
|
+
""
|
|
7790
|
+
].join("\n");
|
|
7791
|
+
}
|
|
7792
|
+
function formatMysqlReceiptGrants(database, writerRole) {
|
|
7793
|
+
const quotedDatabase = database === "<database_name>" ? "`<database_name>`" : quoteSqlIdentifier(database, "mysql");
|
|
7794
|
+
const account = writerRole === "<writer_role>" ? "'<writer_user>'@'%'" : writerRole;
|
|
7795
|
+
return [
|
|
7796
|
+
"-- Least-privilege grants for a pre-created Synapsor Runner receipt table.",
|
|
7797
|
+
`GRANT SELECT, INSERT, UPDATE ON ${quotedDatabase}.synapsor_writeback_receipts TO ${account};`,
|
|
7798
|
+
"",
|
|
7799
|
+
"-- If you want Runner to create the receipt table itself, also grant CREATE on the database:",
|
|
7800
|
+
`-- GRANT CREATE ON ${quotedDatabase}.* TO ${account};`,
|
|
7801
|
+
""
|
|
7802
|
+
].join("\n");
|
|
7803
|
+
}
|
|
7804
|
+
function quoteSqlIdentifier(identifier, engine) {
|
|
7805
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
7806
|
+
throw new Error(`unsafe ${engine} identifier: ${identifier}`);
|
|
7807
|
+
}
|
|
7808
|
+
return engine === "postgres" ? `"${identifier}"` : `\`${identifier}\``;
|
|
7809
|
+
}
|
|
6563
7810
|
function findInspectionTable(inspection, tableName, schemaName) {
|
|
6564
7811
|
const candidates = inspection.tables.filter((table) => {
|
|
6565
7812
|
if (schemaName && table.schema !== schemaName) return false;
|
|
@@ -6593,6 +7840,7 @@ function repeatedArgs(args, flag) {
|
|
|
6593
7840
|
}
|
|
6594
7841
|
function parsePatchFlags(args) {
|
|
6595
7842
|
const patch = {};
|
|
7843
|
+
Object.assign(patch, parsePatchBindings(repeatedArgs(args, "--patch"), "--patch"));
|
|
6596
7844
|
for (const binding of repeatedArgs(args, "--patch-fixed")) {
|
|
6597
7845
|
const [column, ...rest] = binding.split("=");
|
|
6598
7846
|
const value = rest.join("=");
|
|
@@ -6607,8 +7855,27 @@ function parsePatchFlags(args) {
|
|
|
6607
7855
|
}
|
|
6608
7856
|
return patch;
|
|
6609
7857
|
}
|
|
7858
|
+
function parsePatchBindings(bindings, label) {
|
|
7859
|
+
const patch = {};
|
|
7860
|
+
for (const rawBinding of bindings.flatMap((binding) => binding.split(",")).map((item) => item.trim()).filter(Boolean)) {
|
|
7861
|
+
const [column, ...rest] = rawBinding.split("=");
|
|
7862
|
+
const expression = rest.join("=");
|
|
7863
|
+
if (!column || !expression) throw new Error(`${label} must use column=fixed:value or column=arg:name`);
|
|
7864
|
+
const [kind, ...valueParts] = expression.split(":");
|
|
7865
|
+
const value = valueParts.join(":");
|
|
7866
|
+
if (!valueParts.length || !value) throw new Error(`${label} must use column=fixed:value or column=arg:name`);
|
|
7867
|
+
if (kind === "fixed") {
|
|
7868
|
+
patch[column] = { fixed: parseFixedPatchValue(value) };
|
|
7869
|
+
} else if (kind === "arg") {
|
|
7870
|
+
patch[column] = { from_arg: value };
|
|
7871
|
+
} else {
|
|
7872
|
+
throw new Error(`${label} patch kind for ${column} must be fixed or arg`);
|
|
7873
|
+
}
|
|
7874
|
+
}
|
|
7875
|
+
return patch;
|
|
7876
|
+
}
|
|
6610
7877
|
function parseNumericBoundsFlags(args) {
|
|
6611
|
-
return parseNumericBoundsInput(repeatedArgs(args, "--numeric-bound").join(","));
|
|
7878
|
+
return parseNumericBoundsInput([...repeatedArgs(args, "--numeric-bound"), ...repeatedArgs(args, "--patch-bounds")].join(","));
|
|
6612
7879
|
}
|
|
6613
7880
|
function parseNumericBoundsInput(input) {
|
|
6614
7881
|
const bounds = {};
|
|
@@ -6640,7 +7907,7 @@ function formatNumericBounds(bounds) {
|
|
|
6640
7907
|
return Object.entries(bounds).map(([column, bound]) => `${column}=${bound.minimum ?? ""}:${bound.maximum ?? ""}`).join(",");
|
|
6641
7908
|
}
|
|
6642
7909
|
function parseTransitionGuardFlags(args) {
|
|
6643
|
-
return parseTransitionGuardsInput(repeatedArgs(args, "--transition-guard").join(","));
|
|
7910
|
+
return parseTransitionGuardsInput([...repeatedArgs(args, "--transition-guard"), ...repeatedArgs(args, "--status-guards")].join(","));
|
|
6644
7911
|
}
|
|
6645
7912
|
function parseTransitionGuardsInput(input) {
|
|
6646
7913
|
const guards = {};
|
|
@@ -6722,7 +7989,7 @@ function formatSchemaInspectionForCli(inspection, databaseUrlEnv) {
|
|
|
6722
7989
|
}
|
|
6723
7990
|
lines.push("");
|
|
6724
7991
|
lines.push("Next:");
|
|
6725
|
-
lines.push(` ${cliCommandName()}
|
|
7992
|
+
lines.push(` ${cliCommandName()} onboard db --from-env ${databaseUrlEnv}`);
|
|
6726
7993
|
lines.push(` ${cliCommandName()} tools preview --config ./synapsor.runner.json --store ./.synapsor/local.db`);
|
|
6727
7994
|
return `${lines.join("\n")}
|
|
6728
7995
|
`;
|
|
@@ -6829,6 +8096,54 @@ function envPresenceCheck(envName, message) {
|
|
|
6829
8096
|
message: process2.env[envName] ? `${envName} is set.` : message
|
|
6830
8097
|
};
|
|
6831
8098
|
}
|
|
8099
|
+
async function httpHandlerReachabilityCheck(executorName, rawUrl, timeoutMs) {
|
|
8100
|
+
try {
|
|
8101
|
+
const url = new URL(rawUrl);
|
|
8102
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
8103
|
+
return {
|
|
8104
|
+
name: `executor:${executorName}:handler-reachability`,
|
|
8105
|
+
ok: false,
|
|
8106
|
+
level: "fail",
|
|
8107
|
+
message: "HTTP handler URL must use http or https."
|
|
8108
|
+
};
|
|
8109
|
+
}
|
|
8110
|
+
} catch {
|
|
8111
|
+
return {
|
|
8112
|
+
name: `executor:${executorName}:handler-reachability`,
|
|
8113
|
+
ok: false,
|
|
8114
|
+
level: "fail",
|
|
8115
|
+
message: "HTTP handler URL env value is not a valid URL."
|
|
8116
|
+
};
|
|
8117
|
+
}
|
|
8118
|
+
const controller = new AbortController();
|
|
8119
|
+
const timeout = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs || 3e3, 1e4)));
|
|
8120
|
+
try {
|
|
8121
|
+
const response = await fetch(rawUrl, {
|
|
8122
|
+
method: "OPTIONS",
|
|
8123
|
+
headers: { accept: "application/json" },
|
|
8124
|
+
signal: controller.signal
|
|
8125
|
+
});
|
|
8126
|
+
return {
|
|
8127
|
+
name: `executor:${executorName}:handler-reachability`,
|
|
8128
|
+
ok: true,
|
|
8129
|
+
level: "pass",
|
|
8130
|
+
message: `HTTP handler endpoint responded with HTTP ${response.status}; network path is reachable. This is not an apply/writeback probe.`
|
|
8131
|
+
};
|
|
8132
|
+
} catch (error) {
|
|
8133
|
+
return {
|
|
8134
|
+
name: `executor:${executorName}:handler-reachability`,
|
|
8135
|
+
ok: false,
|
|
8136
|
+
level: "fail",
|
|
8137
|
+
message: `HTTP handler endpoint did not respond to the reachability probe (${safeReachabilityError(error)}).`
|
|
8138
|
+
};
|
|
8139
|
+
} finally {
|
|
8140
|
+
clearTimeout(timeout);
|
|
8141
|
+
}
|
|
8142
|
+
}
|
|
8143
|
+
function safeReachabilityError(error) {
|
|
8144
|
+
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) return "timeout";
|
|
8145
|
+
return "connection failed";
|
|
8146
|
+
}
|
|
6832
8147
|
async function inspectConfiguredSource(input) {
|
|
6833
8148
|
if (!process2.env[input.source.read_url_env]) return;
|
|
6834
8149
|
const capabilities = (input.config.capabilities ?? []).filter((capability) => capability.source === input.sourceName);
|
|
@@ -7101,7 +8416,7 @@ async function firstRunDoctor(args) {
|
|
|
7101
8416
|
const configPath = optionalArg(args, "--config") ?? "synapsor.runner.json";
|
|
7102
8417
|
const storePath = optionalArg(args, "--store") ?? "./.synapsor/local.db";
|
|
7103
8418
|
const configExists = await fileExists(configPath);
|
|
7104
|
-
checks.push(configExists ? pass("config", `Runner config exists at ${configPath}.`, "MCP serve/smoke need a reviewed config.", "No action needed.") : warn("config", `Runner config not found at ${configPath}.`, "Own-database MCP setup needs a generated config.", `Run ${cliCommandName()} demo first, or run ${cliCommandName()}
|
|
8419
|
+
checks.push(configExists ? pass("config", `Runner config exists at ${configPath}.`, "MCP serve/smoke need a reviewed config.", "No action needed.") : warn("config", `Runner config not found at ${configPath}.`, "Own-database MCP setup needs a generated config.", `Run ${cliCommandName()} demo first, or run ${cliCommandName()} onboard db --from-env DATABASE_URL.`));
|
|
7105
8420
|
if (configExists) {
|
|
7106
8421
|
const parsedConfig = JSON.parse(await fs3.readFile(configPath, "utf8"));
|
|
7107
8422
|
const validation = validateRunnerCapabilityConfig(parsedConfig);
|
|
@@ -7258,6 +8573,8 @@ function formatFirstRunDoctor(report) {
|
|
|
7258
8573
|
async function localDoctor(args) {
|
|
7259
8574
|
const configPath = optionalArg(args, "--config") ?? "synapsor.runner.json";
|
|
7260
8575
|
const allowSharedCredential = args.includes("--allow-shared-credential");
|
|
8576
|
+
const checkHandlers = args.includes("--check-handlers");
|
|
8577
|
+
const checkWriteback = args.includes("--check-writeback") || args.includes("--check-db");
|
|
7261
8578
|
const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
|
|
7262
8579
|
const checks = [];
|
|
7263
8580
|
const validation = validateRunnerCapabilityConfig(parsed);
|
|
@@ -7300,53 +8617,244 @@ async function localDoctor(args) {
|
|
|
7300
8617
|
} else {
|
|
7301
8618
|
checks.push({ name: `source:${sourceName}:write-url-env`, ok: false, level: "fail", message: "SQL writeback proposal capabilities require write_url_env for trusted writeback." });
|
|
7302
8619
|
}
|
|
8620
|
+
const writeUrl = source.write_url_env ? process2.env[source.write_url_env] : void 0;
|
|
8621
|
+
if (checkWriteback && writeUrl) {
|
|
8622
|
+
checks.push(...await directSqlWritebackDoctorChecks(parsed, sourceName, source, writeUrl));
|
|
8623
|
+
} else if (checkWriteback) {
|
|
8624
|
+
checks.push({
|
|
8625
|
+
name: `source:${sourceName}:writeback-probe`,
|
|
8626
|
+
ok: false,
|
|
8627
|
+
level: "fail",
|
|
8628
|
+
message: "Direct SQL writeback probe skipped because the writer env var is missing."
|
|
8629
|
+
});
|
|
8630
|
+
} else {
|
|
8631
|
+
checks.push({
|
|
8632
|
+
name: `source:${sourceName}:writeback-probe`,
|
|
8633
|
+
ok: true,
|
|
8634
|
+
level: "warn",
|
|
8635
|
+
message: `Direct SQL writeback was not probed. Rerun doctor with --check-writeback to verify writer connectivity, receipt-table permissions, and rollback-only target-table access.`
|
|
8636
|
+
});
|
|
8637
|
+
}
|
|
8638
|
+
}
|
|
8639
|
+
}
|
|
8640
|
+
await inspectConfiguredSource({ config: parsed, sourceName, source, checks });
|
|
8641
|
+
}
|
|
8642
|
+
for (const [executorName, executor] of Object.entries(parsed.executors ?? {})) {
|
|
8643
|
+
if (!isRecord6(executor)) continue;
|
|
8644
|
+
if (executor.type === "http_handler") {
|
|
8645
|
+
const urlEnv = String(executor.url_env ?? "");
|
|
8646
|
+
if (urlEnv) {
|
|
8647
|
+
checks.push(envPresenceCheck(urlEnv, `${urlEnv} is required for http_handler executor ${executorName}.`));
|
|
8648
|
+
const handlerUrl = process2.env[urlEnv];
|
|
8649
|
+
if (checkHandlers && handlerUrl) {
|
|
8650
|
+
checks.push(await httpHandlerReachabilityCheck(executorName, handlerUrl, Number(executor.timeout_ms ?? 3e3)));
|
|
8651
|
+
} else if (!checkHandlers) {
|
|
8652
|
+
checks.push({
|
|
8653
|
+
name: `executor:${executorName}:handler-reachability`,
|
|
8654
|
+
ok: true,
|
|
8655
|
+
level: "warn",
|
|
8656
|
+
message: `Handler reachability was not probed for ${executorName}. Rerun doctor with --check-handlers to verify the network path without applying a proposal.`
|
|
8657
|
+
});
|
|
8658
|
+
}
|
|
8659
|
+
}
|
|
8660
|
+
const auth = isRecord6(executor.auth) ? executor.auth : void 0;
|
|
8661
|
+
const tokenEnv = typeof auth?.token_env === "string" ? auth.token_env : void 0;
|
|
8662
|
+
if (tokenEnv) checks.push(envPresenceCheck(tokenEnv, `${tokenEnv} is required for http_handler executor ${executorName} bearer auth.`));
|
|
8663
|
+
const signingSecretEnv = typeof executor.signing_secret_env === "string" ? executor.signing_secret_env : void 0;
|
|
8664
|
+
if (signingSecretEnv) {
|
|
8665
|
+
checks.push(envPresenceCheck(signingSecretEnv, `${signingSecretEnv} is required to sign http_handler requests for executor ${executorName}.`));
|
|
8666
|
+
} else {
|
|
8667
|
+
checks.push({
|
|
8668
|
+
name: `executor:${executorName}:handler-signing`,
|
|
8669
|
+
ok: true,
|
|
8670
|
+
level: "warn",
|
|
8671
|
+
message: `No signing_secret_env is configured for http_handler executor ${executorName}. HMAC signing is recommended unless the handler is loopback-only and protected by another trusted boundary.`
|
|
8672
|
+
});
|
|
8673
|
+
}
|
|
8674
|
+
}
|
|
8675
|
+
if (executor.type === "command_handler") {
|
|
8676
|
+
const commandEnv = String(executor.command_env ?? "");
|
|
8677
|
+
if (commandEnv) checks.push(envPresenceCheck(commandEnv, `${commandEnv} is required for command_handler executor ${executorName}.`));
|
|
8678
|
+
}
|
|
8679
|
+
}
|
|
8680
|
+
const tools2 = localToolNames(parsed, checks);
|
|
8681
|
+
const forbiddenTools = tools2.filter((tool) => /execute_sql|run_query|approve|commit|apply_writeback/i.test(tool));
|
|
8682
|
+
checks.push({
|
|
8683
|
+
name: "mcp-tool-boundary",
|
|
8684
|
+
ok: forbiddenTools.length === 0,
|
|
8685
|
+
level: forbiddenTools.length === 0 ? "pass" : "fail",
|
|
8686
|
+
message: forbiddenTools.length === 0 ? "MCP tool catalog is semantic-only." : `Forbidden model-facing tools: ${forbiddenTools.join(", ")}`
|
|
8687
|
+
});
|
|
8688
|
+
const report = {
|
|
8689
|
+
ok: checks.every((check) => check.level !== "fail"),
|
|
8690
|
+
mode: String(parsed.mode),
|
|
8691
|
+
config_path: configPath,
|
|
8692
|
+
checks,
|
|
8693
|
+
tools: tools2,
|
|
8694
|
+
store_stats: await localDoctorStoreStats(optionalArg(args, "--store") ?? parsed.storage?.sqlite_path)
|
|
8695
|
+
};
|
|
8696
|
+
if (args.includes("--report")) {
|
|
8697
|
+
const output = outputArg(args) ?? "synapsor-doctor.md";
|
|
8698
|
+
await fs3.mkdir(path3.dirname(path3.resolve(output)), { recursive: true });
|
|
8699
|
+
await fs3.writeFile(output, formatLocalDoctorMarkdown(report), "utf8");
|
|
8700
|
+
process2.stdout.write(`wrote redacted doctor report: ${output}
|
|
8701
|
+
`);
|
|
8702
|
+
} else if (args.includes("--json")) {
|
|
8703
|
+
process2.stdout.write(`${JSON.stringify(report, null, 2)}
|
|
8704
|
+
`);
|
|
8705
|
+
} else {
|
|
8706
|
+
process2.stdout.write(formatLocalDoctorReport(report));
|
|
8707
|
+
}
|
|
8708
|
+
return report.ok ? 0 : 1;
|
|
8709
|
+
}
|
|
8710
|
+
async function directSqlWritebackDoctorChecks(config, sourceName, source, writeUrl) {
|
|
8711
|
+
const checks = [];
|
|
8712
|
+
try {
|
|
8713
|
+
const result = await adapters[source.engine].doctor({
|
|
8714
|
+
controlPlaneUrl: "local",
|
|
8715
|
+
runnerToken: "local",
|
|
8716
|
+
runnerId: "doctor",
|
|
8717
|
+
sourceId: sourceName,
|
|
8718
|
+
databaseUrl: writeUrl,
|
|
8719
|
+
engine: source.engine,
|
|
8720
|
+
pollIntervalMs: 0,
|
|
8721
|
+
logLevel: "error",
|
|
8722
|
+
dryRun: true,
|
|
8723
|
+
stateDir: "./state"
|
|
8724
|
+
});
|
|
8725
|
+
checks.push({
|
|
8726
|
+
name: `source:${sourceName}:receipt-table-probe`,
|
|
8727
|
+
ok: result.ok,
|
|
8728
|
+
level: result.ok ? "pass" : "fail",
|
|
8729
|
+
message: result.ok ? "Writer credential can reach the database and the receipt-table rollback probe succeeded." : `Writer receipt-table probe failed (${safeDatabaseProbeError(result.details)}). ${receiptTableGuidance(source.engine)}`
|
|
8730
|
+
});
|
|
8731
|
+
} catch (error) {
|
|
8732
|
+
checks.push({
|
|
8733
|
+
name: `source:${sourceName}:receipt-table-probe`,
|
|
8734
|
+
ok: false,
|
|
8735
|
+
level: "fail",
|
|
8736
|
+
message: `Writer receipt-table probe failed (${safeDatabaseProbeError(error)}). ${receiptTableGuidance(source.engine)}`
|
|
8737
|
+
});
|
|
8738
|
+
}
|
|
8739
|
+
for (const capability of directSqlProposalCapabilities(config, sourceName)) {
|
|
8740
|
+
try {
|
|
8741
|
+
await rollbackOnlyTargetProbe(source.engine, writeUrl, capability);
|
|
8742
|
+
checks.push({
|
|
8743
|
+
name: `capability:${capability.name}:writeback-target-probe`,
|
|
8744
|
+
ok: true,
|
|
8745
|
+
level: "pass",
|
|
8746
|
+
message: `Rollback-only writer probe reached ${capability.target.schema}.${capability.target.table} and verified configured write columns without mutating business rows.`
|
|
8747
|
+
});
|
|
8748
|
+
} catch (error) {
|
|
8749
|
+
checks.push({
|
|
8750
|
+
name: `capability:${capability.name}:writeback-target-probe`,
|
|
8751
|
+
ok: false,
|
|
8752
|
+
level: "fail",
|
|
8753
|
+
message: `Rollback-only writer probe failed for configured target ${capability.target.schema}.${capability.target.table} (${safeDatabaseProbeError(error)}). Verify writer SELECT/UPDATE on the target table and configured columns.`
|
|
8754
|
+
});
|
|
8755
|
+
}
|
|
8756
|
+
}
|
|
8757
|
+
return checks;
|
|
8758
|
+
}
|
|
8759
|
+
function directSqlProposalCapabilities(config, sourceName) {
|
|
8760
|
+
return (config.capabilities ?? []).filter((capability) => {
|
|
8761
|
+
if (capability.kind !== "proposal" || capability.source !== sourceName) return false;
|
|
8762
|
+
return (capability.executor ?? "sql_update") === "sql_update";
|
|
8763
|
+
});
|
|
8764
|
+
}
|
|
8765
|
+
async function rollbackOnlyTargetProbe(engine, databaseUrl, capability) {
|
|
8766
|
+
if (engine === "postgres") {
|
|
8767
|
+
await rollbackOnlyPostgresTargetProbe(databaseUrl, capability);
|
|
8768
|
+
return;
|
|
8769
|
+
}
|
|
8770
|
+
await rollbackOnlyMysqlTargetProbe(databaseUrl, capability);
|
|
8771
|
+
}
|
|
8772
|
+
async function rollbackOnlyPostgresTargetProbe(databaseUrl, capability) {
|
|
8773
|
+
const pg = await dynamicImportModule("pg");
|
|
8774
|
+
const pool = new pg.Pool({ connectionString: databaseUrl });
|
|
8775
|
+
const client = await pool.connect();
|
|
8776
|
+
try {
|
|
8777
|
+
await client.query("BEGIN");
|
|
8778
|
+
try {
|
|
8779
|
+
const table = `${quotePostgresIdentifier2(capability.target.schema)}.${quotePostgresIdentifier2(capability.target.table)}`;
|
|
8780
|
+
const columns = proposalProbeColumns(capability).map(quotePostgresIdentifier2).join(", ");
|
|
8781
|
+
await client.query(`SELECT ${columns} FROM ${table} WHERE false FOR UPDATE`);
|
|
8782
|
+
for (const column of proposalUpdateProbeColumns(capability)) {
|
|
8783
|
+
const quoted = quotePostgresIdentifier2(column);
|
|
8784
|
+
await client.query(`UPDATE ${table} SET ${quoted} = ${quoted} WHERE false`);
|
|
7303
8785
|
}
|
|
8786
|
+
await client.query("ROLLBACK");
|
|
8787
|
+
} catch (error) {
|
|
8788
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
8789
|
+
throw error;
|
|
7304
8790
|
}
|
|
7305
|
-
|
|
8791
|
+
} finally {
|
|
8792
|
+
client.release();
|
|
8793
|
+
await pool.end();
|
|
7306
8794
|
}
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
|
|
7312
|
-
|
|
7313
|
-
|
|
7314
|
-
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
const
|
|
7318
|
-
|
|
8795
|
+
}
|
|
8796
|
+
async function rollbackOnlyMysqlTargetProbe(databaseUrl, capability) {
|
|
8797
|
+
const mysql4 = await dynamicImportModule("mysql2/promise");
|
|
8798
|
+
const connection = await mysql4.createConnection({ uri: databaseUrl, dateStrings: true });
|
|
8799
|
+
try {
|
|
8800
|
+
await connection.beginTransaction();
|
|
8801
|
+
try {
|
|
8802
|
+
const table = `${quoteMysqlIdentifier2(capability.target.schema)}.${quoteMysqlIdentifier2(capability.target.table)}`;
|
|
8803
|
+
const columns = proposalProbeColumns(capability).map(quoteMysqlIdentifier2).join(", ");
|
|
8804
|
+
await connection.query(`SELECT ${columns} FROM ${table} WHERE 1 = 0 FOR UPDATE`);
|
|
8805
|
+
for (const column of proposalUpdateProbeColumns(capability)) {
|
|
8806
|
+
const quoted = quoteMysqlIdentifier2(column);
|
|
8807
|
+
await connection.query(`UPDATE ${table} SET ${quoted} = ${quoted} WHERE 1 = 0`);
|
|
8808
|
+
}
|
|
8809
|
+
await connection.rollback();
|
|
8810
|
+
} catch (error) {
|
|
8811
|
+
await connection.rollback().catch(() => void 0);
|
|
8812
|
+
throw error;
|
|
7319
8813
|
}
|
|
8814
|
+
} finally {
|
|
8815
|
+
await connection.end();
|
|
7320
8816
|
}
|
|
7321
|
-
|
|
7322
|
-
|
|
7323
|
-
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
7327
|
-
|
|
7328
|
-
|
|
7329
|
-
|
|
7330
|
-
|
|
7331
|
-
|
|
7332
|
-
|
|
7333
|
-
|
|
7334
|
-
|
|
7335
|
-
|
|
7336
|
-
|
|
7337
|
-
|
|
7338
|
-
|
|
7339
|
-
|
|
7340
|
-
|
|
7341
|
-
|
|
7342
|
-
`)
|
|
7343
|
-
|
|
7344
|
-
|
|
7345
|
-
|
|
7346
|
-
|
|
7347
|
-
|
|
7348
|
-
}
|
|
7349
|
-
|
|
8817
|
+
}
|
|
8818
|
+
async function dynamicImportModule(specifier) {
|
|
8819
|
+
const importer = new Function("specifier", "return import(specifier)");
|
|
8820
|
+
return importer(specifier);
|
|
8821
|
+
}
|
|
8822
|
+
function proposalProbeColumns(capability) {
|
|
8823
|
+
const columns = /* @__PURE__ */ new Set();
|
|
8824
|
+
columns.add(capability.target.primary_key);
|
|
8825
|
+
if (capability.target.tenant_key) columns.add(capability.target.tenant_key);
|
|
8826
|
+
if (capability.conflict_guard?.column) columns.add(capability.conflict_guard.column);
|
|
8827
|
+
for (const column of capability.visible_columns ?? []) columns.add(column);
|
|
8828
|
+
for (const column of proposalUpdateProbeColumns(capability)) columns.add(column);
|
|
8829
|
+
return [...columns];
|
|
8830
|
+
}
|
|
8831
|
+
function proposalUpdateProbeColumns(capability) {
|
|
8832
|
+
const columns = /* @__PURE__ */ new Set();
|
|
8833
|
+
for (const column of capability.allowed_columns ?? []) columns.add(column);
|
|
8834
|
+
for (const column of Object.keys(capability.patch ?? {})) columns.add(column);
|
|
8835
|
+
return [...columns];
|
|
8836
|
+
}
|
|
8837
|
+
function quotePostgresIdentifier2(value) {
|
|
8838
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
8839
|
+
}
|
|
8840
|
+
function quoteMysqlIdentifier2(value) {
|
|
8841
|
+
return `\`${value.replace(/`/g, "``")}\``;
|
|
8842
|
+
}
|
|
8843
|
+
function safeDatabaseProbeError(error) {
|
|
8844
|
+
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : JSON.stringify(error ?? {});
|
|
8845
|
+
const message = raw.toLowerCase();
|
|
8846
|
+
if (/permission|denied|not authorized|insufficient|42501|er_tableaccess_denied|er_dbaccess_denied/.test(message)) return "permission denied";
|
|
8847
|
+
if (/authentication|password|28p01|access denied for user|invalid authorization/.test(message)) return "authentication failed";
|
|
8848
|
+
if (/timeout|timed out|etimedout/.test(message)) return "timeout";
|
|
8849
|
+
if (/econnrefused|enotfound|eai_again|network|connection terminated|connection failed/.test(message)) return "connection failed";
|
|
8850
|
+
if (/does not exist|unknown database|no such table|undefined_table|er_no_such_table|42p01/.test(message)) return "configured object not found";
|
|
8851
|
+
return "database probe failed";
|
|
8852
|
+
}
|
|
8853
|
+
function receiptTableGuidance(engine) {
|
|
8854
|
+
if (engine === "postgres") {
|
|
8855
|
+
return `Pre-create the receipt table with "${cliCommandName()} writeback migration --engine postgres --schema synapsor" and grant it with "${cliCommandName()} writeback grants --engine postgres --schema synapsor --writer-role <writer_role>", or use an app-owned handler executor.`;
|
|
8856
|
+
}
|
|
8857
|
+
return `Pre-create the receipt table with "${cliCommandName()} writeback migration --engine mysql --schema <database_name>" and grant it with "${cliCommandName()} writeback grants --engine mysql --schema <database_name> --writer-role \\"'<writer>'@'%'\\"", or use an app-owned handler executor.`;
|
|
7350
8858
|
}
|
|
7351
8859
|
async function localDoctorStoreStats(storePath) {
|
|
7352
8860
|
if (!storePath || storePath === ":memory:") return { path: storePath ?? "not configured", exists: storePath === ":memory:" };
|
|
@@ -7522,8 +9030,8 @@ function findProposalCapability(config, proposal) {
|
|
|
7522
9030
|
return capability;
|
|
7523
9031
|
}
|
|
7524
9032
|
function proposalExecutorName(proposal, capability) {
|
|
7525
|
-
const
|
|
7526
|
-
return capability.executor ?? (typeof
|
|
9033
|
+
const writeback2 = proposal.change_set.writeback;
|
|
9034
|
+
return capability.executor ?? (typeof writeback2.executor === "string" ? writeback2.executor : void 0) ?? "sql_update";
|
|
7527
9035
|
}
|
|
7528
9036
|
function executorConfig(config, executorName) {
|
|
7529
9037
|
const raw = config.executors?.[executorName];
|
|
@@ -7533,6 +9041,9 @@ function executorConfig(config, executorName) {
|
|
|
7533
9041
|
if (raw.type === "sql_update") return { type: "sql_update" };
|
|
7534
9042
|
throw new Error(`executor ${executorName} has unsupported type`);
|
|
7535
9043
|
}
|
|
9044
|
+
function signHandlerRequestBody(body, secret) {
|
|
9045
|
+
return `sha256=${crypto5.createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
9046
|
+
}
|
|
7536
9047
|
async function applyHttpHandlerProposal(input) {
|
|
7537
9048
|
const duplicate = duplicateHandlerReceipt(input.store, input.proposalId);
|
|
7538
9049
|
if (duplicate) return alreadyAppliedReceipt(duplicate.receipt, input.runnerId);
|
|
@@ -7557,6 +9068,21 @@ async function applyHttpHandlerProposal(input) {
|
|
|
7557
9068
|
if (!token) throw new Error(`${input.executor.auth.token_env} is not set`);
|
|
7558
9069
|
headers.authorization = `Bearer ${token}`;
|
|
7559
9070
|
}
|
|
9071
|
+
const issuedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9072
|
+
const requestBody = JSON.stringify({
|
|
9073
|
+
protocol_version: "1.0",
|
|
9074
|
+
...prepared.request,
|
|
9075
|
+
issued_at: issuedAt,
|
|
9076
|
+
executor: input.executorName,
|
|
9077
|
+
dry_run: input.dryRun
|
|
9078
|
+
});
|
|
9079
|
+
headers["x-synapsor-issued-at"] = issuedAt;
|
|
9080
|
+
headers["x-synapsor-proposal-id"] = prepared.proposal.proposal_id;
|
|
9081
|
+
if (input.executor.signing_secret_env) {
|
|
9082
|
+
const signingSecret = input.env[input.executor.signing_secret_env];
|
|
9083
|
+
if (!signingSecret) throw new Error(`${input.executor.signing_secret_env} is not set`);
|
|
9084
|
+
headers["x-synapsor-signature"] = signHandlerRequestBody(requestBody, signingSecret);
|
|
9085
|
+
}
|
|
7560
9086
|
const controller = new AbortController();
|
|
7561
9087
|
const timeout = setTimeout(() => controller.abort(), Math.max(1, input.executor.timeout_ms ?? 5e3));
|
|
7562
9088
|
let receipt;
|
|
@@ -7564,7 +9090,7 @@ async function applyHttpHandlerProposal(input) {
|
|
|
7564
9090
|
const response = await fetch(url, {
|
|
7565
9091
|
method: input.executor.method ?? "POST",
|
|
7566
9092
|
headers,
|
|
7567
|
-
body:
|
|
9093
|
+
body: requestBody,
|
|
7568
9094
|
signal: controller.signal
|
|
7569
9095
|
});
|
|
7570
9096
|
const text = await response.text();
|
|
@@ -7937,7 +9463,13 @@ function capabilityMatchesJob(capability, job) {
|
|
|
7937
9463
|
if (reviewedAllowed.size === 0) return false;
|
|
7938
9464
|
return Object.keys(job.patch).every((column) => reviewedAllowed.has(column));
|
|
7939
9465
|
}
|
|
7940
|
-
async function start() {
|
|
9466
|
+
async function start(args = []) {
|
|
9467
|
+
if (args.length > 0) {
|
|
9468
|
+
if (args.includes("--from-env") || args.includes("--schema") || args.includes("--mode") || args.includes("--engine")) {
|
|
9469
|
+
return onboard(["db", ...args]);
|
|
9470
|
+
}
|
|
9471
|
+
throw new Error(`start accepts own-database onboarding flags such as --from-env DATABASE_URL, or no flags for the legacy polling worker. Unknown start arguments: ${args.join(" ")}`);
|
|
9472
|
+
}
|
|
7941
9473
|
const config = loadConfig();
|
|
7942
9474
|
const controller = new AbortController();
|
|
7943
9475
|
process2.on("SIGINT", () => controller.abort());
|
|
@@ -7945,9 +9477,211 @@ async function start() {
|
|
|
7945
9477
|
await startPolling(config, adapters, controller.signal);
|
|
7946
9478
|
return 0;
|
|
7947
9479
|
}
|
|
9480
|
+
async function up(args = []) {
|
|
9481
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
9482
|
+
"--config",
|
|
9483
|
+
"--store",
|
|
9484
|
+
"--transport",
|
|
9485
|
+
"--serve",
|
|
9486
|
+
"--with-handler",
|
|
9487
|
+
"--host",
|
|
9488
|
+
"--port",
|
|
9489
|
+
"--auth-token-env",
|
|
9490
|
+
"--alias-mode",
|
|
9491
|
+
"--tool-name-style",
|
|
9492
|
+
"--openai-tool-aliases",
|
|
9493
|
+
"--result-format",
|
|
9494
|
+
"--handler-check",
|
|
9495
|
+
"--open-ui",
|
|
9496
|
+
"--print-next",
|
|
9497
|
+
"--dry-run",
|
|
9498
|
+
"--dev-no-auth",
|
|
9499
|
+
"--cors-origin",
|
|
9500
|
+
"--allow-concurrent-store"
|
|
9501
|
+
]);
|
|
9502
|
+
assertKnownOptions(args, allowed, "up");
|
|
9503
|
+
const configPath = optionalArg(args, "--config") ?? defaultConfigPath;
|
|
9504
|
+
const config = await readRuntimeConfig(configPath);
|
|
9505
|
+
const storePath = optionalArg(args, "--store") ?? config.storage?.sqlite_path ?? defaultStorePath;
|
|
9506
|
+
const serveRequested = args.includes("--serve");
|
|
9507
|
+
const transport = optionalArg(args, "--transport") ?? (serveRequested ? "streamable-http" : "stdio");
|
|
9508
|
+
if (transport !== "stdio" && transport !== "streamable-http") {
|
|
9509
|
+
throw new Error("--transport must be stdio or streamable-http");
|
|
9510
|
+
}
|
|
9511
|
+
if (serveRequested && transport === "stdio") {
|
|
9512
|
+
throw new Error("up --serve starts the Streamable HTTP MCP server. Omit --transport or use --transport streamable-http; for stdio, use mcp client-config so the client launches Runner.");
|
|
9513
|
+
}
|
|
9514
|
+
const port = Number(optionalArg(args, "--port") ?? "8766");
|
|
9515
|
+
if (transport === "streamable-http" && (!Number.isInteger(port) || port <= 0 || port > 65535)) {
|
|
9516
|
+
throw new Error("--port must be an integer from 1 to 65535");
|
|
9517
|
+
}
|
|
9518
|
+
const aliasMode = toolNameStyleOption(args);
|
|
9519
|
+
const resultFormat = resultFormatOption(args);
|
|
9520
|
+
const validation = validateRunnerCapabilityConfig(config);
|
|
9521
|
+
if (!validation.ok) {
|
|
9522
|
+
throw new Error(`cannot bring Runner up with invalid config: ${validation.errors.map((error) => `${error.path} ${error.code}`).join("; ")}`);
|
|
9523
|
+
}
|
|
9524
|
+
if (storePath !== ":memory:") {
|
|
9525
|
+
await fs3.mkdir(path3.dirname(path3.resolve(storePath)), { recursive: true });
|
|
9526
|
+
}
|
|
9527
|
+
await assertNoActiveStoreLease(storePath, args.includes("--allow-concurrent-store"), "review-mode up");
|
|
9528
|
+
const boundary = await inspectMcpToolBoundary([
|
|
9529
|
+
"--config",
|
|
9530
|
+
configPath,
|
|
9531
|
+
"--store",
|
|
9532
|
+
storePath,
|
|
9533
|
+
"--alias-mode",
|
|
9534
|
+
aliasMode
|
|
9535
|
+
]);
|
|
9536
|
+
process2.stdout.write(formatReviewModeUp({
|
|
9537
|
+
aliasMode,
|
|
9538
|
+
authTokenEnv: optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
|
|
9539
|
+
boundary,
|
|
9540
|
+
config,
|
|
9541
|
+
configPath,
|
|
9542
|
+
dryRun: args.includes("--dry-run"),
|
|
9543
|
+
host: optionalArg(args, "--host") ?? "127.0.0.1",
|
|
9544
|
+
openUi: args.includes("--open-ui"),
|
|
9545
|
+
port,
|
|
9546
|
+
resultFormat,
|
|
9547
|
+
serveRequested,
|
|
9548
|
+
storePath,
|
|
9549
|
+
transport
|
|
9550
|
+
}));
|
|
9551
|
+
if (args.includes("--with-handler") || args.includes("--handler-check")) {
|
|
9552
|
+
process2.stdout.write("\nHandler check:\n");
|
|
9553
|
+
const doctorCode = await doctor(["--config", configPath, "--store", storePath, "--check-handlers"]);
|
|
9554
|
+
if (doctorCode !== 0) return doctorCode;
|
|
9555
|
+
}
|
|
9556
|
+
if (args.includes("--dry-run") || !serveRequested) return boundary.ok ? 0 : 1;
|
|
9557
|
+
if (!boundary.ok) return 1;
|
|
9558
|
+
const serveArgs = [
|
|
9559
|
+
"--config",
|
|
9560
|
+
configPath,
|
|
9561
|
+
"--store",
|
|
9562
|
+
storePath,
|
|
9563
|
+
"--host",
|
|
9564
|
+
optionalArg(args, "--host") ?? "127.0.0.1",
|
|
9565
|
+
"--port",
|
|
9566
|
+
String(port),
|
|
9567
|
+
"--auth-token-env",
|
|
9568
|
+
optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
|
|
9569
|
+
"--alias-mode",
|
|
9570
|
+
aliasMode,
|
|
9571
|
+
...resultFormat ? ["--result-format", String(resultFormat)] : [],
|
|
9572
|
+
...args.includes("--dev-no-auth") ? ["--dev-no-auth"] : [],
|
|
9573
|
+
...optionalArg(args, "--cors-origin") ? ["--cors-origin", optionalArg(args, "--cors-origin")] : [],
|
|
9574
|
+
...args.includes("--allow-concurrent-store") ? ["--allow-concurrent-store"] : []
|
|
9575
|
+
];
|
|
9576
|
+
return mcpServeStreamableHttp(serveArgs);
|
|
9577
|
+
}
|
|
9578
|
+
function formatReviewModeUp(input) {
|
|
9579
|
+
const lines = [
|
|
9580
|
+
"Synapsor Runner review-mode up",
|
|
9581
|
+
"",
|
|
9582
|
+
`Config: ${input.configPath}`,
|
|
9583
|
+
`Store: ${input.storePath}`,
|
|
9584
|
+
`Mode: ${input.config.mode}`,
|
|
9585
|
+
`Transport: ${input.transport}`,
|
|
9586
|
+
`Serve now: ${input.serveRequested ? "yes" : "no"}`,
|
|
9587
|
+
`Alias mode: ${input.aliasMode}`,
|
|
9588
|
+
`Result format: ${input.resultFormat ? `v${input.resultFormat}` : configResultFormat(input.config)}`,
|
|
9589
|
+
`Dry run: ${input.dryRun ? "yes" : "no"}`,
|
|
9590
|
+
"",
|
|
9591
|
+
"Model-facing tools:",
|
|
9592
|
+
...formatUpToolLines(input.boundary),
|
|
9593
|
+
"",
|
|
9594
|
+
"Writeback paths:",
|
|
9595
|
+
...formatUpWritebackLines(input.config)
|
|
9596
|
+
];
|
|
9597
|
+
const handlerLines = formatUpHandlerLines(input.config);
|
|
9598
|
+
if (handlerLines.length > 0) {
|
|
9599
|
+
lines.push("", "App-owned handler requirements:", ...handlerLines, "", handlerSecurityWarning);
|
|
9600
|
+
}
|
|
9601
|
+
lines.push("", "Server guidance:");
|
|
9602
|
+
if (input.transport === "stdio") {
|
|
9603
|
+
lines.push(
|
|
9604
|
+
" stdio mode is launched by an MCP client. This command does not hold a protocol session open.",
|
|
9605
|
+
` Print client config: ${cliCommandName()} mcp client-config --client claude-desktop --config ${input.configPath} --store ${input.storePath}`,
|
|
9606
|
+
` Serve command used by clients: ${cliCommandName()} mcp serve --config ${input.configPath} --store ${input.storePath} --alias-mode ${input.aliasMode}`
|
|
9607
|
+
);
|
|
9608
|
+
} else {
|
|
9609
|
+
lines.push(
|
|
9610
|
+
` Streamable HTTP endpoint: http://${input.host}:${input.port}/mcp`,
|
|
9611
|
+
` Auth token env: ${input.authTokenEnv} (${process2.env[input.authTokenEnv] ? "set" : "missing"})`,
|
|
9612
|
+
input.serveRequested ? input.dryRun ? " Status: dry run only; server not started." : " Status: starting after this checklist." : ` Start command: ${cliCommandName()} up --serve --config ${input.configPath} --store ${input.storePath} --port ${input.port} --auth-token-env ${input.authTokenEnv} --alias-mode ${input.aliasMode}`
|
|
9613
|
+
);
|
|
9614
|
+
}
|
|
9615
|
+
if (input.openUi) {
|
|
9616
|
+
lines.push("", "Local review UI:", ` ${cliCommandName()} ui --open --tour --config ${input.configPath} --store ${input.storePath}`);
|
|
9617
|
+
}
|
|
9618
|
+
lines.push("", "Next commands:", ...formatUpNextCommands(input.config, input.configPath, input.storePath), "");
|
|
9619
|
+
return `${lines.join("\n")}
|
|
9620
|
+
`;
|
|
9621
|
+
}
|
|
9622
|
+
function formatUpToolLines(boundary) {
|
|
9623
|
+
if (boundary.exposures.length === 0) return [" - (none)"];
|
|
9624
|
+
return boundary.exposures.map((item) => item.isAlias ? ` - ${item.exposedName} -> ${item.canonicalName}` : ` - ${item.exposedName}`);
|
|
9625
|
+
}
|
|
9626
|
+
function formatUpWritebackLines(config) {
|
|
9627
|
+
const proposals2 = (config.capabilities ?? []).filter((capability) => capability.kind === "proposal");
|
|
9628
|
+
if (proposals2.length === 0) return [" - no proposal capabilities; this config is read-only from Runner's perspective"];
|
|
9629
|
+
return proposals2.map((capability) => {
|
|
9630
|
+
const executorName = capability.executor ?? "sql_update";
|
|
9631
|
+
if (executorName === "sql_update") {
|
|
9632
|
+
const source = config.sources?.[capability.source];
|
|
9633
|
+
const envName = source?.write_url_env ?? "SYNAPSOR_DATABASE_URL";
|
|
9634
|
+
return ` - ${capability.name}: direct guarded one-row UPDATE via ${envName} (${process2.env[envName] ? "set" : "missing"})`;
|
|
9635
|
+
}
|
|
9636
|
+
const executor = config.executors?.[executorName];
|
|
9637
|
+
return ` - ${capability.name}: app-owned ${String(executor?.type ?? "executor")} ${executorName}`;
|
|
9638
|
+
});
|
|
9639
|
+
}
|
|
9640
|
+
function formatUpHandlerLines(config) {
|
|
9641
|
+
const lines = [];
|
|
9642
|
+
for (const [name, executor] of Object.entries(config.executors ?? {})) {
|
|
9643
|
+
if (!isRecord6(executor)) continue;
|
|
9644
|
+
if (executor.type === "http_handler") {
|
|
9645
|
+
const urlEnv = typeof executor.url_env === "string" ? executor.url_env : "";
|
|
9646
|
+
const auth = isRecord6(executor.auth) ? executor.auth : void 0;
|
|
9647
|
+
const tokenEnv = typeof auth?.token_env === "string" ? auth.token_env : void 0;
|
|
9648
|
+
const signingSecretEnv = typeof executor.signing_secret_env === "string" ? executor.signing_secret_env : void 0;
|
|
9649
|
+
lines.push(` - ${name}: http_handler`);
|
|
9650
|
+
if (urlEnv) lines.push(` url env: ${urlEnv} (${process2.env[urlEnv] ? "set" : "missing"})`);
|
|
9651
|
+
if (tokenEnv) lines.push(` bearer token env: ${tokenEnv} (${process2.env[tokenEnv] ? "set" : "missing"})`);
|
|
9652
|
+
if (signingSecretEnv) lines.push(` signing secret env: ${signingSecretEnv} (${process2.env[signingSecretEnv] ? "set" : "missing"})`);
|
|
9653
|
+
if (!signingSecretEnv) lines.push(" signing secret env: not configured (recommended unless loopback-only)");
|
|
9654
|
+
} else if (executor.type === "command_handler") {
|
|
9655
|
+
const commandEnv = typeof executor.command_env === "string" ? executor.command_env : "";
|
|
9656
|
+
lines.push(` - ${name}: command_handler`);
|
|
9657
|
+
if (commandEnv) lines.push(` command env: ${commandEnv} (${process2.env[commandEnv] ? "set" : "missing"})`);
|
|
9658
|
+
}
|
|
9659
|
+
}
|
|
9660
|
+
return lines;
|
|
9661
|
+
}
|
|
9662
|
+
function configResultFormat(config) {
|
|
9663
|
+
return config.result_format === 2 ? "v2" : config.result_format === 1 ? "v1" : "default";
|
|
9664
|
+
}
|
|
9665
|
+
function formatUpNextCommands(config, configPath, storePath) {
|
|
9666
|
+
const firstTool = (config.capabilities ?? [])[0]?.name ?? "<capability>";
|
|
9667
|
+
const hasHandlers = Object.keys(config.executors ?? {}).length > 0;
|
|
9668
|
+
return [
|
|
9669
|
+
` - Preview tools: ${cliCommandName()} tools preview --config ${configPath} --store ${storePath}`,
|
|
9670
|
+
` - Smoke call: ${cliCommandName()} smoke call ${firstTool} --sample --config ${configPath} --store ${storePath}`,
|
|
9671
|
+
` - List proposals: ${cliCommandName()} proposals list --store ${storePath}`,
|
|
9672
|
+
` - Show proposal: ${cliCommandName()} proposals show latest --store ${storePath}`,
|
|
9673
|
+
` - Approve proposal: ${cliCommandName()} proposals approve latest --yes --store ${storePath}`,
|
|
9674
|
+
` - Apply approved proposal: ${cliCommandName()} apply latest --config ${configPath} --store ${storePath}`,
|
|
9675
|
+
` - Replay: ${cliCommandName()} replay show latest --store ${storePath}`,
|
|
9676
|
+
` - Tail events: ${cliCommandName()} events tail --store ${storePath}`,
|
|
9677
|
+
` - Direct writeback doctor: ${cliCommandName()} doctor --config ${configPath} --check-writeback`,
|
|
9678
|
+
...hasHandlers ? [` - Handler doctor: ${cliCommandName()} doctor --config ${configPath} --check-handlers`] : []
|
|
9679
|
+
];
|
|
9680
|
+
}
|
|
7948
9681
|
async function runnerCommand(args) {
|
|
7949
9682
|
const [subcommand, ...rest] = args;
|
|
7950
|
-
if (subcommand === "start") return start();
|
|
9683
|
+
if (subcommand === "start") return start(rest);
|
|
9684
|
+
if (subcommand === "up") return up(rest);
|
|
7951
9685
|
if (subcommand === "doctor") return doctor(rest);
|
|
7952
9686
|
usage();
|
|
7953
9687
|
return 2;
|
|
@@ -7986,7 +9720,7 @@ async function cloudConnect(args) {
|
|
|
7986
9720
|
return 1;
|
|
7987
9721
|
}
|
|
7988
9722
|
const runnerId = String(parsed.cloud.runner_id || process2.env.SYNAPSOR_RUNNER_ID || "synapsor_runner_local").trim();
|
|
7989
|
-
const runnerVersion = String(parsed.cloud.runner_version || process2.env.npm_package_version || "0.1.0
|
|
9723
|
+
const runnerVersion = String(parsed.cloud.runner_version || process2.env.npm_package_version || "0.1.0").trim();
|
|
7990
9724
|
const engines = normalizeEngines(parsed.cloud.engines);
|
|
7991
9725
|
const capabilities = normalizeCapabilities(parsed.cloud.capabilities);
|
|
7992
9726
|
const client = new ControlPlaneClient({
|
|
@@ -8037,8 +9771,10 @@ async function mcp(args) {
|
|
|
8037
9771
|
const [subcommand, ...rest] = args;
|
|
8038
9772
|
if (subcommand === "serve") return mcpServe(rest);
|
|
8039
9773
|
if (subcommand === "serve-http") return mcpServeHttp(rest);
|
|
9774
|
+
if (subcommand === "serve-streamable-http") return mcpServeStreamableHttp(rest);
|
|
8040
9775
|
if (subcommand === "audit") return mcpAudit(rest);
|
|
8041
9776
|
if (subcommand === "config") return mcpConfig(rest);
|
|
9777
|
+
if (subcommand === "client-config") return mcpConfigure(rest);
|
|
8042
9778
|
if (subcommand === "configure") return mcpConfigure(rest);
|
|
8043
9779
|
if (subcommand === "smoke") return mcpSmoke(rest);
|
|
8044
9780
|
usage(["mcp"]);
|
|
@@ -8047,9 +9783,158 @@ async function mcp(args) {
|
|
|
8047
9783
|
async function tools(args) {
|
|
8048
9784
|
const [subcommand, ...rest] = args;
|
|
8049
9785
|
if (subcommand === "preview") return toolsPreview(rest);
|
|
9786
|
+
if (subcommand === "list") return toolsPreview(rest);
|
|
8050
9787
|
usage(["tools"]);
|
|
8051
9788
|
return 2;
|
|
8052
9789
|
}
|
|
9790
|
+
async function smoke(args) {
|
|
9791
|
+
const [subcommand, ...rest] = args;
|
|
9792
|
+
if (subcommand === "call") return smokeCall(rest);
|
|
9793
|
+
if (subcommand === "boundary") return mcpSmoke(rest);
|
|
9794
|
+
usage(["smoke"]);
|
|
9795
|
+
return 2;
|
|
9796
|
+
}
|
|
9797
|
+
async function writeback(args) {
|
|
9798
|
+
const [subcommand, ...rest] = args;
|
|
9799
|
+
if (subcommand === "doctor") return writebackDoctor(rest);
|
|
9800
|
+
if (subcommand === "migration") return writebackMigration(rest);
|
|
9801
|
+
if (subcommand === "grants") return writebackGrants(rest);
|
|
9802
|
+
usage(["writeback"]);
|
|
9803
|
+
return 2;
|
|
9804
|
+
}
|
|
9805
|
+
async function handler(args) {
|
|
9806
|
+
const [subcommand, ...rest] = args;
|
|
9807
|
+
if (subcommand === "template") return handlerTemplate(rest);
|
|
9808
|
+
usage(["handler"]);
|
|
9809
|
+
return 2;
|
|
9810
|
+
}
|
|
9811
|
+
async function handlerTemplate(args) {
|
|
9812
|
+
const allowed = /* @__PURE__ */ new Set(["--list", "--output", "--out", "--stdout", "--force"]);
|
|
9813
|
+
assertKnownOptions(args, allowed, "handler template");
|
|
9814
|
+
if (args.includes("--list")) {
|
|
9815
|
+
process2.stdout.write(formatHandlerTemplateList());
|
|
9816
|
+
return 0;
|
|
9817
|
+
}
|
|
9818
|
+
const requested = positional(args, 0);
|
|
9819
|
+
if (!requested) throw new Error("handler template requires <node-fastify|python-fastapi|command>, or use --list");
|
|
9820
|
+
const name = resolveHandlerTemplateName(requested);
|
|
9821
|
+
const definition = handlerTemplateDefinitions[name];
|
|
9822
|
+
const content = definition.content;
|
|
9823
|
+
if (args.includes("--stdout")) {
|
|
9824
|
+
process2.stdout.write(content);
|
|
9825
|
+
return 0;
|
|
9826
|
+
}
|
|
9827
|
+
const output = outputArg(args) ?? definition.fileName;
|
|
9828
|
+
await writeHandlerTemplateFile(name, output, args.includes("--force"));
|
|
9829
|
+
process2.stdout.write(`created ${output}
|
|
9830
|
+
`);
|
|
9831
|
+
process2.stdout.write(`${handlerSecurityWarning}
|
|
9832
|
+
`);
|
|
9833
|
+
return 0;
|
|
9834
|
+
}
|
|
9835
|
+
async function writeHandlerTemplateFile(name, output, force) {
|
|
9836
|
+
const definition = handlerTemplateDefinitions[name];
|
|
9837
|
+
await writeFileGuarded(output, definition.content, force);
|
|
9838
|
+
if (name === "command" || output.endsWith(".mjs") || output.endsWith(".js")) {
|
|
9839
|
+
await fs3.chmod(path3.resolve(output), 493).catch(() => void 0);
|
|
9840
|
+
}
|
|
9841
|
+
}
|
|
9842
|
+
function formatHandlerTemplateList() {
|
|
9843
|
+
return [
|
|
9844
|
+
"Synapsor app-owned writeback handler templates",
|
|
9845
|
+
"",
|
|
9846
|
+
...Object.entries(handlerTemplateDefinitions).map(([name, definition]) => `- ${name}: ${definition.description}`),
|
|
9847
|
+
"",
|
|
9848
|
+
handlerSecurityWarning,
|
|
9849
|
+
"",
|
|
9850
|
+
"Examples:",
|
|
9851
|
+
` ${cliCommandName()} handler template node-fastify --output ./synapsor-writeback-handler.mjs`,
|
|
9852
|
+
` ${cliCommandName()} handler template python-fastapi --output ./synapsor_writeback_handler.py`,
|
|
9853
|
+
` ${cliCommandName()} handler template command --output ./synapsor-command-handler.mjs`,
|
|
9854
|
+
""
|
|
9855
|
+
].join("\n");
|
|
9856
|
+
}
|
|
9857
|
+
function resolveHandlerTemplateName(value) {
|
|
9858
|
+
const normalized = value.trim().toLowerCase();
|
|
9859
|
+
for (const [name, definition] of Object.entries(handlerTemplateDefinitions)) {
|
|
9860
|
+
if (normalized === name || definition.aliases.includes(normalized)) return name;
|
|
9861
|
+
}
|
|
9862
|
+
throw new Error(`unknown handler template: ${value}. Use ${cliCommandName()} handler template --list`);
|
|
9863
|
+
}
|
|
9864
|
+
async function writebackDoctor(args) {
|
|
9865
|
+
const configPath = optionalArg(args, "--config") ?? defaultConfigPath;
|
|
9866
|
+
const config = await readRuntimeConfig(configPath);
|
|
9867
|
+
const checkDb = args.includes("--check-db");
|
|
9868
|
+
const sqlSources = Object.entries(config.sources ?? {}).filter(([sourceName]) => sourceNeedsSqlWriteback(config, sourceName));
|
|
9869
|
+
const lines = [
|
|
9870
|
+
"Synapsor writeback doctor",
|
|
9871
|
+
`Config: ${configPath}`,
|
|
9872
|
+
""
|
|
9873
|
+
];
|
|
9874
|
+
if (sqlSources.length === 0) {
|
|
9875
|
+
lines.push("No direct SQL writeback sources found.", "Rich writes can use app-owned http_handler or command_handler executors without Runner creating receipt tables.", "");
|
|
9876
|
+
process2.stdout.write(lines.join("\n"));
|
|
9877
|
+
return 0;
|
|
9878
|
+
}
|
|
9879
|
+
let ok = true;
|
|
9880
|
+
for (const [sourceName, source] of sqlSources) {
|
|
9881
|
+
const writeEnv = source.write_url_env;
|
|
9882
|
+
const writeUrl = writeEnv ? process2.env[writeEnv] : void 0;
|
|
9883
|
+
lines.push(`Source: ${sourceName}`);
|
|
9884
|
+
lines.push(` engine: ${source.engine}`);
|
|
9885
|
+
lines.push(` writer env: ${writeEnv ?? "(missing write_url_env)"}`);
|
|
9886
|
+
lines.push(` env status: ${writeUrl ? "set" : "missing"}`);
|
|
9887
|
+
lines.push(" receipt table: synapsor_writeback_receipts");
|
|
9888
|
+
if (!writeEnv || !writeUrl) ok = false;
|
|
9889
|
+
if (checkDb && writeUrl) {
|
|
9890
|
+
const result = await adapters[source.engine].doctor({
|
|
9891
|
+
controlPlaneUrl: "local",
|
|
9892
|
+
runnerToken: "local",
|
|
9893
|
+
runnerId: "writeback-doctor",
|
|
9894
|
+
sourceId: sourceName,
|
|
9895
|
+
databaseUrl: writeUrl,
|
|
9896
|
+
engine: source.engine,
|
|
9897
|
+
pollIntervalMs: 0,
|
|
9898
|
+
logLevel: "error",
|
|
9899
|
+
dryRun: true,
|
|
9900
|
+
stateDir: "./state"
|
|
9901
|
+
});
|
|
9902
|
+
lines.push(` db check: ${result.ok ? "ok" : "failed"}`);
|
|
9903
|
+
lines.push(` details: ${JSON.stringify(redactConfig(result.details ?? {}))}`);
|
|
9904
|
+
if (!result.ok) ok = false;
|
|
9905
|
+
} else if (checkDb) {
|
|
9906
|
+
lines.push(" db check: skipped because writer env is missing");
|
|
9907
|
+
}
|
|
9908
|
+
lines.push("");
|
|
9909
|
+
}
|
|
9910
|
+
lines.push("Next:");
|
|
9911
|
+
lines.push(` ${cliCommandName()} writeback migration --engine postgres`);
|
|
9912
|
+
lines.push(` ${cliCommandName()} writeback grants --engine postgres --writer-role <writer_role>`);
|
|
9913
|
+
lines.push("");
|
|
9914
|
+
process2.stdout.write(lines.join("\n"));
|
|
9915
|
+
return ok ? 0 : 1;
|
|
9916
|
+
}
|
|
9917
|
+
async function writebackMigration(args) {
|
|
9918
|
+
const engine = requiredWritebackEngine(args);
|
|
9919
|
+
const schema = optionalArg(args, "--schema");
|
|
9920
|
+
if (engine === "postgres") {
|
|
9921
|
+
process2.stdout.write(formatPostgresReceiptMigration(schema));
|
|
9922
|
+
return 0;
|
|
9923
|
+
}
|
|
9924
|
+
process2.stdout.write(formatMysqlReceiptMigration(schema));
|
|
9925
|
+
return 0;
|
|
9926
|
+
}
|
|
9927
|
+
async function writebackGrants(args) {
|
|
9928
|
+
const engine = requiredWritebackEngine(args);
|
|
9929
|
+
const writerRole = optionalArg(args, "--writer-role") ?? "<writer_role>";
|
|
9930
|
+
const schema = optionalArg(args, "--schema") ?? (engine === "postgres" ? "public" : "<database_name>");
|
|
9931
|
+
if (engine === "postgres") {
|
|
9932
|
+
process2.stdout.write(formatPostgresReceiptGrants(schema, writerRole));
|
|
9933
|
+
return 0;
|
|
9934
|
+
}
|
|
9935
|
+
process2.stdout.write(formatMysqlReceiptGrants(schema, writerRole));
|
|
9936
|
+
return 0;
|
|
9937
|
+
}
|
|
8053
9938
|
async function onboard(args) {
|
|
8054
9939
|
const [subcommand, ...rest] = args;
|
|
8055
9940
|
if (subcommand !== "db") {
|
|
@@ -8060,8 +9945,10 @@ async function onboard(args) {
|
|
|
8060
9945
|
process2.stdout.write("You will inspect metadata, choose one table/view, confirm safety rules, and generate semantic MCP tools without writing JSON by hand.\n\n");
|
|
8061
9946
|
const outputPath = outputArg(rest) ?? "synapsor.runner.json";
|
|
8062
9947
|
const storePath = optionalArg(rest, "--store") ?? "./.synapsor/local.db";
|
|
8063
|
-
const
|
|
9948
|
+
const scripted = isScriptedOnboardingArgs(rest);
|
|
9949
|
+
const result = scripted ? await init(["--non-interactive", ...rest]) : await runInitWizard(["--wizard", ...rest]);
|
|
8064
9950
|
if (result !== 0) return result;
|
|
9951
|
+
if (rest.includes("--dry-run")) return 0;
|
|
8065
9952
|
process2.stdout.write("\nValidation:\n");
|
|
8066
9953
|
const configCode = await configValidate(["--config", outputPath]);
|
|
8067
9954
|
const smokeCode = await mcpSmoke(["--config", outputPath, "--store", storePath]);
|
|
@@ -8075,7 +9962,7 @@ async function onboard(args) {
|
|
|
8075
9962
|
${cliCommandName()} mcp serve --config ${outputPath} --store ${storePath}
|
|
8076
9963
|
`);
|
|
8077
9964
|
process2.stdout.write(`2. Open local UI:
|
|
8078
|
-
${cliCommandName()} ui --tour --config ${outputPath} --store ${storePath}
|
|
9965
|
+
${cliCommandName()} ui --open --tour --config ${outputPath} --store ${storePath}
|
|
8079
9966
|
`);
|
|
8080
9967
|
process2.stdout.write("3. Approve/apply only after setting a trusted write credential and reviewing the proposal.\n");
|
|
8081
9968
|
return configCode === 0 && smokeCode === 0 ? 0 : 1;
|
|
@@ -8173,7 +10060,7 @@ function formatQuickDemoDetails(seeded) {
|
|
|
8173
10060
|
quickDemoStorePath,
|
|
8174
10061
|
"",
|
|
8175
10062
|
"If you ran this through one-off npx and did not install the package, prefix",
|
|
8176
|
-
"follow-up commands with: npx -y -p @synapsor/runner
|
|
10063
|
+
"follow-up commands with: npx -y -p @synapsor/runner synapsor-runner",
|
|
8177
10064
|
"",
|
|
8178
10065
|
"Raw MCP shape:",
|
|
8179
10066
|
"execute_sql(sql: string)",
|
|
@@ -8299,7 +10186,7 @@ function quickDemoGuidedScreens(seeded) {
|
|
|
8299
10186
|
body: [
|
|
8300
10187
|
"Run this next:",
|
|
8301
10188
|
"",
|
|
8302
|
-
"npx -y -p @synapsor/runner
|
|
10189
|
+
"npx -y -p @synapsor/runner synapsor-runner demo inspect",
|
|
8303
10190
|
"",
|
|
8304
10191
|
"demo inspect shows the proposal, evidence, activity search, and replay commands.",
|
|
8305
10192
|
"",
|
|
@@ -8318,7 +10205,7 @@ function quickDemoGuidedScreens(seeded) {
|
|
|
8318
10205
|
"",
|
|
8319
10206
|
"Use your own staging DB:",
|
|
8320
10207
|
'export DATABASE_URL="postgres://..."',
|
|
8321
|
-
`${cliCommandName()}
|
|
10208
|
+
`${cliCommandName()} onboard db --from-env DATABASE_URL`,
|
|
8322
10209
|
"",
|
|
8323
10210
|
"Done. You just saw Synapsor's core boundary: business tools for the model, approval/writeback outside the model, and replay for inspection."
|
|
8324
10211
|
]
|
|
@@ -8351,7 +10238,7 @@ async function waitForEnter(message, options) {
|
|
|
8351
10238
|
}
|
|
8352
10239
|
}
|
|
8353
10240
|
function quickDemoInspectCommands(useNpx) {
|
|
8354
|
-
const cmd = useNpx ? "npx -y -p @synapsor/runner
|
|
10241
|
+
const cmd = useNpx ? "npx -y -p @synapsor/runner synapsor-runner" : cliCommandName();
|
|
8355
10242
|
return [
|
|
8356
10243
|
{
|
|
8357
10244
|
label: "Proposal summary",
|
|
@@ -8556,50 +10443,229 @@ function quickDemoChangeSet() {
|
|
|
8556
10443
|
};
|
|
8557
10444
|
}
|
|
8558
10445
|
async function mcpServe(args) {
|
|
10446
|
+
const transport = optionalArg(args, "--transport") ?? "stdio";
|
|
10447
|
+
if (transport === "streamable-http") return mcpServeStreamableHttp(args);
|
|
10448
|
+
if (transport === "http" || transport === "json-rpc-http" || transport === "jsonrpc-http") return mcpServeHttp(args);
|
|
10449
|
+
if (transport !== "stdio") throw new Error("--transport must be stdio, streamable-http, or http");
|
|
8559
10450
|
const configPath = optionalArg(args, "--config") ?? process2.env.SYNAPSOR_MCP_CONFIG;
|
|
8560
10451
|
const readOnly = args.includes("--read-only");
|
|
8561
10452
|
const config = readOnly ? { ...await readRuntimeConfig(configPath ?? defaultConfigPath), mode: "read_only" } : void 0;
|
|
8562
|
-
|
|
8563
|
-
|
|
8564
|
-
|
|
8565
|
-
|
|
8566
|
-
|
|
8567
|
-
|
|
10453
|
+
const toolNameStyle = toolNameStyleOption(args);
|
|
10454
|
+
const resultFormat = resultFormatOption(args);
|
|
10455
|
+
const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE;
|
|
10456
|
+
const releaseLease = await writeStoreLease(storePath, "mcp", "stdio", args.includes("--allow-concurrent-store"));
|
|
10457
|
+
try {
|
|
10458
|
+
await serveStdio({
|
|
10459
|
+
configPath,
|
|
10460
|
+
storePath,
|
|
10461
|
+
config,
|
|
10462
|
+
toolNameStyle,
|
|
10463
|
+
resultFormat
|
|
10464
|
+
});
|
|
10465
|
+
return 0;
|
|
10466
|
+
} finally {
|
|
10467
|
+
await releaseLease();
|
|
10468
|
+
}
|
|
8568
10469
|
}
|
|
8569
10470
|
async function mcpServeHttp(args) {
|
|
10471
|
+
process2.stderr.write([
|
|
10472
|
+
"Warning: mcp serve-http is a legacy JSON-RPC bridge, not spec MCP Streamable HTTP.",
|
|
10473
|
+
`For OpenAI Agents SDK or standard HTTP MCP clients, use: ${cliCommandName()} mcp serve --transport streamable-http`,
|
|
10474
|
+
""
|
|
10475
|
+
].join("\n"));
|
|
8570
10476
|
const configPath = optionalArg(args, "--config") ?? process2.env.SYNAPSOR_MCP_CONFIG;
|
|
8571
10477
|
const readOnly = args.includes("--read-only");
|
|
8572
10478
|
const config = readOnly ? { ...await readRuntimeConfig(configPath ?? defaultConfigPath), mode: "read_only" } : void 0;
|
|
8573
10479
|
const host = optionalArg(args, "--host") ?? "127.0.0.1";
|
|
8574
10480
|
const port = Number(optionalArg(args, "--port") ?? "8765");
|
|
10481
|
+
const resultFormat = resultFormatOption(args);
|
|
8575
10482
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
8576
10483
|
throw new Error("--port must be an integer from 1 to 65535");
|
|
8577
10484
|
}
|
|
8578
10485
|
if (host === "0.0.0.0") {
|
|
8579
10486
|
process2.stderr.write("Warning: binding Synapsor Runner HTTP MCP to 0.0.0.0 exposes model-facing tools on the network. Use TLS, private networking, authentication, and rate limits.\n");
|
|
8580
10487
|
}
|
|
8581
|
-
const
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
|
|
8585
|
-
|
|
8586
|
-
|
|
8587
|
-
|
|
8588
|
-
|
|
8589
|
-
|
|
10488
|
+
const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE;
|
|
10489
|
+
const releaseLease = await writeStoreLease(storePath, "mcp", "legacy-jsonrpc", args.includes("--allow-concurrent-store"));
|
|
10490
|
+
let server;
|
|
10491
|
+
try {
|
|
10492
|
+
server = await startHttpMcpServer({
|
|
10493
|
+
configPath,
|
|
10494
|
+
config,
|
|
10495
|
+
storePath,
|
|
10496
|
+
host,
|
|
10497
|
+
port,
|
|
10498
|
+
authTokenEnv: optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
|
|
10499
|
+
devNoAuth: args.includes("--dev-no-auth"),
|
|
10500
|
+
corsOrigin: optionalArg(args, "--cors-origin"),
|
|
10501
|
+
resultFormat
|
|
10502
|
+
});
|
|
10503
|
+
} catch (error) {
|
|
10504
|
+
await releaseLease();
|
|
10505
|
+
throw error;
|
|
10506
|
+
}
|
|
10507
|
+
process2.stderr.write("Press Ctrl+C to stop.\n");
|
|
10508
|
+
await new Promise((resolve) => {
|
|
10509
|
+
const stop = async () => {
|
|
10510
|
+
process2.off("SIGINT", stop);
|
|
10511
|
+
process2.off("SIGTERM", stop);
|
|
10512
|
+
await server.close();
|
|
10513
|
+
await releaseLease();
|
|
10514
|
+
resolve();
|
|
10515
|
+
};
|
|
10516
|
+
process2.once("SIGINT", stop);
|
|
10517
|
+
process2.once("SIGTERM", stop);
|
|
8590
10518
|
});
|
|
10519
|
+
return 0;
|
|
10520
|
+
}
|
|
10521
|
+
async function mcpServeStreamableHttp(args) {
|
|
10522
|
+
const configPath = optionalArg(args, "--config") ?? process2.env.SYNAPSOR_MCP_CONFIG;
|
|
10523
|
+
const readOnly = args.includes("--read-only");
|
|
10524
|
+
const config = readOnly ? { ...await readRuntimeConfig(configPath ?? defaultConfigPath), mode: "read_only" } : void 0;
|
|
10525
|
+
const toolNameStyle = toolNameStyleOption(args);
|
|
10526
|
+
const resultFormat = resultFormatOption(args);
|
|
10527
|
+
const host = optionalArg(args, "--host") ?? "127.0.0.1";
|
|
10528
|
+
const port = Number(optionalArg(args, "--port") ?? "8766");
|
|
10529
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
10530
|
+
throw new Error("--port must be an integer from 1 to 65535");
|
|
10531
|
+
}
|
|
10532
|
+
if (host === "0.0.0.0") {
|
|
10533
|
+
process2.stderr.write("Warning: binding Synapsor Runner Streamable HTTP MCP to 0.0.0.0 exposes model-facing tools on the network. Use TLS, private networking, authentication, and rate limits.\n");
|
|
10534
|
+
}
|
|
10535
|
+
const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE;
|
|
10536
|
+
const releaseLease = await writeStoreLease(storePath, "mcp", "streamable-http", args.includes("--allow-concurrent-store"));
|
|
10537
|
+
let server;
|
|
10538
|
+
try {
|
|
10539
|
+
server = await startStreamableHttpMcpServer({
|
|
10540
|
+
configPath,
|
|
10541
|
+
config,
|
|
10542
|
+
storePath,
|
|
10543
|
+
host,
|
|
10544
|
+
port,
|
|
10545
|
+
toolNameStyle,
|
|
10546
|
+
authTokenEnv: optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
|
|
10547
|
+
devNoAuth: args.includes("--dev-no-auth"),
|
|
10548
|
+
corsOrigin: optionalArg(args, "--cors-origin"),
|
|
10549
|
+
resultFormat
|
|
10550
|
+
});
|
|
10551
|
+
} catch (error) {
|
|
10552
|
+
await releaseLease();
|
|
10553
|
+
throw error;
|
|
10554
|
+
}
|
|
8591
10555
|
process2.stderr.write("Press Ctrl+C to stop.\n");
|
|
8592
10556
|
await new Promise((resolve) => {
|
|
8593
10557
|
const stop = async () => {
|
|
8594
10558
|
process2.off("SIGINT", stop);
|
|
8595
10559
|
process2.off("SIGTERM", stop);
|
|
8596
10560
|
await server.close();
|
|
10561
|
+
await releaseLease();
|
|
8597
10562
|
resolve();
|
|
8598
10563
|
};
|
|
8599
|
-
process2.once("SIGINT", stop);
|
|
8600
|
-
process2.once("SIGTERM", stop);
|
|
8601
|
-
});
|
|
8602
|
-
return 0;
|
|
10564
|
+
process2.once("SIGINT", stop);
|
|
10565
|
+
process2.once("SIGTERM", stop);
|
|
10566
|
+
});
|
|
10567
|
+
return 0;
|
|
10568
|
+
}
|
|
10569
|
+
async function writeStoreLease(storePath, mode, transport, allowConcurrent) {
|
|
10570
|
+
const resolved = resolveStorePathForLease(storePath);
|
|
10571
|
+
if (!resolved) return async () => void 0;
|
|
10572
|
+
await assertNoActiveStoreLease(resolved, allowConcurrent, "serve");
|
|
10573
|
+
const leasePath = storeLeasePath(resolved);
|
|
10574
|
+
const lease = {
|
|
10575
|
+
pid: process2.pid,
|
|
10576
|
+
mode,
|
|
10577
|
+
transport,
|
|
10578
|
+
store_path: resolved,
|
|
10579
|
+
started_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
10580
|
+
};
|
|
10581
|
+
await fs3.mkdir(path3.dirname(resolved), { recursive: true });
|
|
10582
|
+
await fs3.writeFile(leasePath, `${JSON.stringify(lease, null, 2)}
|
|
10583
|
+
`, "utf8");
|
|
10584
|
+
return async () => {
|
|
10585
|
+
const current = await readStoreLease(resolved);
|
|
10586
|
+
if (current?.pid === process2.pid && current.transport === transport) {
|
|
10587
|
+
await fs3.rm(leasePath, { force: true });
|
|
10588
|
+
}
|
|
10589
|
+
};
|
|
10590
|
+
}
|
|
10591
|
+
async function assertNoActiveStoreLease(storePath, force, operation) {
|
|
10592
|
+
const resolved = resolveStorePathForLease(storePath);
|
|
10593
|
+
if (!resolved) return;
|
|
10594
|
+
const lease = await readStoreLease(resolved);
|
|
10595
|
+
if (!lease) return;
|
|
10596
|
+
if (!pidIsActive(lease.pid)) {
|
|
10597
|
+
await fs3.rm(storeLeasePath(resolved), { force: true });
|
|
10598
|
+
return;
|
|
10599
|
+
}
|
|
10600
|
+
const message = `Local store appears active for ${lease.mode}/${lease.transport} (pid ${lease.pid}, started ${lease.started_at}). Refusing ${operation}. Stop the server or rerun with --allow-concurrent-store/--force if you have verified it is safe.`;
|
|
10601
|
+
if (!force) throw new Error(message);
|
|
10602
|
+
process2.stderr.write(`Warning: ${message}
|
|
10603
|
+
`);
|
|
10604
|
+
}
|
|
10605
|
+
function resolveStorePathForLease(storePath) {
|
|
10606
|
+
const value = storePath ?? process2.env.SYNAPSOR_LOCAL_STORE ?? "./.synapsor/local.db";
|
|
10607
|
+
if (value === ":memory:") return void 0;
|
|
10608
|
+
return path3.resolve(value);
|
|
10609
|
+
}
|
|
10610
|
+
function storeLeasePath(resolvedStorePath) {
|
|
10611
|
+
return `${resolvedStorePath}.lease.json`;
|
|
10612
|
+
}
|
|
10613
|
+
async function readStoreLease(storePath) {
|
|
10614
|
+
const resolved = resolveStorePathForLease(storePath);
|
|
10615
|
+
if (!resolved) return void 0;
|
|
10616
|
+
try {
|
|
10617
|
+
const parsed = JSON.parse(await fs3.readFile(storeLeasePath(resolved), "utf8"));
|
|
10618
|
+
if (typeof parsed.pid !== "number" || typeof parsed.mode !== "string" || typeof parsed.transport !== "string" || typeof parsed.started_at !== "string") {
|
|
10619
|
+
return void 0;
|
|
10620
|
+
}
|
|
10621
|
+
return {
|
|
10622
|
+
pid: parsed.pid,
|
|
10623
|
+
mode: parsed.mode,
|
|
10624
|
+
transport: parsed.transport,
|
|
10625
|
+
store_path: typeof parsed.store_path === "string" ? parsed.store_path : resolved,
|
|
10626
|
+
started_at: parsed.started_at
|
|
10627
|
+
};
|
|
10628
|
+
} catch (error) {
|
|
10629
|
+
if (error.code === "ENOENT") return void 0;
|
|
10630
|
+
return void 0;
|
|
10631
|
+
}
|
|
10632
|
+
}
|
|
10633
|
+
function pidIsActive(pid) {
|
|
10634
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
10635
|
+
try {
|
|
10636
|
+
process2.kill(pid, 0);
|
|
10637
|
+
return true;
|
|
10638
|
+
} catch (error) {
|
|
10639
|
+
return error.code === "EPERM";
|
|
10640
|
+
}
|
|
10641
|
+
}
|
|
10642
|
+
function toolNameStyleOption(args) {
|
|
10643
|
+
const requestedStyle = optionalArg(args, "--tool-name-style");
|
|
10644
|
+
const requestedAliasMode = optionalArg(args, "--alias-mode");
|
|
10645
|
+
if (requestedStyle && requestedAliasMode && requestedStyle !== requestedAliasMode) {
|
|
10646
|
+
throw new Error("--tool-name-style and --alias-mode must match when both are provided");
|
|
10647
|
+
}
|
|
10648
|
+
const requested = requestedAliasMode ?? requestedStyle;
|
|
10649
|
+
if (args.includes("--openai-tool-aliases")) {
|
|
10650
|
+
if (requested && requested !== "openai") throw new Error("--openai-tool-aliases cannot be combined with a non-openai alias mode");
|
|
10651
|
+
return "openai";
|
|
10652
|
+
}
|
|
10653
|
+
if (!requested) return "canonical";
|
|
10654
|
+
if (requested === "canonical" || requested === "openai" || requested === "both") return requested;
|
|
10655
|
+
throw new Error("--alias-mode must be canonical, openai, or both");
|
|
10656
|
+
}
|
|
10657
|
+
function resultFormatOption(args) {
|
|
10658
|
+
const requested = optionalArg(args, "--result-format");
|
|
10659
|
+
if (!requested) return void 0;
|
|
10660
|
+
if (requested === "1" || requested === "v1") return 1;
|
|
10661
|
+
if (requested === "2" || requested === "v2") return 2;
|
|
10662
|
+
throw new Error("--result-format must be v1, 1, v2, or 2");
|
|
10663
|
+
}
|
|
10664
|
+
function normalizeResultFormatAnswer(value) {
|
|
10665
|
+
if (value === "1" || value === "v1") return "v1";
|
|
10666
|
+
if (value === "2" || value === "v2") return "v2";
|
|
10667
|
+
if (value === "default") return "default";
|
|
10668
|
+
throw new Error("--result-format must be default, v1, 1, v2, or 2");
|
|
8603
10669
|
}
|
|
8604
10670
|
async function mcpAudit(args) {
|
|
8605
10671
|
const format = optionalArg(args, "--format") ?? (args.includes("--json") ? "json" : "text");
|
|
@@ -8743,21 +10809,33 @@ function formatProposeResult(capabilityName, result, storePath) {
|
|
|
8743
10809
|
`;
|
|
8744
10810
|
}
|
|
8745
10811
|
async function mcpConfigure(args) {
|
|
8746
|
-
const client = optionalArg(args, "--client");
|
|
8747
|
-
if (!client) throw new Error("mcp configure requires --client generic-stdio|claude-desktop|cursor|vscode");
|
|
10812
|
+
const client = normalizeMcpClientName(optionalArg(args, "--client"));
|
|
10813
|
+
if (!client) throw new Error("mcp configure requires --client generic-stdio|claude|claude-desktop|cursor|vscode|openai-agents");
|
|
8748
10814
|
const useAbsolutePaths = args.includes("--absolute-paths");
|
|
8749
10815
|
const rawConfigPath = optionalArg(args, "--config") ?? "./synapsor.runner.json";
|
|
8750
10816
|
const rawStorePath = optionalArg(args, "--store") ?? "./.synapsor/local.db";
|
|
8751
10817
|
const configPath = useAbsolutePaths ? path3.resolve(rawConfigPath) : rawConfigPath;
|
|
8752
10818
|
const storePath = useAbsolutePaths ? path3.resolve(rawStorePath) : rawStorePath;
|
|
10819
|
+
const transport = mcpClientConfigTransport(args, client);
|
|
10820
|
+
const aliasMode = mcpClientConfigAliasMode(args, client);
|
|
10821
|
+
const includeInstructions = args.includes("--include-instructions");
|
|
10822
|
+
const host = optionalArg(args, "--host") ?? "127.0.0.1";
|
|
10823
|
+
const port = Number(optionalArg(args, "--port") ?? "8766");
|
|
10824
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
10825
|
+
throw new Error("--port must be an integer from 1 to 65535");
|
|
10826
|
+
}
|
|
10827
|
+
const authTokenEnv = optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN";
|
|
8753
10828
|
if (!await fileExists(rawConfigPath)) {
|
|
8754
10829
|
process2.stderr.write(`Warning: config path does not exist yet: ${rawConfigPath}
|
|
8755
10830
|
`);
|
|
8756
10831
|
}
|
|
8757
|
-
if (!path3.isAbsolute(configPath) || !path3.isAbsolute(storePath)) {
|
|
10832
|
+
if (transport === "stdio" && (!path3.isAbsolute(configPath) || !path3.isAbsolute(storePath))) {
|
|
8758
10833
|
process2.stderr.write("Warning: relative paths are resolved by the MCP client working directory. Use --absolute-paths if the client runs from another directory.\n");
|
|
8759
10834
|
}
|
|
8760
|
-
const snippet = mcpClientSnippet(client, configPath, storePath);
|
|
10835
|
+
const snippet = mcpClientSnippet(client, configPath, storePath, { transport, aliasMode, host, port, authTokenEnv });
|
|
10836
|
+
if (includeInstructions) {
|
|
10837
|
+
snippet.agent_instructions = mcpAgentInstructions(client, aliasMode);
|
|
10838
|
+
}
|
|
8761
10839
|
if (args.includes("--write")) {
|
|
8762
10840
|
const destination = optionalArg(args, "--destination");
|
|
8763
10841
|
if (!destination) throw new Error("mcp configure --write requires --destination <path>");
|
|
@@ -8775,20 +10853,124 @@ async function mcpConfigure(args) {
|
|
|
8775
10853
|
async function mcpConfig(args) {
|
|
8776
10854
|
const [client, ...rest] = args;
|
|
8777
10855
|
if (!client || client.startsWith("--")) return mcpConfigure(["--client", "claude-desktop", ...args]);
|
|
8778
|
-
return mcpConfigure(["--client", client, ...rest]);
|
|
10856
|
+
return mcpConfigure(["--client", normalizeMcpClientName(client) ?? client, ...rest]);
|
|
10857
|
+
}
|
|
10858
|
+
function normalizeMcpClientName(client) {
|
|
10859
|
+
if (client === "claude") return "claude-desktop";
|
|
10860
|
+
return client;
|
|
10861
|
+
}
|
|
10862
|
+
function mcpClientConfigTransport(args, client) {
|
|
10863
|
+
const requested = optionalArg(args, "--transport") ?? (client === "openai-agents" ? "streamable-http" : "stdio");
|
|
10864
|
+
if (requested === "stdio" || requested === "streamable-http") return requested;
|
|
10865
|
+
if (requested === "http" || requested === "json-rpc-http" || requested === "jsonrpc-http") {
|
|
10866
|
+
throw new Error("mcp config uses stdio or streamable-http. The lightweight JSON-RPC HTTP bridge is not a standard MCP client transport.");
|
|
10867
|
+
}
|
|
10868
|
+
throw new Error("--transport must be stdio or streamable-http");
|
|
10869
|
+
}
|
|
10870
|
+
function mcpClientConfigAliasMode(args, client) {
|
|
10871
|
+
const requested = optionalArg(args, "--alias-mode");
|
|
10872
|
+
const aliasMode = requested ?? (args.includes("--openai-tool-aliases") ? "openai" : client === "openai-agents" ? "openai" : "canonical");
|
|
10873
|
+
if (aliasMode === "canonical" || aliasMode === "openai" || aliasMode === "both") return aliasMode;
|
|
10874
|
+
throw new Error("--alias-mode must be canonical, openai, or both");
|
|
10875
|
+
}
|
|
10876
|
+
function serveArgsForClient(configPath, storePath, options) {
|
|
10877
|
+
const args = options.transport === "streamable-http" ? [
|
|
10878
|
+
"mcp",
|
|
10879
|
+
"serve-streamable-http",
|
|
10880
|
+
"--config",
|
|
10881
|
+
configPath,
|
|
10882
|
+
"--store",
|
|
10883
|
+
storePath,
|
|
10884
|
+
"--host",
|
|
10885
|
+
options.host,
|
|
10886
|
+
"--port",
|
|
10887
|
+
String(options.port),
|
|
10888
|
+
"--auth-token-env",
|
|
10889
|
+
options.authTokenEnv
|
|
10890
|
+
] : ["mcp", "serve", "--config", configPath, "--store", storePath];
|
|
10891
|
+
if (options.aliasMode !== "canonical") args.push("--alias-mode", options.aliasMode);
|
|
10892
|
+
return args;
|
|
8779
10893
|
}
|
|
8780
|
-
function mcpClientSnippet(client, configPath, storePath) {
|
|
8781
|
-
const command =
|
|
8782
|
-
const args =
|
|
10894
|
+
function mcpClientSnippet(client, configPath, storePath, options) {
|
|
10895
|
+
const command = cliCommandName();
|
|
10896
|
+
const args = serveArgsForClient(configPath, storePath, options);
|
|
8783
10897
|
if (client === "generic" || client === "generic-stdio") return { command, args };
|
|
8784
10898
|
if (client === "claude-desktop" || client === "cursor") {
|
|
10899
|
+
if (options.transport !== "stdio") throw new Error(`${client} config output currently supports stdio. Use --transport stdio.`);
|
|
8785
10900
|
return { mcpServers: { synapsor: { command, args } } };
|
|
8786
10901
|
}
|
|
8787
10902
|
if (client === "vscode") {
|
|
10903
|
+
if (options.transport !== "stdio") throw new Error("vscode config output currently supports stdio. Use --transport stdio.");
|
|
8788
10904
|
return { servers: { synapsor: { type: "stdio", command, args } } };
|
|
8789
10905
|
}
|
|
10906
|
+
if (client === "openai-agents") {
|
|
10907
|
+
if (options.transport !== "streamable-http") throw new Error("openai-agents config output uses Streamable HTTP. Use --transport streamable-http.");
|
|
10908
|
+
const url = `http://${options.host}:${options.port}/mcp`;
|
|
10909
|
+
return {
|
|
10910
|
+
transport: "streamable-http",
|
|
10911
|
+
start_server: {
|
|
10912
|
+
command,
|
|
10913
|
+
args,
|
|
10914
|
+
env: {
|
|
10915
|
+
[options.authTokenEnv]: "<set-a-random-local-token>"
|
|
10916
|
+
}
|
|
10917
|
+
},
|
|
10918
|
+
openai_agents_sdk: {
|
|
10919
|
+
package: "openai-agents",
|
|
10920
|
+
url,
|
|
10921
|
+
headers_from_env: {
|
|
10922
|
+
Authorization: `Bearer $${options.authTokenEnv}`
|
|
10923
|
+
},
|
|
10924
|
+
python: [
|
|
10925
|
+
"import os",
|
|
10926
|
+
"from agents.mcp import MCPServerStreamableHttp",
|
|
10927
|
+
"",
|
|
10928
|
+
"synapsor_mcp = MCPServerStreamableHttp(",
|
|
10929
|
+
` params={`,
|
|
10930
|
+
` "url": "${url}",`,
|
|
10931
|
+
` "headers": {"Authorization": f"Bearer {os.environ['${options.authTokenEnv}']}"},`,
|
|
10932
|
+
" }",
|
|
10933
|
+
")"
|
|
10934
|
+
].join("\n")
|
|
10935
|
+
},
|
|
10936
|
+
tool_names: {
|
|
10937
|
+
canonical: "billing.inspect_invoice",
|
|
10938
|
+
model_visible_with_alias_mode_openai: "billing__inspect_invoice",
|
|
10939
|
+
alias_mode: options.aliasMode
|
|
10940
|
+
},
|
|
10941
|
+
notes: [
|
|
10942
|
+
"Start the local Streamable HTTP MCP server before creating the OpenAI Agents SDK server.",
|
|
10943
|
+
"OpenAI-facing configs should use --alias-mode openai because OpenAI function names cannot contain dots.",
|
|
10944
|
+
"Runner maps aliases back to canonical Synapsor capability names and includes the canonical name in MCP tool metadata.",
|
|
10945
|
+
"This config contains no database URLs, write credentials, API keys, or bearer token values."
|
|
10946
|
+
]
|
|
10947
|
+
};
|
|
10948
|
+
}
|
|
8790
10949
|
throw new Error(`unsupported MCP client: ${client}`);
|
|
8791
10950
|
}
|
|
10951
|
+
function mcpAgentInstructions(client, aliasMode) {
|
|
10952
|
+
const toolNameNote = aliasMode === "openai" ? "OpenAI-facing tool names may use aliases such as billing__inspect_invoice. Treat the canonical Synapsor capability name in tool metadata/results as the audit name." : "Use the model-visible Synapsor tool names exactly as listed by the MCP client.";
|
|
10953
|
+
return {
|
|
10954
|
+
target_client: client,
|
|
10955
|
+
alias_mode: aliasMode,
|
|
10956
|
+
recommended_system_prompt: [
|
|
10957
|
+
"Use Synapsor Runner tools in a propose-first pattern.",
|
|
10958
|
+
"Inspect relevant records, policy rows, and other evidence before proposing a change.",
|
|
10959
|
+
"Do not claim a database change was committed unless a result says source_database_changed: true.",
|
|
10960
|
+
"Proposal tools create reviewable proposals only; they do not commit writes.",
|
|
10961
|
+
"You cannot approve, apply, commit, or write back through model-facing MCP tools.",
|
|
10962
|
+
"On VERSION_CONFLICT, re-inspect the record before proposing again.",
|
|
10963
|
+
"Evidence handles are audit/replay handles; you do not need to call them during the turn.",
|
|
10964
|
+
toolNameNote
|
|
10965
|
+
].join(" "),
|
|
10966
|
+
checklist: [
|
|
10967
|
+
"Inspect evidence before proposing.",
|
|
10968
|
+
"Use trusted session scope; never ask the user/model for tenant or principal values.",
|
|
10969
|
+
"Report proposal ids and source_database_changed exactly from the tool result.",
|
|
10970
|
+
"If ok is false, follow error.code. On TEMPORARILY_UNAVAILABLE, retry later. On NOT_FOUND_IN_TENANT, do not infer cross-tenant existence."
|
|
10971
|
+
]
|
|
10972
|
+
};
|
|
10973
|
+
}
|
|
8792
10974
|
async function mcpSmoke(args) {
|
|
8793
10975
|
const boundary = await inspectMcpToolBoundary(args);
|
|
8794
10976
|
if (args.includes("--json")) {
|
|
@@ -8799,6 +10981,35 @@ async function mcpSmoke(args) {
|
|
|
8799
10981
|
}
|
|
8800
10982
|
return boundary.ok ? 0 : 1;
|
|
8801
10983
|
}
|
|
10984
|
+
async function smokeCall(args) {
|
|
10985
|
+
const configPath = optionalArg(args, "--config") ?? defaultConfigPath;
|
|
10986
|
+
const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE ?? defaultStorePath;
|
|
10987
|
+
const config = await readRuntimeConfig(configPath);
|
|
10988
|
+
const env = envWithDemoDefaults(config, configPath);
|
|
10989
|
+
const store = new ProposalStore(storePath);
|
|
10990
|
+
const runtime = createMcpRuntime(config, { store, env });
|
|
10991
|
+
try {
|
|
10992
|
+
const tools2 = runtime.listTools();
|
|
10993
|
+
const requestedTool = firstPositional(args);
|
|
10994
|
+
const toolName = requestedTool ?? (tools2.length === 1 ? tools2[0]?.name : void 0);
|
|
10995
|
+
if (!toolName) {
|
|
10996
|
+
throw new Error(`smoke call needs <capability-name> because ${tools2.length} tools are exposed: ${tools2.map((tool) => tool.name).join(", ") || "none"}`);
|
|
10997
|
+
}
|
|
10998
|
+
const capability = (config.capabilities ?? []).find((item) => item.name === toolName);
|
|
10999
|
+
if (!capability && config.mode !== "cloud") throw new Error(`capability not found in ${configPath}: ${toolName}`);
|
|
11000
|
+
const input = capability ? await smokeToolInput(args, capability) : await smokeInputFromArgs(args);
|
|
11001
|
+
const result = await runtime.callTool(toolName, input);
|
|
11002
|
+
if (args.includes("--json")) {
|
|
11003
|
+
process2.stdout.write(`${JSON.stringify({ ok: true, tool: toolName, input, result, store_path: storePath }, null, 2)}
|
|
11004
|
+
`);
|
|
11005
|
+
} else {
|
|
11006
|
+
process2.stdout.write(formatSmokeCallResult(toolName, input, result, storePath));
|
|
11007
|
+
}
|
|
11008
|
+
return 0;
|
|
11009
|
+
} finally {
|
|
11010
|
+
runtime.close();
|
|
11011
|
+
}
|
|
11012
|
+
}
|
|
8802
11013
|
async function toolsPreview(args) {
|
|
8803
11014
|
const boundary = await inspectMcpToolBoundary(args);
|
|
8804
11015
|
if (args.includes("--json")) {
|
|
@@ -8806,7 +11017,9 @@ async function toolsPreview(args) {
|
|
|
8806
11017
|
ok: boundary.ok,
|
|
8807
11018
|
config_path: boundary.configPath,
|
|
8808
11019
|
store_path: boundary.storePath,
|
|
11020
|
+
alias_mode: boundary.aliasMode,
|
|
8809
11021
|
exposed_to_mcp: boundary.names,
|
|
11022
|
+
alias_mappings: boundary.exposures,
|
|
8810
11023
|
not_exposed_to_mcp: defaultBlockedToolSurface(),
|
|
8811
11024
|
checks: boundary.checks
|
|
8812
11025
|
}, null, 2)}
|
|
@@ -8819,6 +11032,7 @@ async function toolsPreview(args) {
|
|
|
8819
11032
|
async function inspectMcpToolBoundary(args) {
|
|
8820
11033
|
const configPath = optionalArg(args, "--config") ?? "./synapsor.runner.json";
|
|
8821
11034
|
const storePath = optionalArg(args, "--store") ?? "./.synapsor/local.db";
|
|
11035
|
+
const aliasMode = args.includes("--aliases") && !optionalArg(args, "--alias-mode") && !optionalArg(args, "--tool-name-style") ? "both" : toolNameStyleOption(args);
|
|
8822
11036
|
if (!await fileExists(configPath)) {
|
|
8823
11037
|
throw new Error(`MCP tool preview could not find ${configPath}.
|
|
8824
11038
|
|
|
@@ -8826,13 +11040,14 @@ Why it matters:
|
|
|
8826
11040
|
The MCP server needs a reviewed config before it can expose semantic tools.
|
|
8827
11041
|
|
|
8828
11042
|
Fix:
|
|
8829
|
-
Run ${cliCommandName()}
|
|
11043
|
+
Run ${cliCommandName()} onboard db --from-env DATABASE_URL, or pass --config <path>.`);
|
|
8830
11044
|
}
|
|
8831
11045
|
const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
|
|
8832
11046
|
const runtime = createMcpRuntime(parsed, { storePath });
|
|
8833
11047
|
try {
|
|
8834
11048
|
const tools2 = runtime.listTools();
|
|
8835
|
-
const
|
|
11049
|
+
const exposures = toolNameExposures(tools2.map((tool) => tool.name), aliasMode);
|
|
11050
|
+
const names = exposures.map((item) => item.exposedName);
|
|
8836
11051
|
const serialized = JSON.stringify(tools2);
|
|
8837
11052
|
const checks = [
|
|
8838
11053
|
{ name: "semantic tools present", ok: names.length > 0, detail: names.join(", ") || "none" },
|
|
@@ -8843,7 +11058,7 @@ Run ${cliCommandName()} init --wizard --from-env DATABASE_URL, or pass --config
|
|
|
8843
11058
|
{ name: "write credentials absent", ok: !/(password|secret|bearer|private[_-]?key|token)/i.test(serialized), detail: "MCP tools do not include write credentials" }
|
|
8844
11059
|
];
|
|
8845
11060
|
const ok = checks.every((check) => check.ok);
|
|
8846
|
-
return { ok, configPath, storePath, names, checks };
|
|
11061
|
+
return { ok, configPath, storePath, aliasMode, names, exposures, checks };
|
|
8847
11062
|
} finally {
|
|
8848
11063
|
runtime.close();
|
|
8849
11064
|
}
|
|
@@ -8860,13 +11075,15 @@ function defaultBlockedToolSurface() {
|
|
|
8860
11075
|
];
|
|
8861
11076
|
}
|
|
8862
11077
|
function formatToolsPreview(input) {
|
|
11078
|
+
const exposedLines = input.exposures.length > 0 ? input.exposures.map((item) => item.isAlias ? ` - ${item.exposedName} -> ${item.canonicalName}` : ` - ${item.exposedName}`) : [" - (none)"];
|
|
8863
11079
|
const lines = [
|
|
8864
11080
|
`Synapsor tools preview: ${input.ok ? "ok" : "failed"}`,
|
|
8865
11081
|
`Config: ${input.configPath}`,
|
|
8866
11082
|
`Store: ${input.storePath}`,
|
|
11083
|
+
`Alias mode: ${input.aliasMode}`,
|
|
8867
11084
|
"",
|
|
8868
11085
|
"Exposed to MCP:",
|
|
8869
|
-
...
|
|
11086
|
+
...exposedLines,
|
|
8870
11087
|
"",
|
|
8871
11088
|
"Not exposed to MCP:",
|
|
8872
11089
|
...defaultBlockedToolSurface().map((name) => ` - ${name}`),
|
|
@@ -8900,6 +11117,69 @@ function formatMcpSmoke(input) {
|
|
|
8900
11117
|
return `${lines.join("\n")}
|
|
8901
11118
|
`;
|
|
8902
11119
|
}
|
|
11120
|
+
async function smokeToolInput(args, capability) {
|
|
11121
|
+
if (!args.includes("--sample") && !optionalArg(args, "--input") && !optionalArg(args, "--json")) {
|
|
11122
|
+
return sampleInputForCapability(capability);
|
|
11123
|
+
}
|
|
11124
|
+
return await smokeInputFromArgs(args, capability);
|
|
11125
|
+
}
|
|
11126
|
+
async function smokeInputFromArgs(args, capability) {
|
|
11127
|
+
const jsonInput = optionalArg(args, "--json");
|
|
11128
|
+
const inputPath = optionalArg(args, "--input");
|
|
11129
|
+
const sample = args.includes("--sample");
|
|
11130
|
+
const selected = [Boolean(jsonInput), Boolean(inputPath), sample].filter(Boolean).length;
|
|
11131
|
+
if (selected > 1) throw new Error("smoke call accepts only one of --sample, --input, or --json");
|
|
11132
|
+
if (jsonInput) {
|
|
11133
|
+
const parsed = JSON.parse(jsonInput);
|
|
11134
|
+
if (!isRecord6(parsed)) throw new Error("smoke call --json must be a JSON object");
|
|
11135
|
+
return parsed;
|
|
11136
|
+
}
|
|
11137
|
+
if (inputPath) {
|
|
11138
|
+
const parsed = JSON.parse(await fs3.readFile(inputPath, "utf8"));
|
|
11139
|
+
if (!isRecord6(parsed)) throw new Error("smoke call --input must point to a JSON object");
|
|
11140
|
+
return parsed;
|
|
11141
|
+
}
|
|
11142
|
+
if (sample && capability) return sampleInputForCapability(capability);
|
|
11143
|
+
if (sample) return {};
|
|
11144
|
+
return {};
|
|
11145
|
+
}
|
|
11146
|
+
function formatSmokeCallResult(toolName, input, result, storePath) {
|
|
11147
|
+
const evidenceId = stringField(result, "evidence_bundle_id");
|
|
11148
|
+
const proposalId = stringField(result, "proposal_id");
|
|
11149
|
+
const replayResource = stringField(result, "replay_resource");
|
|
11150
|
+
const sourceChanged = result.source_database_changed === true || result.source_database_mutated === true;
|
|
11151
|
+
const lines = [
|
|
11152
|
+
"Synapsor smoke call: ok",
|
|
11153
|
+
"",
|
|
11154
|
+
"Tool:",
|
|
11155
|
+
toolName,
|
|
11156
|
+
"",
|
|
11157
|
+
"Input:",
|
|
11158
|
+
JSON.stringify(input, null, 2),
|
|
11159
|
+
"",
|
|
11160
|
+
"Source DB changed:",
|
|
11161
|
+
sourceChanged ? "yes" : "no",
|
|
11162
|
+
"",
|
|
11163
|
+
"Evidence:",
|
|
11164
|
+
evidenceId || "(not returned)",
|
|
11165
|
+
""
|
|
11166
|
+
];
|
|
11167
|
+
if (proposalId) {
|
|
11168
|
+
lines.push("Proposal:", proposalId, "", "Replay:", replayResource || `synapsor://replay/replay_${proposalId}`, "");
|
|
11169
|
+
}
|
|
11170
|
+
lines.push("Next:");
|
|
11171
|
+
if (evidenceId) lines.push(` ${cliCommandName()} evidence show ${evidenceId} --store ${storePath}`);
|
|
11172
|
+
if (proposalId) {
|
|
11173
|
+
lines.push(` ${cliCommandName()} proposals show ${proposalId} --store ${storePath}`);
|
|
11174
|
+
lines.push(` ${cliCommandName()} proposals approve ${proposalId} --store ${storePath}`);
|
|
11175
|
+
lines.push(` ${cliCommandName()} apply ${proposalId} --store ${storePath}`);
|
|
11176
|
+
lines.push(` ${cliCommandName()} replay show --proposal ${proposalId} --store ${storePath}`);
|
|
11177
|
+
} else if (evidenceId) {
|
|
11178
|
+
lines.push(` ${cliCommandName()} query-audit list --evidence ${evidenceId} --store ${storePath}`);
|
|
11179
|
+
}
|
|
11180
|
+
return `${lines.join("\n")}
|
|
11181
|
+
`;
|
|
11182
|
+
}
|
|
8903
11183
|
async function writeMcpClientSnippet(destination, client, snippet, yes) {
|
|
8904
11184
|
const resolved = path3.resolve(destination);
|
|
8905
11185
|
let existing = {};
|
|
@@ -9406,11 +11686,19 @@ async function activity(args) {
|
|
|
9406
11686
|
usage(["activity"]);
|
|
9407
11687
|
return 2;
|
|
9408
11688
|
}
|
|
11689
|
+
async function events(args) {
|
|
11690
|
+
const [subcommand, ...rest] = args;
|
|
11691
|
+
if (subcommand === "tail") return eventsTail(rest);
|
|
11692
|
+
if (subcommand === "webhook" || subcommand === "push") return eventsWebhook(rest);
|
|
11693
|
+
usage(["events"]);
|
|
11694
|
+
return 2;
|
|
11695
|
+
}
|
|
9409
11696
|
async function storeCommand(args) {
|
|
9410
11697
|
const [subcommand, ...rest] = args;
|
|
9411
11698
|
if (subcommand === "stats") return storeStats(rest);
|
|
9412
11699
|
if (subcommand === "vacuum") return storeVacuum(rest);
|
|
9413
11700
|
if (subcommand === "prune") return storePrune(rest);
|
|
11701
|
+
if (subcommand === "reset") return storeReset(rest);
|
|
9414
11702
|
usage(["store"]);
|
|
9415
11703
|
return 2;
|
|
9416
11704
|
}
|
|
@@ -9435,6 +11723,10 @@ async function ui(args) {
|
|
|
9435
11723
|
});
|
|
9436
11724
|
process2.stdout.write(`Synapsor Runner local UI: ${server.url}
|
|
9437
11725
|
`);
|
|
11726
|
+
if (args.includes("--open")) {
|
|
11727
|
+
openBrowser(server.url);
|
|
11728
|
+
process2.stdout.write("Opening the local review UI in your browser when a desktop opener is available.\n");
|
|
11729
|
+
}
|
|
9438
11730
|
process2.stdout.write("Approval and rejection actions require the per-run local session plus CSRF token. Press Ctrl+C to stop.\n");
|
|
9439
11731
|
await new Promise((resolve) => {
|
|
9440
11732
|
const stop = async () => {
|
|
@@ -9448,6 +11740,13 @@ async function ui(args) {
|
|
|
9448
11740
|
});
|
|
9449
11741
|
return 0;
|
|
9450
11742
|
}
|
|
11743
|
+
function openBrowser(url) {
|
|
11744
|
+
const command = process2.platform === "darwin" ? "open" : process2.platform === "win32" ? "cmd" : "xdg-open";
|
|
11745
|
+
const args = process2.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
11746
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
11747
|
+
child.on("error", () => void 0);
|
|
11748
|
+
child.unref();
|
|
11749
|
+
}
|
|
9451
11750
|
async function shadowList(args) {
|
|
9452
11751
|
const store = await openLocalStore(args);
|
|
9453
11752
|
try {
|
|
@@ -9915,6 +12214,125 @@ async function activitySearch(args) {
|
|
|
9915
12214
|
store.close();
|
|
9916
12215
|
}
|
|
9917
12216
|
}
|
|
12217
|
+
async function eventsTail(args) {
|
|
12218
|
+
assertKnownOptions(args, eventTailAllowedOptions, "events tail");
|
|
12219
|
+
const follow = args.includes("--follow");
|
|
12220
|
+
if (follow && args.includes("--json")) throw new Error("events tail --follow does not support --json yet");
|
|
12221
|
+
const storePath = optionalArg(args, "--store");
|
|
12222
|
+
const intervalMs = Number(optionalArg(args, "--interval-ms") ?? "1000");
|
|
12223
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 250) throw new Error("--interval-ms must be at least 250");
|
|
12224
|
+
const filters = eventFiltersFromArgs(args);
|
|
12225
|
+
const printOnce = async (seen2) => {
|
|
12226
|
+
const store = await openLocalStore(["--store", storePath ?? "./.synapsor/local.db"]);
|
|
12227
|
+
try {
|
|
12228
|
+
const rows = store.listEvents(filters).sort((left, right) => left.event_id - right.event_id).filter((event) => !seen2?.has(event.event_id));
|
|
12229
|
+
if (seen2) rows.forEach((event) => seen2.add(event.event_id));
|
|
12230
|
+
if (args.includes("--json")) {
|
|
12231
|
+
process2.stdout.write(`${JSON.stringify({ events: rows }, null, 2)}
|
|
12232
|
+
`);
|
|
12233
|
+
} else if (rows.length === 0 && !follow) {
|
|
12234
|
+
process2.stdout.write("No local events found.\n");
|
|
12235
|
+
} else {
|
|
12236
|
+
for (const event of rows) process2.stdout.write(formatEventLine(event, showDetails(args)));
|
|
12237
|
+
}
|
|
12238
|
+
return rows.length;
|
|
12239
|
+
} finally {
|
|
12240
|
+
store.close();
|
|
12241
|
+
}
|
|
12242
|
+
};
|
|
12243
|
+
if (!follow) {
|
|
12244
|
+
await printOnce();
|
|
12245
|
+
return 0;
|
|
12246
|
+
}
|
|
12247
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12248
|
+
await printOnce(seen);
|
|
12249
|
+
await new Promise((resolve) => {
|
|
12250
|
+
const timer = setInterval(() => {
|
|
12251
|
+
void printOnce(seen).catch((error) => {
|
|
12252
|
+
process2.stderr.write(`events tail error: ${safeErrorMessage(error)}
|
|
12253
|
+
`);
|
|
12254
|
+
});
|
|
12255
|
+
}, intervalMs);
|
|
12256
|
+
const stop = () => {
|
|
12257
|
+
clearInterval(timer);
|
|
12258
|
+
process2.off("SIGINT", stop);
|
|
12259
|
+
process2.off("SIGTERM", stop);
|
|
12260
|
+
resolve();
|
|
12261
|
+
};
|
|
12262
|
+
process2.once("SIGINT", stop);
|
|
12263
|
+
process2.once("SIGTERM", stop);
|
|
12264
|
+
});
|
|
12265
|
+
return 0;
|
|
12266
|
+
}
|
|
12267
|
+
async function eventsWebhook(args) {
|
|
12268
|
+
assertKnownOptions(args, eventWebhookAllowedOptions, "events webhook");
|
|
12269
|
+
const url = optionalArg(args, "--url") ?? envValue(optionalArg(args, "--url-env"));
|
|
12270
|
+
if (!url) throw new Error("events webhook requires --url <https://...> or --url-env <ENV>");
|
|
12271
|
+
const endpoint = new URL(url);
|
|
12272
|
+
if (!["http:", "https:"].includes(endpoint.protocol)) throw new Error("events webhook URL must use http or https");
|
|
12273
|
+
const follow = args.includes("--follow");
|
|
12274
|
+
const dryRun = args.includes("--dry-run");
|
|
12275
|
+
const jsonOutput = args.includes("--json");
|
|
12276
|
+
if (follow && jsonOutput) throw new Error("events webhook --follow does not support --json");
|
|
12277
|
+
const storePath = optionalArg(args, "--store");
|
|
12278
|
+
const intervalMs = Number(optionalArg(args, "--interval-ms") ?? "1000");
|
|
12279
|
+
const timeoutMs = Number(optionalArg(args, "--timeout-ms") ?? "5000");
|
|
12280
|
+
const sinceEventId = optionalPositiveIntegerArg(args, "--since-event-id");
|
|
12281
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 250) throw new Error("--interval-ms must be at least 250");
|
|
12282
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 250) throw new Error("--timeout-ms must be at least 250");
|
|
12283
|
+
const token = envValue(optionalArg(args, "--auth-token-env"));
|
|
12284
|
+
const filters = eventFiltersFromArgs(args);
|
|
12285
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12286
|
+
const pushOnce = async () => {
|
|
12287
|
+
const store = await openLocalStore(["--store", storePath ?? "./.synapsor/local.db"]);
|
|
12288
|
+
try {
|
|
12289
|
+
const rows = store.listEvents(filters).filter((event) => sinceEventId === void 0 || event.event_id > sinceEventId).sort((left, right) => left.event_id - right.event_id).filter((event) => !seen.has(event.event_id));
|
|
12290
|
+
rows.forEach((event) => seen.add(event.event_id));
|
|
12291
|
+
let delivered = 0;
|
|
12292
|
+
const payloads = [];
|
|
12293
|
+
for (const event of rows) {
|
|
12294
|
+
const payload = localEventWebhookPayload(event, store.path);
|
|
12295
|
+
payloads.push(payload);
|
|
12296
|
+
if (dryRun) {
|
|
12297
|
+
if (!jsonOutput) process2.stdout.write(`${JSON.stringify(payload, null, 2)}
|
|
12298
|
+
`);
|
|
12299
|
+
} else {
|
|
12300
|
+
await postLocalEventWebhook(endpoint, payload, { token, timeoutMs });
|
|
12301
|
+
if (!jsonOutput) process2.stdout.write(`pushed event ${event.event_id} ${event.kind} for ${event.proposal_id} to ${redactWebhookUrl(endpoint)}
|
|
12302
|
+
`);
|
|
12303
|
+
}
|
|
12304
|
+
delivered += 1;
|
|
12305
|
+
}
|
|
12306
|
+
if (rows.length === 0 && !follow && !jsonOutput) process2.stdout.write(dryRun ? "No local events matched for dry-run.\n" : "No local events matched.\n");
|
|
12307
|
+
return { delivered, payloads };
|
|
12308
|
+
} finally {
|
|
12309
|
+
store.close();
|
|
12310
|
+
}
|
|
12311
|
+
};
|
|
12312
|
+
const first = await pushOnce();
|
|
12313
|
+
if (jsonOutput && !follow) {
|
|
12314
|
+
process2.stdout.write(`${JSON.stringify({ ok: true, dry_run: dryRun, delivered: first.delivered, webhook: redactWebhookUrl(endpoint), events: first.payloads }, null, 2)}
|
|
12315
|
+
`);
|
|
12316
|
+
}
|
|
12317
|
+
if (!follow) return 0;
|
|
12318
|
+
await new Promise((resolve) => {
|
|
12319
|
+
const timer = setInterval(() => {
|
|
12320
|
+
void pushOnce().catch((error) => {
|
|
12321
|
+
process2.stderr.write(`events webhook error: ${safeErrorMessage(error)}
|
|
12322
|
+
`);
|
|
12323
|
+
});
|
|
12324
|
+
}, intervalMs);
|
|
12325
|
+
const stop = () => {
|
|
12326
|
+
clearInterval(timer);
|
|
12327
|
+
process2.off("SIGINT", stop);
|
|
12328
|
+
process2.off("SIGTERM", stop);
|
|
12329
|
+
resolve();
|
|
12330
|
+
};
|
|
12331
|
+
process2.once("SIGINT", stop);
|
|
12332
|
+
process2.once("SIGTERM", stop);
|
|
12333
|
+
});
|
|
12334
|
+
return 0;
|
|
12335
|
+
}
|
|
9918
12336
|
async function storeStats(args) {
|
|
9919
12337
|
assertKnownOptions(args, storeStatsAllowedOptions, "store stats");
|
|
9920
12338
|
const store = await openLocalStore(args);
|
|
@@ -9952,6 +12370,7 @@ async function storePrune(args) {
|
|
|
9952
12370
|
if (args.includes("--yes") && args.includes("--dry-run")) throw new Error("store prune accepts either --dry-run or --yes, not both");
|
|
9953
12371
|
const cutoff = cutoffFromOlderThan(olderThan);
|
|
9954
12372
|
const dryRun = !args.includes("--yes");
|
|
12373
|
+
if (!dryRun) await assertNoActiveStoreLease(optionalArg(args, "--store"), args.includes("--force"), "store prune");
|
|
9955
12374
|
const store = await openLocalStore(args);
|
|
9956
12375
|
try {
|
|
9957
12376
|
const result = store.pruneBefore(cutoff, { dryRun });
|
|
@@ -9963,6 +12382,36 @@ async function storePrune(args) {
|
|
|
9963
12382
|
store.close();
|
|
9964
12383
|
}
|
|
9965
12384
|
}
|
|
12385
|
+
async function storeReset(args) {
|
|
12386
|
+
assertKnownOptions(args, storeResetAllowedOptions, "store reset");
|
|
12387
|
+
const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE ?? "./.synapsor/local.db";
|
|
12388
|
+
if (storePath === ":memory:") throw new Error("store reset does not apply to :memory: stores");
|
|
12389
|
+
if (!args.includes("--yes")) {
|
|
12390
|
+
throw new Error("store reset is destructive for the local ledger. Rerun with --yes after backing up anything you need.");
|
|
12391
|
+
}
|
|
12392
|
+
await assertNoActiveStoreLease(storePath, args.includes("--force"), "store reset");
|
|
12393
|
+
const resolved = path3.resolve(storePath);
|
|
12394
|
+
const candidates = [resolved, `${resolved}-wal`, `${resolved}-shm`, storeLeasePath(resolved)];
|
|
12395
|
+
const removed = [];
|
|
12396
|
+
for (const candidate of candidates) {
|
|
12397
|
+
try {
|
|
12398
|
+
await fs3.rm(candidate, { force: true });
|
|
12399
|
+
removed.push(candidate);
|
|
12400
|
+
} catch (error) {
|
|
12401
|
+
if (error.code !== "ENOENT") throw error;
|
|
12402
|
+
}
|
|
12403
|
+
}
|
|
12404
|
+
const result = {
|
|
12405
|
+
ok: true,
|
|
12406
|
+
store: resolved,
|
|
12407
|
+
removed,
|
|
12408
|
+
source_database_changed: false
|
|
12409
|
+
};
|
|
12410
|
+
if (args.includes("--json")) process2.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
12411
|
+
`);
|
|
12412
|
+
else process2.stdout.write(formatStoreReset(result));
|
|
12413
|
+
return 0;
|
|
12414
|
+
}
|
|
9966
12415
|
var commonReadOptions = /* @__PURE__ */ new Set(["--store", "--json", "--details", "--debug"]);
|
|
9967
12416
|
var showAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions]);
|
|
9968
12417
|
var exportAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--output", "--out", "--format", "--evidence", "--audit"]);
|
|
@@ -10022,6 +12471,26 @@ var receiptListAllowedOptions = /* @__PURE__ */ new Set([
|
|
|
10022
12471
|
"--to",
|
|
10023
12472
|
"--limit"
|
|
10024
12473
|
]);
|
|
12474
|
+
var eventTailAllowedOptions = /* @__PURE__ */ new Set([
|
|
12475
|
+
...commonReadOptions,
|
|
12476
|
+
"--proposal",
|
|
12477
|
+
"--kind",
|
|
12478
|
+
"--actor",
|
|
12479
|
+
"--from",
|
|
12480
|
+
"--to",
|
|
12481
|
+
"--limit",
|
|
12482
|
+
"--follow",
|
|
12483
|
+
"--interval-ms"
|
|
12484
|
+
]);
|
|
12485
|
+
var eventWebhookAllowedOptions = /* @__PURE__ */ new Set([
|
|
12486
|
+
...eventTailAllowedOptions,
|
|
12487
|
+
"--url",
|
|
12488
|
+
"--url-env",
|
|
12489
|
+
"--auth-token-env",
|
|
12490
|
+
"--timeout-ms",
|
|
12491
|
+
"--since-event-id",
|
|
12492
|
+
"--dry-run"
|
|
12493
|
+
]);
|
|
10025
12494
|
var replayShowAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--proposal", "--replay", "--evidence"]);
|
|
10026
12495
|
var replayExportAllowedOptions = /* @__PURE__ */ new Set([...replayShowAllowedOptions, "--output", "--out", "--format"]);
|
|
10027
12496
|
var replayListAllowedOptions = /* @__PURE__ */ new Set([
|
|
@@ -10064,7 +12533,8 @@ var activitySearchAllowedOptions = /* @__PURE__ */ new Set([
|
|
|
10064
12533
|
]);
|
|
10065
12534
|
var storeStatsAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions]);
|
|
10066
12535
|
var storeVacuumAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions]);
|
|
10067
|
-
var storePruneAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--older-than", "--dry-run", "--yes"]);
|
|
12536
|
+
var storePruneAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--older-than", "--dry-run", "--yes", "--force"]);
|
|
12537
|
+
var storeResetAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--yes", "--force"]);
|
|
10068
12538
|
function assertKnownOptions(args, allowed, commandName) {
|
|
10069
12539
|
for (const arg of args) {
|
|
10070
12540
|
if (!arg.startsWith("--")) continue;
|
|
@@ -10213,6 +12683,27 @@ function receiptFiltersFromActivityArgs(args, store) {
|
|
|
10213
12683
|
limit: limitFromArgs(args)
|
|
10214
12684
|
};
|
|
10215
12685
|
}
|
|
12686
|
+
function eventFiltersFromArgs(args) {
|
|
12687
|
+
return {
|
|
12688
|
+
proposal: optionalArg(args, "--proposal"),
|
|
12689
|
+
kind: optionalArg(args, "--kind"),
|
|
12690
|
+
actor: optionalArg(args, "--actor"),
|
|
12691
|
+
from: optionalArg(args, "--from"),
|
|
12692
|
+
to: optionalArg(args, "--to"),
|
|
12693
|
+
limit: limitFromArgs(args)
|
|
12694
|
+
};
|
|
12695
|
+
}
|
|
12696
|
+
function optionalPositiveIntegerArg(args, flag) {
|
|
12697
|
+
const value = optionalArg(args, flag);
|
|
12698
|
+
if (!value) return void 0;
|
|
12699
|
+
const parsed = Number(value);
|
|
12700
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${flag} must be a non-negative integer`);
|
|
12701
|
+
return parsed;
|
|
12702
|
+
}
|
|
12703
|
+
function envValue(name) {
|
|
12704
|
+
if (!name) return void 0;
|
|
12705
|
+
return process2.env[name];
|
|
12706
|
+
}
|
|
10216
12707
|
function linkedProposalFilter(args, store, options = {}) {
|
|
10217
12708
|
const noLinkedProposal = "__synapsor_no_linked_proposal__";
|
|
10218
12709
|
const replay2 = optionalArg(args, "--replay");
|
|
@@ -10393,8 +12884,8 @@ async function prepareReferenceDemo(args) {
|
|
|
10393
12884
|
].join("\n"));
|
|
10394
12885
|
const down = spawnSync("docker", ["compose", "-f", composePath, "down", "-v", "--remove-orphans"], { stdio: "inherit", env: process2.env });
|
|
10395
12886
|
if (down.status !== 0) return down.status ?? 1;
|
|
10396
|
-
const
|
|
10397
|
-
if (
|
|
12887
|
+
const up2 = spawnSync("docker", ["compose", "-f", composePath, "up", "-d"], { stdio: "inherit", env: process2.env });
|
|
12888
|
+
if (up2.status !== 0) return up2.status ?? 1;
|
|
10398
12889
|
await waitForReferenceDemoDatabase();
|
|
10399
12890
|
await fs3.copyFile(path3.join(demoDir, "synapsor.runner.json"), configPath);
|
|
10400
12891
|
process2.stdout.write([
|
|
@@ -10416,7 +12907,7 @@ async function prepareReferenceDemo(args) {
|
|
|
10416
12907
|
`${cliCommandName()} mcp config --absolute-paths --config ./synapsor.runner.json --store ./.synapsor/local.db`,
|
|
10417
12908
|
"",
|
|
10418
12909
|
"Open UI:",
|
|
10419
|
-
`${cliCommandName()} ui --tour`,
|
|
12910
|
+
`${cliCommandName()} ui --open --tour`,
|
|
10420
12911
|
""
|
|
10421
12912
|
].join("\n"));
|
|
10422
12913
|
return 0;
|
|
@@ -10470,7 +12961,7 @@ function databaseInputFromArgs(args) {
|
|
|
10470
12961
|
if (inlineUrl && !isDatabaseUrl(inlineUrl)) {
|
|
10471
12962
|
throw new Error("--from must be a postgres://, postgresql://, or mysql:// URL.");
|
|
10472
12963
|
}
|
|
10473
|
-
const fromEnv = optionalArg(args, "--from-env") ?? optionalArg(args, "--database-url-env");
|
|
12964
|
+
const fromEnv = optionalArg(args, "--from-env") ?? optionalArg(args, "--url-env") ?? optionalArg(args, "--database-url-env");
|
|
10474
12965
|
const configDatabaseUrlEnv = fromEnv ?? "SYNAPSOR_DATABASE_READ_URL";
|
|
10475
12966
|
if (inlineUrl) {
|
|
10476
12967
|
return {
|
|
@@ -10516,6 +13007,7 @@ function firstPositional(args) {
|
|
|
10516
13007
|
"--format",
|
|
10517
13008
|
"--from",
|
|
10518
13009
|
"--from-env",
|
|
13010
|
+
"--url-env",
|
|
10519
13011
|
"--host",
|
|
10520
13012
|
"--idempotency-key",
|
|
10521
13013
|
"--input",
|
|
@@ -10545,6 +13037,10 @@ function firstPositional(args) {
|
|
|
10545
13037
|
"--reason",
|
|
10546
13038
|
"--recipe",
|
|
10547
13039
|
"--receipt",
|
|
13040
|
+
"--read-tool",
|
|
13041
|
+
"--inspect-tool-name",
|
|
13042
|
+
"--proposal-tool",
|
|
13043
|
+
"--proposal-tool-name",
|
|
10548
13044
|
"--replay",
|
|
10549
13045
|
"--runner",
|
|
10550
13046
|
"--schema",
|
|
@@ -10706,11 +13202,11 @@ function formatProposalDetail(proposal, storedEvidenceItemCount) {
|
|
|
10706
13202
|
...formatChangeLines(proposal)
|
|
10707
13203
|
].join("\n") + "\n";
|
|
10708
13204
|
}
|
|
10709
|
-
function formatProposalEventDetail(
|
|
10710
|
-
if (
|
|
13205
|
+
function formatProposalEventDetail(events2) {
|
|
13206
|
+
if (events2.length === 0) return "Events:\n none\n";
|
|
10711
13207
|
return [
|
|
10712
13208
|
"Events:",
|
|
10713
|
-
...
|
|
13209
|
+
...events2.map((event) => ` event ${event.event_id}: ${event.kind} by ${event.actor} at ${event.created_at}`)
|
|
10714
13210
|
].join("\n") + "\n";
|
|
10715
13211
|
}
|
|
10716
13212
|
function formatProposalDebug(proposal, storePath) {
|
|
@@ -11172,6 +13668,64 @@ function formatActivityNext(items, storeSuffix) {
|
|
|
11172
13668
|
return `${lines.join("\n")}
|
|
11173
13669
|
`;
|
|
11174
13670
|
}
|
|
13671
|
+
function formatEventLine(event, details = false) {
|
|
13672
|
+
const lines = [
|
|
13673
|
+
`${event.created_at} ${event.kind}`,
|
|
13674
|
+
` proposal: ${event.proposal_id}`,
|
|
13675
|
+
` actor: ${event.actor}`
|
|
13676
|
+
];
|
|
13677
|
+
if (details && Object.keys(event.payload).length > 0) {
|
|
13678
|
+
lines.push(` payload: ${JSON.stringify(event.payload)}`);
|
|
13679
|
+
}
|
|
13680
|
+
lines.push("");
|
|
13681
|
+
return `${lines.join("\n")}
|
|
13682
|
+
`;
|
|
13683
|
+
}
|
|
13684
|
+
function localEventWebhookPayload(event, storePath) {
|
|
13685
|
+
return {
|
|
13686
|
+
schema_version: "synapsor.local-event-webhook.v1",
|
|
13687
|
+
delivered_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
13688
|
+
source: {
|
|
13689
|
+
kind: "local_store",
|
|
13690
|
+
store_path: storePath
|
|
13691
|
+
},
|
|
13692
|
+
event
|
|
13693
|
+
};
|
|
13694
|
+
}
|
|
13695
|
+
async function postLocalEventWebhook(endpoint, payload, options) {
|
|
13696
|
+
const controller = new AbortController();
|
|
13697
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
13698
|
+
try {
|
|
13699
|
+
const headers = {
|
|
13700
|
+
"content-type": "application/json",
|
|
13701
|
+
accept: "application/json",
|
|
13702
|
+
"user-agent": "synapsor-runner-events-webhook"
|
|
13703
|
+
};
|
|
13704
|
+
if (options.token) headers.authorization = `Bearer ${options.token}`;
|
|
13705
|
+
const response = await fetch(endpoint, {
|
|
13706
|
+
method: "POST",
|
|
13707
|
+
headers,
|
|
13708
|
+
body: JSON.stringify(payload),
|
|
13709
|
+
signal: controller.signal
|
|
13710
|
+
});
|
|
13711
|
+
if (!response.ok) {
|
|
13712
|
+
const text = await response.text().catch(() => "");
|
|
13713
|
+
throw new Error(`webhook returned HTTP ${response.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
|
|
13714
|
+
}
|
|
13715
|
+
} finally {
|
|
13716
|
+
clearTimeout(timeout);
|
|
13717
|
+
}
|
|
13718
|
+
}
|
|
13719
|
+
function redactWebhookUrl(endpoint) {
|
|
13720
|
+
const copy = new URL(endpoint.toString());
|
|
13721
|
+
copy.username = "";
|
|
13722
|
+
copy.password = "";
|
|
13723
|
+
copy.search = "";
|
|
13724
|
+
return copy.toString();
|
|
13725
|
+
}
|
|
13726
|
+
function safeErrorMessage(error) {
|
|
13727
|
+
return error instanceof Error ? error.message : String(error);
|
|
13728
|
+
}
|
|
11175
13729
|
function formatStoreStats(stats) {
|
|
11176
13730
|
return [
|
|
11177
13731
|
`Local store: ${stats.path}`,
|
|
@@ -11203,6 +13757,18 @@ function formatStorePrune(result) {
|
|
|
11203
13757
|
return `${lines.join("\n")}
|
|
11204
13758
|
`;
|
|
11205
13759
|
}
|
|
13760
|
+
function formatStoreReset(result) {
|
|
13761
|
+
const lines = [
|
|
13762
|
+
"Local store reset complete",
|
|
13763
|
+
`Store: ${result.store}`,
|
|
13764
|
+
`Source database changed: ${result.source_database_changed ? "yes" : "no"}`,
|
|
13765
|
+
"",
|
|
13766
|
+
"Removed:",
|
|
13767
|
+
...result.removed.length ? result.removed.map((entry) => ` - ${entry}`) : [" - no local store files were present"]
|
|
13768
|
+
];
|
|
13769
|
+
return `${lines.join("\n")}
|
|
13770
|
+
`;
|
|
13771
|
+
}
|
|
11206
13772
|
function cutoffFromOlderThan(value) {
|
|
11207
13773
|
const match = value.match(/^(\d+)([smhd])$/i);
|
|
11208
13774
|
if (!match) throw new Error("--older-than must use a duration such as 30d, 12h, 90m, or 0d");
|
|
@@ -11444,7 +14010,7 @@ function starterCloudConfig() {
|
|
|
11444
14010
|
base_url_env: "SYNAPSOR_CLOUD_BASE_URL",
|
|
11445
14011
|
runner_token_env: "SYNAPSOR_RUNNER_TOKEN",
|
|
11446
14012
|
runner_id: "synapsor_runner_local",
|
|
11447
|
-
runner_version: "0.1.0
|
|
14013
|
+
runner_version: "0.1.0",
|
|
11448
14014
|
project_id: "token_scope",
|
|
11449
14015
|
adapter_id: "mcp.your_adapter",
|
|
11450
14016
|
source_id: "src_replace_me",
|
|
@@ -11479,10 +14045,14 @@ function isKnownTopLevelCommand(command) {
|
|
|
11479
14045
|
"propose",
|
|
11480
14046
|
"audit",
|
|
11481
14047
|
"start",
|
|
14048
|
+
"up",
|
|
11482
14049
|
"runner",
|
|
11483
14050
|
"cloud",
|
|
11484
14051
|
"mcp",
|
|
14052
|
+
"smoke",
|
|
11485
14053
|
"tools",
|
|
14054
|
+
"writeback",
|
|
14055
|
+
"handler",
|
|
11486
14056
|
"onboard",
|
|
11487
14057
|
"demo",
|
|
11488
14058
|
"recipes",
|
|
@@ -11493,6 +14063,7 @@ function isKnownTopLevelCommand(command) {
|
|
|
11493
14063
|
"query-audit",
|
|
11494
14064
|
"receipts",
|
|
11495
14065
|
"activity",
|
|
14066
|
+
"events",
|
|
11496
14067
|
"store",
|
|
11497
14068
|
"shadow",
|
|
11498
14069
|
"ui"
|
|
@@ -11504,7 +14075,7 @@ function cliCommandName() {
|
|
|
11504
14075
|
}
|
|
11505
14076
|
function usage(args = []) {
|
|
11506
14077
|
const [command, subcommand] = args;
|
|
11507
|
-
const key = command === "mcp" && subcommand ?
|
|
14078
|
+
const key = (command === "mcp" || command === "handler") && subcommand ? `${command} ${subcommand}` : command ?? "";
|
|
11508
14079
|
const cmd = cliCommandName();
|
|
11509
14080
|
const help = {
|
|
11510
14081
|
"": `Synapsor Runner
|
|
@@ -11516,8 +14087,15 @@ Usage:
|
|
|
11516
14087
|
|
|
11517
14088
|
Commands:
|
|
11518
14089
|
inspect Inspect a Postgres/MySQL schema
|
|
14090
|
+
start Start guided own-database setup, or no-arg legacy worker polling
|
|
14091
|
+
up Bring up local review mode guidance/server
|
|
11519
14092
|
init Generate a Synapsor capability contract
|
|
11520
14093
|
mcp Serve safe semantic tools over MCP
|
|
14094
|
+
onboard One-command own-database setup
|
|
14095
|
+
smoke Test generated tool calls before wiring an MCP client
|
|
14096
|
+
tools List model-facing MCP tools and aliases
|
|
14097
|
+
writeback Print direct SQL writeback receipt DDL, grants, and checks
|
|
14098
|
+
handler Create app-owned writeback handler templates
|
|
11521
14099
|
propose Create a local evidence-backed proposal
|
|
11522
14100
|
audit Review MCP/database tool risk
|
|
11523
14101
|
proposals Review, approve, or reject proposals
|
|
@@ -11525,6 +14103,7 @@ Commands:
|
|
|
11525
14103
|
query-audit Inspect local query audit records
|
|
11526
14104
|
receipts Inspect guarded writeback receipts
|
|
11527
14105
|
activity Search local evidence/replay ledger
|
|
14106
|
+
events Tail or push local proposal/writeback lifecycle events
|
|
11528
14107
|
store Inspect and maintain the local SQLite ledger
|
|
11529
14108
|
apply Apply an approved proposal with guarded writeback
|
|
11530
14109
|
replay Show what happened
|
|
@@ -11532,47 +14111,137 @@ Commands:
|
|
|
11532
14111
|
ui Open the local review UI
|
|
11533
14112
|
|
|
11534
14113
|
Examples:
|
|
14114
|
+
${cmd} start --from-env DATABASE_URL
|
|
14115
|
+
${cmd} up --config ./synapsor.runner.json --store ./.synapsor/local.db --dry-run
|
|
14116
|
+
${cmd} onboard db --from-env DATABASE_URL
|
|
11535
14117
|
${cmd} inspect --from-env DATABASE_URL
|
|
11536
14118
|
${cmd} init --wizard --from-env DATABASE_URL
|
|
14119
|
+
${cmd} smoke call --config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
14120
|
+
${cmd} tools list --aliases --config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
14121
|
+
${cmd} handler template node-fastify --output ./synapsor-writeback-handler.mjs
|
|
11537
14122
|
${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
11538
14123
|
${cmd} propose billing.propose_late_fee_waiver --sample
|
|
11539
14124
|
${cmd} audit ./synapsor.runner.json
|
|
14125
|
+
`,
|
|
14126
|
+
up: `Usage:
|
|
14127
|
+
${cmd} up --config ./synapsor.runner.json --store ./.synapsor/local.db [--transport stdio|streamable-http]
|
|
14128
|
+
${cmd} up --serve --config ./synapsor.runner.json --store ./.synapsor/local.db --port 8766 --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN
|
|
14129
|
+
${cmd} up --config ./synapsor.runner.json --store ./.synapsor/local.db --handler-check --dry-run
|
|
14130
|
+
|
|
14131
|
+
Validate the local Runner config and store, summarize model-facing tools,
|
|
14132
|
+
explain direct SQL versus app-owned executor writeback, and print the next
|
|
14133
|
+
smoke/approve/apply/replay commands.
|
|
14134
|
+
|
|
14135
|
+
With --transport stdio, \`${cmd} up\` prints MCP client wiring because stdio is
|
|
14136
|
+
launched by the client. \`${cmd} up --serve\` starts the standard Streamable HTTP
|
|
14137
|
+
MCP server after the checklist. Use --with-handler to run the handler doctor
|
|
14138
|
+
before serving app-owned writeback configs.
|
|
14139
|
+
|
|
14140
|
+
Options:
|
|
14141
|
+
--serve
|
|
14142
|
+
--alias-mode canonical|openai|both
|
|
14143
|
+
--result-format v1|v2
|
|
14144
|
+
--handler-check
|
|
14145
|
+
--with-handler
|
|
14146
|
+
--open-ui
|
|
14147
|
+
--dry-run
|
|
14148
|
+
`,
|
|
14149
|
+
start: `Usage:
|
|
14150
|
+
${cmd} start --from-env DATABASE_URL [--schema public] [--mode read_only|shadow|review]
|
|
14151
|
+
${cmd} start --from-env DATABASE_URL --mode review --writeback http_handler --handler-url-env APP_WRITEBACK_URL [--handler-signing-secret-env APP_WRITEBACK_SIGNING_SECRET]
|
|
14152
|
+
${cmd} start
|
|
14153
|
+
|
|
14154
|
+
With --from-env, run the guided own-database setup: inspect schema, choose one
|
|
14155
|
+
object, create trusted context, generate semantic MCP tools, run/print a smoke
|
|
14156
|
+
call, and print MCP/UI next steps.
|
|
14157
|
+
|
|
14158
|
+
With no flags, start the legacy cloud-linked writeback polling worker from the
|
|
14159
|
+
worker environment config. Prefer \`${cmd} runner start\` for that worker path
|
|
14160
|
+
so it is not confused with first-run onboarding.
|
|
11540
14161
|
`,
|
|
11541
14162
|
inspect: `Usage:
|
|
11542
14163
|
${cmd} inspect --from-env DATABASE_URL [--engine auto|postgres|mysql] [--schema public] [--json]
|
|
14164
|
+
${cmd} inspect --engine postgres --url-env DATABASE_URL
|
|
11543
14165
|
${cmd} inspect "<postgres-or-mysql-url>" [--engine auto|postgres|mysql] [--schema public] [--json]
|
|
11544
14166
|
|
|
11545
14167
|
Inspect schema metadata without mutating the database or printing credentials.
|
|
11546
14168
|
`,
|
|
11547
14169
|
init: `Usage:
|
|
11548
14170
|
${cmd} init --wizard --from-env DATABASE_URL [--mode read_only|review|shadow] [--out synapsor.runner.json]
|
|
11549
|
-
${cmd} init --
|
|
14171
|
+
${cmd} init --engine postgres --url-env DATABASE_URL --mode review --table public.invoices
|
|
14172
|
+
${cmd} init --inspection-json schema.json --table invoices --mode review --patch late_fee_cents=fixed:0,waiver_reason=arg:reason
|
|
14173
|
+
${cmd} init --answers answers.json --yes
|
|
14174
|
+
${cmd} init --inspection-json schema.json --table invoices --mode review --writeback http_handler --handler-url-env APP_WRITEBACK_URL --emit-handler [--handler-signing-secret-env APP_WRITEBACK_SIGNING_SECRET]
|
|
11550
14175
|
|
|
11551
14176
|
Generate a reviewed Synapsor Runner contract. Defaults to read-only in the wizard.
|
|
11552
|
-
|
|
14177
|
+
Review mode writeback choices: sql_update, http_handler, command_handler.
|
|
14178
|
+
If --namespace is omitted, init derives one from the table name instead of using source.*.
|
|
14179
|
+
Use --read-tool and --proposal-tool to override the exact model-facing capability names.
|
|
14180
|
+
The guided wizard shows a final preview and lets you revise visible fields or capability names before writing files.
|
|
14181
|
+
Use --yes/--non-interactive plus explicit flags, or --answers, for script/agent-friendly setup without prompts.
|
|
14182
|
+
`,
|
|
11553
14183
|
mcp: `Usage:
|
|
11554
14184
|
${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
14185
|
+
${cmd} mcp serve --transport streamable-http --config ./synapsor.runner.json --store ./.synapsor/local.db --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN
|
|
14186
|
+
${cmd} mcp serve-streamable-http --config ./synapsor.runner.json --store ./.synapsor/local.db --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN
|
|
11555
14187
|
${cmd} mcp serve-http --config ./synapsor.runner.json --store ./.synapsor/local.db --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN
|
|
11556
14188
|
${cmd} mcp config --absolute-paths --config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
14189
|
+
${cmd} mcp client-config --client openai-agents --config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
11557
14190
|
${cmd} mcp audit --example dangerous-db-mcp
|
|
11558
14191
|
${cmd} mcp audit ./tools-list.json
|
|
11559
14192
|
|
|
11560
|
-
Use stdio for local MCP clients that launch the runner. Use
|
|
14193
|
+
Use stdio for local MCP clients that launch the runner. Use Streamable HTTP for standard HTTP MCP clients. Use serve-http only when you explicitly want the lightweight JSON-RPC bridge.
|
|
11561
14194
|
MCP clients see semantic tools. They do not receive raw SQL, write credentials, approval tools, or commit tools.
|
|
14195
|
+
`,
|
|
14196
|
+
tools: `Usage:
|
|
14197
|
+
${cmd} tools list --config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
14198
|
+
${cmd} tools list --aliases --config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
14199
|
+
${cmd} tools preview --config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
14200
|
+
|
|
14201
|
+
List the model-facing MCP tools generated from a reviewed Runner config.
|
|
14202
|
+
Use --aliases to show canonical Synapsor names and OpenAI-safe aliases.
|
|
14203
|
+
This command never prints database URLs or write credentials.
|
|
11562
14204
|
`,
|
|
11563
14205
|
"mcp serve": `Usage:
|
|
11564
|
-
${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db [--read-only] [--local]
|
|
14206
|
+
${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db [--transport stdio] [--read-only] [--local] [--alias-mode canonical|openai|both] [--result-format v1|v2]
|
|
14207
|
+
${cmd} mcp serve --transport streamable-http --config ./synapsor.runner.json --store ./.synapsor/local.db --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN [--result-format v2]
|
|
11565
14208
|
|
|
11566
14209
|
Start the stdio MCP server for local MCP clients such as Claude Desktop, Cursor, or local agent tools. Startup logs stay off stdout so the MCP protocol remains clean.
|
|
14210
|
+
Use --alias-mode openai, or --openai-tool-aliases, for clients that reject dotted tool names. Use --alias-mode both to expose canonical and alias names.
|
|
14211
|
+
Use --result-format v2 to return one stable ok/summary/data/proposal/error envelope from every tool call.
|
|
14212
|
+
`,
|
|
14213
|
+
"mcp serve-streamable-http": `Usage:
|
|
14214
|
+
export SYNAPSOR_RUNNER_HTTP_TOKEN=...
|
|
14215
|
+
${cmd} mcp serve-streamable-http --config ./synapsor.runner.json --store ./.synapsor/local.db [--host 127.0.0.1] [--port 8766] [--auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN] [--alias-mode canonical|openai|both] [--result-format v1|v2]
|
|
14216
|
+
|
|
14217
|
+
Start the spec-compatible MCP Streamable HTTP endpoint for clients and SDKs that support HTTP MCP.
|
|
14218
|
+
Bearer auth is required by default.
|
|
14219
|
+
|
|
14220
|
+
Alpha scope:
|
|
14221
|
+
- Supports MCP initialize/session behavior through the official MCP Streamable HTTP transport.
|
|
14222
|
+
- Use --alias-mode openai, or --openai-tool-aliases, for clients that reject dotted tool names.
|
|
14223
|
+
- Use --alias-mode both to expose canonical names and aliases.
|
|
14224
|
+
- Use --result-format v2 for the stable ok/summary/data/proposal/error envelope.
|
|
14225
|
+
- OpenAI aliases expose names such as billing__inspect_invoice while preserving the canonical Synapsor name in _meta.
|
|
14226
|
+
- Use /mcp for the MCP endpoint and /healthz for service health.
|
|
14227
|
+
- Sessions are in-memory. Restarting the runner clears active HTTP MCP sessions.
|
|
14228
|
+
|
|
14229
|
+
Security:
|
|
14230
|
+
- Defaults to 127.0.0.1:8766.
|
|
14231
|
+
- Refuses to start if the auth token env var is missing.
|
|
14232
|
+
- Use --dev-no-auth only for localhost development.
|
|
14233
|
+
- If binding to 0.0.0.0, use TLS, private networking, authentication, and rate limits.
|
|
14234
|
+
- Optional CORS: --cors-origin http://localhost:3000
|
|
11567
14235
|
`,
|
|
11568
14236
|
"mcp serve-http": `Usage:
|
|
11569
14237
|
export SYNAPSOR_RUNNER_HTTP_TOKEN=...
|
|
11570
|
-
${cmd} mcp serve-http --config ./synapsor.runner.json --store ./.synapsor/local.db [--host 127.0.0.1] [--port 8765] [--auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN]
|
|
14238
|
+
${cmd} mcp serve-http --config ./synapsor.runner.json --store ./.synapsor/local.db [--host 127.0.0.1] [--port 8765] [--auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN] [--result-format v1|v2]
|
|
11571
14239
|
|
|
11572
|
-
Start the HTTP JSON-RPC
|
|
14240
|
+
Start the lightweight HTTP JSON-RPC bridge for app/server deployments that want simple POST calls.
|
|
14241
|
+
Bearer auth is required by default.
|
|
11573
14242
|
|
|
11574
14243
|
Alpha scope: supports POST /mcp methods tools/list, tools/call, and resources/read.
|
|
11575
|
-
It does not implement
|
|
14244
|
+
It does not implement MCP Streamable HTTP initialize/session behavior. Use ${cmd} mcp serve-streamable-http for standard HTTP MCP clients.
|
|
11576
14245
|
|
|
11577
14246
|
Security:
|
|
11578
14247
|
- Defaults to 127.0.0.1:8765.
|
|
@@ -11582,9 +14251,67 @@ Security:
|
|
|
11582
14251
|
- Optional CORS: --cors-origin http://localhost:3000
|
|
11583
14252
|
`,
|
|
11584
14253
|
"mcp config": `Usage:
|
|
11585
|
-
${cmd} mcp config [claude-desktop|cursor|generic|vscode] [--absolute-paths] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
|
|
14254
|
+
${cmd} mcp config [claude-desktop|cursor|generic|vscode|openai-agents] [--absolute-paths] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
|
|
14255
|
+
${cmd} mcp client-config --client openai-agents [--transport streamable-http] [--port 8766] [--alias-mode openai] [--include-instructions] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
|
|
11586
14256
|
|
|
11587
14257
|
Print MCP client configuration that references the local runner command, not database URLs. Defaults to claude-desktop.
|
|
14258
|
+
OpenAI Agents SDK output uses Streamable HTTP and OpenAI-safe aliases by default.
|
|
14259
|
+
`,
|
|
14260
|
+
"mcp client-config": `Usage:
|
|
14261
|
+
${cmd} mcp client-config --client claude-desktop [--absolute-paths] [--include-instructions] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
|
|
14262
|
+
${cmd} mcp client-config --client cursor [--absolute-paths] [--include-instructions] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
|
|
14263
|
+
${cmd} mcp client-config --client openai-agents [--transport streamable-http] [--port 8766] [--alias-mode openai] [--include-instructions] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
|
|
14264
|
+
|
|
14265
|
+
Print MCP client configuration that references the local runner command, not database URLs.
|
|
14266
|
+
OpenAI Agents SDK output uses Streamable HTTP and OpenAI-safe aliases by default.
|
|
14267
|
+
Use --include-instructions to include the recommended propose-first agent prompt.
|
|
14268
|
+
`,
|
|
14269
|
+
smoke: `Usage:
|
|
14270
|
+
${cmd} smoke call [capability-name] [--sample] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
|
|
14271
|
+
${cmd} smoke call [capability-name] --json '{"record_id":"..."}'
|
|
14272
|
+
${cmd} smoke boundary [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
|
|
14273
|
+
|
|
14274
|
+
Call a generated semantic tool locally before wiring Claude, Cursor, or another MCP client. The call uses the same runtime as MCP, records evidence/query audit/proposals in the local store, and does not expose raw SQL or write credentials.
|
|
14275
|
+
`,
|
|
14276
|
+
writeback: `Usage:
|
|
14277
|
+
${cmd} writeback doctor --config ./synapsor.runner.json [--check-db]
|
|
14278
|
+
${cmd} writeback migration --engine postgres [--schema synapsor]
|
|
14279
|
+
${cmd} writeback migration --engine mysql [--schema appdb]
|
|
14280
|
+
${cmd} writeback grants --engine postgres --writer-role app_writer [--schema synapsor]
|
|
14281
|
+
${cmd} writeback grants --engine mysql --writer-role "'app_writer'@'%'" [--schema appdb]
|
|
14282
|
+
|
|
14283
|
+
Print and verify the receipt-table setup required by direct SQL writeback. Rich writes should prefer app-owned http_handler or command_handler executors.
|
|
14284
|
+
`,
|
|
14285
|
+
handler: `Usage:
|
|
14286
|
+
${cmd} handler template --list
|
|
14287
|
+
${cmd} handler template node-fastify [--output ./synapsor-writeback-handler.mjs] [--force]
|
|
14288
|
+
${cmd} handler template python-fastapi [--output ./synapsor_writeback_handler.py] [--force]
|
|
14289
|
+
${cmd} handler template command [--output ./synapsor-command-handler.mjs] [--force]
|
|
14290
|
+
|
|
14291
|
+
Write starter app-owned writeback handlers for approved proposals. Use these when rich writes should run through your application service instead of Runner-managed SQL.
|
|
14292
|
+
`,
|
|
14293
|
+
"handler template": `Usage:
|
|
14294
|
+
${cmd} handler template --list
|
|
14295
|
+
${cmd} handler template node-fastify [--output ./synapsor-writeback-handler.mjs] [--force]
|
|
14296
|
+
${cmd} handler template python-fastapi [--output ./synapsor_writeback_handler.py] [--force]
|
|
14297
|
+
${cmd} handler template command [--output ./synapsor-command-handler.mjs] [--force]
|
|
14298
|
+
${cmd} handler template node-fastify --stdout
|
|
14299
|
+
|
|
14300
|
+
Templates:
|
|
14301
|
+
node-fastify HTTP handler for a Node/Fastify application service
|
|
14302
|
+
python-fastapi HTTP handler for a Python/FastAPI application service
|
|
14303
|
+
command Local command handler for scripts or job runners
|
|
14304
|
+
|
|
14305
|
+
The template receives an approved proposal writeback request and must return an applied/conflict/failed receipt. Re-check tenant, principal, idempotency, row/version guards, and business policy before mutating state.
|
|
14306
|
+
`,
|
|
14307
|
+
onboard: `Usage:
|
|
14308
|
+
${cmd} onboard db --from-env DATABASE_URL [--schema public] [--mode read_only|shadow|review]
|
|
14309
|
+
${cmd} onboard db --from-env DATABASE_URL --table invoices --mode review --patch late_fee_cents=fixed:0 --write-url-env SYNAPSOR_DATABASE_WRITE_URL --yes
|
|
14310
|
+
${cmd} onboard db --from-env DATABASE_URL --table invoices --mode review --writeback http_handler --handler-url-env APP_WRITEBACK_URL --emit-handler --yes
|
|
14311
|
+
${cmd} onboard db --answers answers.json --yes
|
|
14312
|
+
|
|
14313
|
+
Guided own-database setup: inspect schema, choose one object, create trusted context, choose read-only/shadow/review writeback mode, generate semantic tools, validate config, run a tool-boundary smoke check, and print the MCP/UI next steps.
|
|
14314
|
+
Use --yes/--non-interactive with explicit flags, or --answers, when CI or an LLM agent must run without prompts.
|
|
11588
14315
|
`,
|
|
11589
14316
|
propose: `Usage:
|
|
11590
14317
|
${cmd} propose <capability-name> --sample [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
|
|
@@ -11612,10 +14339,13 @@ Static MCP/database risk review only. This is not a security guarantee.
|
|
|
11612
14339
|
doctor: `Usage:
|
|
11613
14340
|
${cmd} doctor --config synapsor.runner.json
|
|
11614
14341
|
${cmd} doctor --config synapsor.runner.json --json
|
|
14342
|
+
${cmd} doctor --config synapsor.runner.json --check-handlers
|
|
14343
|
+
${cmd} doctor --config synapsor.runner.json --check-writeback
|
|
11615
14344
|
${cmd} doctor --config synapsor.runner.json --report --redact --output synapsor-doctor.md
|
|
11616
14345
|
${cmd} doctor --first-run
|
|
11617
14346
|
|
|
11618
|
-
Validate local config, environment bindings, semantic tool boundary, source metadata when reachable, and local store stats. Reports are redacted; do not paste secrets into issues.
|
|
14347
|
+
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.
|
|
14348
|
+
Use --check-writeback only after reviewing receipt-table DDL/grants; it connects with the trusted writer and can create the receipt table if the writer has permission.
|
|
11619
14349
|
`,
|
|
11620
14350
|
proposals: `Usage:
|
|
11621
14351
|
${cmd} proposals list [--tenant acme] [--capability billing.propose_late_fee_waiver] [--object invoice:INV-3001] [--status applied]
|
|
@@ -11683,13 +14413,26 @@ or an administrator must pre-create and grant it.
|
|
|
11683
14413
|
|
|
11684
14414
|
Search the local SQLite evidence/replay ledger across proposals, evidence, query audit, receipts, and replay records.
|
|
11685
14415
|
`,
|
|
14416
|
+
events: `Usage:
|
|
14417
|
+
${cmd} events tail --store ./.synapsor/local.db
|
|
14418
|
+
${cmd} events tail --proposal wrp_...
|
|
14419
|
+
${cmd} events tail --kind writeback_applied
|
|
14420
|
+
${cmd} events tail --follow --interval-ms 1000
|
|
14421
|
+
${cmd} events webhook --url http://127.0.0.1:8788/synapsor/events --kind proposal_created
|
|
14422
|
+
${cmd} events webhook --url-env SYNAPSOR_EVENT_WEBHOOK_URL --auth-token-env SYNAPSOR_EVENT_WEBHOOK_TOKEN --follow
|
|
14423
|
+
${cmd} events webhook --url http://127.0.0.1:8788/synapsor/events --dry-run
|
|
14424
|
+
|
|
14425
|
+
Show or push local proposal/writeback lifecycle events such as proposal_created, proposal_approved, writeback_applied, writeback_conflict, and writeback_failed. Webhook delivery POSTs one local event envelope per event and never exposes database credentials.
|
|
14426
|
+
`,
|
|
11686
14427
|
store: `Usage:
|
|
11687
14428
|
${cmd} store stats --store ./.synapsor/local.db
|
|
11688
14429
|
${cmd} store vacuum --store ./.synapsor/local.db
|
|
11689
14430
|
${cmd} store prune --store ./.synapsor/local.db --older-than 30d --dry-run
|
|
11690
14431
|
${cmd} store prune --store ./.synapsor/local.db --older-than 30d --yes
|
|
14432
|
+
${cmd} store prune --store ./.synapsor/local.db --older-than 30d --yes --force
|
|
14433
|
+
${cmd} store reset --store ./.synapsor/local.db --yes
|
|
11691
14434
|
|
|
11692
|
-
Local store maintenance only. Prune defaults to dry-run and never
|
|
14435
|
+
Local store maintenance only. Prune defaults to dry-run and reset requires --yes. These commands never touch your source Postgres/MySQL database. Destructive operations refuse while an active server lease exists unless --force is provided.
|
|
11693
14436
|
`,
|
|
11694
14437
|
demo: `Usage:
|
|
11695
14438
|
${cmd} demo [--force]
|
|
@@ -11704,9 +14447,10 @@ Local store maintenance only. Prune defaults to dry-run and never touches your s
|
|
|
11704
14447
|
Use --quick for a fixture-only guided walkthrough and local ledger seed with no Docker startup. Use demo inspect to print follow-up commands for the quick-demo fixture.
|
|
11705
14448
|
`,
|
|
11706
14449
|
ui: `Usage:
|
|
11707
|
-
${cmd} ui [--tour] [--config synapsor.runner.json] [--store ./.synapsor/local.db]
|
|
14450
|
+
${cmd} ui [--open] [--tour] [--config synapsor.runner.json] [--store ./.synapsor/local.db]
|
|
11708
14451
|
|
|
11709
14452
|
Open the localhost review UI for proposals, diffs, evidence, receipts, and replay.
|
|
14453
|
+
Use --open to launch the URL in your browser when a desktop opener is available.
|
|
11710
14454
|
`
|
|
11711
14455
|
};
|
|
11712
14456
|
process2.stdout.write(help[key] ?? help[command ?? ""] ?? help[""] ?? "");
|