@synapsor/client 0.1.5 → 0.1.6

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/README.md CHANGED
@@ -181,6 +181,26 @@ The worker validates Synapsor-provided mapping metadata, only writes allowlisted
181
181
  columns, adds tenant and primary-key guards, checks optional conflict columns,
182
182
  and reports `applied`, `conflict`, or `failed` back to Synapsor idempotently.
183
183
 
184
+ ## CLI MCP Risk Review
185
+
186
+ The package installs a `synapsor` CLI. Use `synapsor mcp audit <target>` for a
187
+ static MCP database risk review of exported tool manifests, remote `tools/list`
188
+ endpoints, or stdio MCP servers.
189
+
190
+ ```bash
191
+ synapsor mcp audit ./tools-manifest.json
192
+ synapsor mcp audit https://mcp.example.com --bearer-env MCP_AUDIT_TOKEN --json
193
+ synapsor mcp audit 'stdio:node ./server.mjs' --timeout-ms 5000
194
+ ```
195
+
196
+ The audit flags patterns such as generic `execute_sql` tools, model-controlled
197
+ tenant/table/column inputs, model-callable approval or commit tools, missing
198
+ proposal boundaries, missing structured output schemas, and missing idempotency
199
+ or conflict-guard metadata. It never calls business tools during the audit.
200
+
201
+ This is a static MCP database risk review, not proof that an MCP server is
202
+ secure. MCP annotations are treated as descriptive hints, not enforcement.
203
+
184
204
  ## API Surface
185
205
 
186
206
  - `execute(sql)` and `query(sql)`
package/bin/synapsor.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
+ import { spawn } from "node:child_process";
4
5
  import { homedir } from "node:os";
5
6
  import { createRequire } from "node:module";
6
7
  import { dirname, join } from "node:path";
@@ -11,6 +12,7 @@ import { stdin as input, stdout as output } from "node:process";
11
12
  const require = createRequire(import.meta.url);
12
13
  const { version: PACKAGE_VERSION } = require("../package.json");
13
14
  const DEFAULT_BASE_URL = "https://synapsor.ai";
15
+ const MCP_AUDIT_DISCLAIMER = "This is a static MCP database risk review, not proof that an MCP server is secure.";
14
16
  const ERROR_HINTS = new Map([
15
17
  ["AUTH_REQUIRED", "Set SYNAPSOR_API_KEY or run `synapsor config set api-key`."],
16
18
  ["BILLING_REQUIRED", "Builder resources require active or trialing Builder billing."],
@@ -176,6 +178,13 @@ function boolFlag(args, name) {
176
178
  return args.includes(name);
177
179
  }
178
180
 
181
+ function intFlag(args, name, fallback) {
182
+ const value = flagValue(args, name, "");
183
+ if (!value) return fallback;
184
+ const parsed = Number.parseInt(value, 10);
185
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
186
+ }
187
+
179
188
  function resolveSecretRef(value) {
180
189
  const text = String(value || "").trim();
181
190
  if (text.startsWith("env:")) {
@@ -221,6 +230,250 @@ async function readStdinSecret() {
221
230
  return value.trim();
222
231
  }
223
232
 
233
+ async function loadMcpAuditTarget(target, args) {
234
+ if (!target) throw new Error("MCP_AUDIT_TARGET_REQUIRED: usage `synapsor mcp audit <target>`");
235
+ const timeoutMs = intFlag(args, "--timeout-ms", 5000);
236
+ if (/^https?:\/\//i.test(target)) {
237
+ return { target, kind: "remote_tools_list", payload: await fetchRemoteMcpTools(target, args, timeoutMs) };
238
+ }
239
+ if (target.startsWith("stdio:")) {
240
+ const command = target.slice("stdio:".length).trim();
241
+ if (!command) throw new Error("MCP_STDIO_COMMAND_REQUIRED: usage `synapsor mcp audit 'stdio:node server.js'`");
242
+ return { target, kind: "stdio_tools_list", payload: await fetchStdioMcpTools(command, timeoutMs) };
243
+ }
244
+ const raw = await readFile(target, "utf8");
245
+ return { target, kind: "manifest", payload: JSON.parse(raw) };
246
+ }
247
+
248
+ async function fetchRemoteMcpTools(url, args, timeoutMs) {
249
+ const bearerEnv = flagValue(args, "--bearer-env", "SYNAPSOR_MCP_AUDIT_BEARER");
250
+ const bearer = bearerEnv ? process.env[bearerEnv] || "" : "";
251
+ const controller = new AbortController();
252
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
253
+ try {
254
+ const response = await fetch(url, {
255
+ method: "POST",
256
+ signal: controller.signal,
257
+ headers: {
258
+ accept: "application/json",
259
+ "content-type": "application/json",
260
+ ...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
261
+ },
262
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
263
+ });
264
+ const text = await response.text();
265
+ const payload = text.trim() ? JSON.parse(text) : {};
266
+ if (!response.ok) throw new Error(`MCP_AUDIT_REMOTE_FAILED: HTTP ${response.status}`);
267
+ return payload;
268
+ } finally {
269
+ clearTimeout(timer);
270
+ }
271
+ }
272
+
273
+ function splitCommand(command) {
274
+ const parts = [];
275
+ const matcher = /"([^"]*)"|'([^']*)'|(\S+)/g;
276
+ let match;
277
+ while ((match = matcher.exec(command)) !== null) {
278
+ parts.push(match[1] ?? match[2] ?? match[3]);
279
+ }
280
+ return parts;
281
+ }
282
+
283
+ async function fetchStdioMcpTools(command, timeoutMs) {
284
+ const [bin, ...args] = splitCommand(command);
285
+ if (!bin) throw new Error("MCP_STDIO_COMMAND_REQUIRED: empty stdio command");
286
+ return await new Promise((resolve, reject) => {
287
+ const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] });
288
+ let stdout = "";
289
+ let stderr = "";
290
+ const timer = setTimeout(() => {
291
+ child.kill("SIGTERM");
292
+ reject(new Error(`MCP_AUDIT_TIMEOUT: tools/list did not complete within ${timeoutMs}ms`));
293
+ }, timeoutMs);
294
+ child.stdout.on("data", (chunk) => {
295
+ stdout += String(chunk);
296
+ const response = parseJsonRpcLine(stdout, 2);
297
+ if (response) {
298
+ clearTimeout(timer);
299
+ child.kill("SIGTERM");
300
+ resolve(response);
301
+ }
302
+ });
303
+ child.stderr.on("data", (chunk) => {
304
+ stderr += String(chunk);
305
+ });
306
+ child.on("error", (error) => {
307
+ clearTimeout(timer);
308
+ reject(error);
309
+ });
310
+ child.on("close", () => {
311
+ const response = parseJsonRpcLine(stdout, 2);
312
+ if (response) return;
313
+ clearTimeout(timer);
314
+ reject(new Error(`MCP_AUDIT_STDIO_FAILED: tools/list response not found${stderr ? `: ${stderr.slice(0, 240)}` : ""}`));
315
+ });
316
+ child.stdin.write(`${JSON.stringify({
317
+ jsonrpc: "2.0",
318
+ id: 1,
319
+ method: "initialize",
320
+ params: {
321
+ protocolVersion: "2025-11-25",
322
+ capabilities: {},
323
+ clientInfo: { name: "synapsor-mcp-audit", version: PACKAGE_VERSION },
324
+ },
325
+ })}\n`);
326
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} })}\n`);
327
+ });
328
+ }
329
+
330
+ function parseJsonRpcLine(text, id) {
331
+ for (const line of text.split(/\r?\n/)) {
332
+ if (!line.trim()) continue;
333
+ try {
334
+ const parsed = JSON.parse(line);
335
+ if (parsed.id === id) return parsed;
336
+ } catch {
337
+ // Ignore non-JSON logs from a local stdio server.
338
+ }
339
+ }
340
+ return null;
341
+ }
342
+
343
+ function extractMcpTools(payload) {
344
+ if (Array.isArray(payload)) return { tools: payload, source: "array_manifest" };
345
+ if (!payload || typeof payload !== "object") return { tools: [], source: "unknown" };
346
+ if (Array.isArray(payload.tools)) return { tools: payload.tools, source: "tool_manifest" };
347
+ if (Array.isArray(payload.result?.tools)) return { tools: payload.result.tools, source: "json_rpc_tools_list" };
348
+ if (Array.isArray(payload.tool_manifest?.tools)) return { tools: payload.tool_manifest.tools, source: "tool_manifest" };
349
+ if (payload.mcpServers && typeof payload.mcpServers === "object") {
350
+ return { tools: [], source: "mcp_client_config", serverCount: Object.keys(payload.mcpServers).length };
351
+ }
352
+ return { tools: [], source: "unknown" };
353
+ }
354
+
355
+ function propNames(schema) {
356
+ if (!schema || typeof schema !== "object") return [];
357
+ const properties = schema.properties && typeof schema.properties === "object" ? schema.properties : {};
358
+ return Object.keys(properties);
359
+ }
360
+
361
+ function schemaText(schema) {
362
+ try {
363
+ return JSON.stringify(schema || {}).toLowerCase();
364
+ } catch {
365
+ return "";
366
+ }
367
+ }
368
+
369
+ function hasAny(text, patterns) {
370
+ return patterns.some((pattern) => pattern.test(text));
371
+ }
372
+
373
+ function buildMcpAuditReport(targetInfo) {
374
+ const { tools, source, serverCount } = extractMcpTools(targetInfo.payload);
375
+ const findings = [];
376
+ const add = (severity, code, message, details = {}) => findings.push({ severity, code, message, ...details });
377
+ if (source === "mcp_client_config") {
378
+ add(
379
+ "LOW",
380
+ "MCP_CLIENT_CONFIG_ONLY",
381
+ "MCP client config found; export or query tools/list for schema-level database risk review.",
382
+ { evidence: `${serverCount || 0} configured MCP server(s)` },
383
+ );
384
+ }
385
+ if (!tools.length && source !== "mcp_client_config") {
386
+ add("MEDIUM", "NO_TOOLS_FOUND", "No MCP tools were found in the target manifest or tools/list response.");
387
+ }
388
+
389
+ for (const tool of tools) {
390
+ const name = String(tool.name || tool.id || tool.title || "unnamed_tool");
391
+ const description = String(tool.description || "");
392
+ const inputSchema = tool.inputSchema || tool.input_schema || tool.parameters || {};
393
+ const outputSchema = tool.outputSchema || tool.output_schema || tool.resultSchema || tool.result_schema || null;
394
+ const props = propNames(inputSchema);
395
+ const propsLower = props.map((prop) => prop.toLowerCase());
396
+ const text = `${name} ${description} ${schemaText(inputSchema)} ${schemaText(outputSchema)}`.toLowerCase();
397
+ const writeTool = hasAny(text, [
398
+ /\b(writes?|written|updates?|updated|deletes?|deleted|inserts?|inserted|upserts?|upserted|mutates?|mutated|refunds?|refunded|waives?|waived|charges?|charged|credits?|credited|commits?|committed|settles?|settled|merges?|merged)\b/,
399
+ /(^|[._-])(approve|close|resolve|cancel)([._-]|$)/,
400
+ ]);
401
+ const readTool = hasAny(text, [/\b(read|get|list|query|inspect|fetch|search)\b/]);
402
+ const proposalBoundary = hasAny(text, [/\b(proposal|propose|approval|review_required|writeback|change[-_ ]?set|diff|evidence)\b/]);
403
+ const idempotency = hasAny(text, [/\b(idempotency|idempotent|request_id|requestid|idempotency_key|idempotencykey)\b/]);
404
+ const conflictGuard = hasAny(text, [/\b(expected_version|row_version|updated_at|etag|version|conflict_guard|conflict guard|optimistic)\b/]);
405
+
406
+ if (/(^|[._-])(execute_sql|exec_sql|run_query|raw_sql|sql_query|query_sql|execute_query)([._-]|$)/.test(name.toLowerCase())) {
407
+ add("HIGH", "GENERIC_SQL_TOOL", "Generic execute_sql or run_query-style database tool exposed.", { tool: name });
408
+ }
409
+ if (propsLower.some((prop) => ["sql", "query", "statement", "raw_sql"].includes(prop)) && writeTool) {
410
+ add("HIGH", "WRITE_ACCEPTS_SQL", "Write-capable tool accepts arbitrary SQL/query/statement input.", { tool: name, evidence: props.join(", ") });
411
+ }
412
+ if (propsLower.some((prop) => ["schema", "table", "table_name", "tablename", "column", "columns", "database", "db"].includes(prop))) {
413
+ add("HIGH", "MODEL_CONTROLLED_IDENTIFIER", "Tool accepts schema/table/column identifiers as ordinary model input.", { tool: name, evidence: props.join(", ") });
414
+ }
415
+ if (propsLower.some((prop) => ["tenant", "tenant_id", "tenantid"].includes(prop))) {
416
+ add("HIGH", "MODEL_CONTROLLED_TENANT", "Tool accepts tenant_id as ordinary model input instead of trusted session context.", { tool: name, evidence: props.join(", ") });
417
+ }
418
+ if (/(^|[._-])(approve|commit|settle|merge)[._-]?(proposal|write|change)|(^|[._-])(proposal|write|change)[._-]?(approve|commit|settle|merge)([._-]|$)/.test(name.toLowerCase())) {
419
+ add("HIGH", "MODEL_CALLABLE_APPROVAL", "Approval or commit operation is exposed as a model-callable MCP tool.", { tool: name });
420
+ }
421
+ if (writeTool && !proposalBoundary) {
422
+ add("HIGH", "NO_VISIBLE_PROPOSAL_BOUNDARY", "Write-capable tool has no visible proposal, approval, evidence, or writeback boundary.", { tool: name });
423
+ }
424
+ if (!outputSchema) {
425
+ add("MEDIUM", "NO_STRUCTURED_OUTPUT_SCHEMA", "Tool has no structured output schema visible in the manifest.", { tool: name });
426
+ }
427
+ if (writeTool && !idempotency) {
428
+ add("MEDIUM", "NO_IDEMPOTENCY_KEY", "Write/proposal tool has no visible idempotency or request-key metadata.", { tool: name });
429
+ }
430
+ if (writeTool && !conflictGuard) {
431
+ add("MEDIUM", "NO_CONFLICT_GUARD", "Write/proposal tool has no visible row-version or conflict-guard metadata.", { tool: name });
432
+ }
433
+ if (readTool && writeTool) {
434
+ add("MEDIUM", "AMBIGUOUS_READ_WRITE_TOOL", "Tool mixes read and write behavior ambiguously.", { tool: name });
435
+ }
436
+ if (!tool.annotations) {
437
+ add("LOW", "MISSING_TOOL_ANNOTATIONS", "Tool is missing descriptive MCP annotations. Annotations are hints, not enforcement.", { tool: name });
438
+ }
439
+ }
440
+
441
+ const severityRank = { HIGH: 0, MEDIUM: 1, LOW: 2 };
442
+ findings.sort((a, b) => severityRank[a.severity] - severityRank[b.severity] || String(a.tool || "").localeCompare(String(b.tool || "")) || a.code.localeCompare(b.code));
443
+ const summary = { high: 0, medium: 0, low: 0 };
444
+ for (const finding of findings) summary[finding.severity.toLowerCase()] += 1;
445
+ return {
446
+ ok: true,
447
+ target: targetInfo.target,
448
+ source,
449
+ inspected_at: new Date().toISOString(),
450
+ disclaimer: MCP_AUDIT_DISCLAIMER,
451
+ tool_count: tools.length,
452
+ summary,
453
+ findings,
454
+ };
455
+ }
456
+
457
+ function printMcpAudit(report, globals) {
458
+ if (globals.json) return print(report, globals);
459
+ console.log("Static MCP database risk review");
460
+ console.log(`Target: ${report.target}`);
461
+ console.log(`Tools inspected: ${report.tool_count}`);
462
+ console.log(`Summary: HIGH=${report.summary.high} MEDIUM=${report.summary.medium} LOW=${report.summary.low}`);
463
+ console.log(MCP_AUDIT_DISCLAIMER);
464
+ if (!report.findings.length) {
465
+ console.log("No matching database MCP risk patterns found. This is not a security guarantee.");
466
+ return;
467
+ }
468
+ console.log("\nFindings:");
469
+ for (const finding of report.findings) {
470
+ const tool = finding.tool ? ` tool=${finding.tool}` : "";
471
+ const evidence = finding.evidence ? ` evidence=${finding.evidence}` : "";
472
+ console.log(`${finding.severity.padEnd(6)} ${finding.code}${tool}${evidence}`);
473
+ console.log(` ${finding.message}`);
474
+ }
475
+ }
476
+
224
477
  async function main(argv = process.argv.slice(2)) {
225
478
  const { globals, args } = parse(argv);
226
479
  const [cmd, sub, third, ...rest] = args;
@@ -375,6 +628,11 @@ async function main(argv = process.argv.slice(2)) {
375
628
  }
376
629
  }
377
630
 
631
+ if (cmd === "mcp" && sub === "audit") {
632
+ const targetInfo = await loadMcpAuditTarget(third, rest);
633
+ return printMcpAudit(buildMcpAuditReport(targetInfo), globals);
634
+ }
635
+
378
636
  if (cmd === "sql") {
379
637
  const db = globals.db || "";
380
638
  let sql = args.slice(1).join(" ").trim();
@@ -438,6 +696,9 @@ Usage:
438
696
  synapsor sources show app_postgres --project <project>
439
697
  synapsor sources doctor app_postgres --project <project>
440
698
  synapsor sources disable app_postgres --project <project> --yes
699
+ synapsor mcp audit ./tools-manifest.json
700
+ synapsor mcp audit https://mcp.example.com --bearer-env MCP_AUDIT_TOKEN --json
701
+ synapsor mcp audit 'stdio:node ./server.mjs' --timeout-ms 5000
441
702
  synapsor sql --db <database> "SELECT 1;"
442
703
  synapsor sql --db <database> --file ./query.sql
443
704
  synapsor invoke <capability> --db <database> --json '{"ticket_id":"TICK-1001"}'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synapsor/client",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Node.js SDK for Synapsor, the agent-native database for auditable AI applications",
5
5
  "type": "module",
6
6
  "main": "./synapsor.mjs",
package/synapsor.mjs CHANGED
@@ -19,6 +19,17 @@ export {
19
19
  PROTOCOL_VERSION,
20
20
  };
21
21
 
22
+ export const WRITEBACK_LEASE_EXPIRED = "WRITEBACK_LEASE_EXPIRED";
23
+ export const WRITEBACK_ALREADY_COMPLETED = "WRITEBACK_ALREADY_COMPLETED";
24
+ export const WRITEBACK_CONFLICT = "WRITEBACK_CONFLICT";
25
+ export const WRITEBACK_SAFETY_FAILURE = "WRITEBACK_SAFETY_FAILURE";
26
+ export const WRITEBACK_ERROR_CODES = Object.freeze([
27
+ WRITEBACK_LEASE_EXPIRED,
28
+ WRITEBACK_ALREADY_COMPLETED,
29
+ WRITEBACK_CONFLICT,
30
+ WRITEBACK_SAFETY_FAILURE,
31
+ ]);
32
+
22
33
  export class SynapsorError extends Error {
23
34
  constructor(
24
35
  message,
@@ -644,6 +655,10 @@ export class Synapsor {
644
655
  });
645
656
  }
646
657
 
658
+ async listAdapterTools(adapter, options = {}) {
659
+ return this.adapterTools(adapter, options);
660
+ }
661
+
647
662
  async callAdapterTool(adapter, tool, {
648
663
  arguments: args = {},
649
664
  runId = undefined,
@@ -669,6 +684,138 @@ export class Synapsor {
669
684
  return this.request("POST", "/v1/agent/adapters/call-tool", payload);
670
685
  }
671
686
 
687
+ async createWritebackRunnerToken(sourceId, {
688
+ runnerId = undefined,
689
+ runner_id = undefined,
690
+ tokenId = undefined,
691
+ token_id = undefined,
692
+ permissions = undefined,
693
+ expiresAt = undefined,
694
+ expires_at = undefined,
695
+ } = {}) {
696
+ const payload = {};
697
+ const runner = runnerId ?? runner_id;
698
+ const token = tokenId ?? token_id;
699
+ const expiry = expiresAt ?? expires_at;
700
+ if (runner !== undefined) payload.runner_id = runner;
701
+ if (token !== undefined) payload.token_id = token;
702
+ if (permissions !== undefined) payload.permissions = permissions;
703
+ if (expiry !== undefined) payload.expires_at = expiry;
704
+ return this.request("POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/writeback-runner-tokens`, payload);
705
+ }
706
+
707
+ async createRunnerToken(sourceId, options = {}) {
708
+ return this.createWritebackRunnerToken(sourceId, options);
709
+ }
710
+
711
+ async listWritebackRunnerTokens(sourceId) {
712
+ return this.request("GET", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/writeback-runner-tokens`);
713
+ }
714
+
715
+ async listWritebackRunners(sourceId) {
716
+ return this.request("GET", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/writeback-runners`);
717
+ }
718
+
719
+ async listRunners(sourceId) {
720
+ return this.listWritebackRunners(sourceId);
721
+ }
722
+
723
+ async revokeWritebackRunnerToken(sourceId, tokenId, { reason = undefined } = {}) {
724
+ const payload = {};
725
+ if (reason !== undefined) payload.reason = reason;
726
+ return this.request("POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/writeback-runner-tokens/${encodeURIComponent(tokenId)}/revoke`, payload);
727
+ }
728
+
729
+ async listExternalWritebackProposals({
730
+ projectId = undefined,
731
+ project_id = undefined,
732
+ sourceId = undefined,
733
+ source_id = undefined,
734
+ status = undefined,
735
+ limit = undefined,
736
+ } = {}) {
737
+ return this.request("GET", withQuery("/v1/control/external-writebacks/proposals", {
738
+ project_id: projectId ?? project_id,
739
+ source_id: sourceId ?? source_id,
740
+ status,
741
+ limit,
742
+ }));
743
+ }
744
+
745
+ async getExternalWritebackProposal(proposalId) {
746
+ return this.request("GET", `/v1/control/external-writebacks/proposals/${encodeURIComponent(proposalId)}`);
747
+ }
748
+
749
+ async getProposal(proposalId) {
750
+ return this.getExternalWritebackProposal(proposalId);
751
+ }
752
+
753
+ async createExternalWritebackProposal(payload) {
754
+ return this.request("POST", "/v1/control/external-writebacks/proposals", payload);
755
+ }
756
+
757
+ async transitionExternalWritebackProposal(proposalId, action, payload = {}) {
758
+ return this.request("POST", `/v1/control/external-writebacks/proposals/${encodeURIComponent(proposalId)}/${encodeURIComponent(action)}`, payload);
759
+ }
760
+
761
+ async approveExternalWritebackProposal(proposalId, payload = {}) {
762
+ return this.transitionExternalWritebackProposal(proposalId, "approve", payload);
763
+ }
764
+
765
+ async rejectExternalWritebackProposal(proposalId, payload = {}) {
766
+ return this.transitionExternalWritebackProposal(proposalId, "reject", payload);
767
+ }
768
+
769
+ async recordExternalWritebackApplyResult(proposalId, payload) {
770
+ return this.request("POST", `/v1/control/external-writebacks/proposals/${encodeURIComponent(proposalId)}/apply-result`, payload);
771
+ }
772
+
773
+ async doctorWritebackRunner() {
774
+ return this.request("GET", "/v1/writeback/runner/doctor");
775
+ }
776
+
777
+ async registerRunner(payload) {
778
+ return this.request("POST", "/v1/runner/register", payload);
779
+ }
780
+
781
+ async heartbeatRunner(payload) {
782
+ return this.request("POST", "/v1/runner/heartbeat", payload);
783
+ }
784
+
785
+ async claimWritebackJob({
786
+ sourceId = undefined,
787
+ source_id = undefined,
788
+ limit = undefined,
789
+ leaseSeconds = undefined,
790
+ lease_seconds = undefined,
791
+ } = {}) {
792
+ const payload = {};
793
+ const source = sourceId ?? source_id;
794
+ const lease = leaseSeconds ?? lease_seconds;
795
+ if (source !== undefined) payload.source_id = source;
796
+ if (limit !== undefined) payload.limit = Number(limit);
797
+ if (lease !== undefined) payload.lease_seconds = Number(lease);
798
+ return this.request("POST", "/v1/writeback/jobs/claim", payload);
799
+ }
800
+
801
+ async renewWritebackLease(jobId, {
802
+ leaseSeconds = undefined,
803
+ lease_seconds = undefined,
804
+ } = {}) {
805
+ const payload = {};
806
+ const lease = leaseSeconds ?? lease_seconds;
807
+ if (lease !== undefined) payload.lease_seconds = Number(lease);
808
+ return this.request("POST", `/v1/writeback/jobs/${encodeURIComponent(jobId)}/heartbeat`, payload);
809
+ }
810
+
811
+ async completeWritebackJob(jobId, result) {
812
+ return this.request("POST", `/v1/writeback/jobs/${encodeURIComponent(jobId)}/result`, result);
813
+ }
814
+
815
+ async recordWritebackJobResult(jobId, result) {
816
+ return this.completeWritebackJob(jobId, result);
817
+ }
818
+
672
819
  async traceAgentRun(runId, { session = undefined } = {}) {
673
820
  return this.request("POST", "/v1/agent/runs/trace", {
674
821
  run_id: runId,
@@ -1053,6 +1200,10 @@ export class Synapsor {
1053
1200
  return this.request("POST", "/v1/agent/runs/replay", payload);
1054
1201
  }
1055
1202
 
1203
+ async getReplay(runId, options = {}) {
1204
+ return this.replayAgentRun(runId, options);
1205
+ }
1206
+
1056
1207
  async proposalLifecycle(action, proposal, { session = undefined, promoteBranch = undefined, promote_branch = undefined, targetBranch = undefined, target_branch = undefined, settlementPolicy = undefined, settlement_policy = undefined } = {}) {
1057
1208
  const payload = {
1058
1209
  proposal,
@@ -1479,6 +1630,17 @@ function identifier(value) {
1479
1630
  return value;
1480
1631
  }
1481
1632
 
1633
+ function withQuery(path, params) {
1634
+ const query = new URLSearchParams();
1635
+ for (const [key, value] of Object.entries(params)) {
1636
+ if (value !== undefined && value !== null && value !== "") {
1637
+ query.set(key, String(value));
1638
+ }
1639
+ }
1640
+ const queryText = query.toString();
1641
+ return queryText ? `${path}?${queryText}` : path;
1642
+ }
1643
+
1482
1644
  function repoRoot() {
1483
1645
  return resolve(dirname(fileURLToPath(import.meta.url)), "../..");
1484
1646
  }