@dypai-ai/mcp 1.4.3 → 1.4.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/package.json +1 -1
- package/src/api.js +14 -2
- package/src/auto-update.js +44 -1
- package/src/index.js +260 -19
- package/src/tools/deploy.js +49 -1
- package/src/tools/frontend.js +59 -6
- package/src/tools/scaffold.js +6 -2
- package/src/tools/search-logs-offload.js +151 -0
- package/src/tools/sync/diff.js +88 -7
- package/src/tools/sync/pull.js +75 -8
- package/src/tools/sync/push.js +129 -96
- package/src/tools/sync/test-endpoint.js +217 -73
- package/src/tools/sync/validate.js +415 -48
- package/src/tools/sync.js +85 -13
- package/src/tools/status.js +0 -94
package/src/tools/sync/push.js
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
readLocalStateSnapshot,
|
|
17
17
|
readLocalConfig,
|
|
18
18
|
readLocalRealtime,
|
|
19
|
-
fetchRemoteRealtime,
|
|
20
19
|
computePlan,
|
|
21
20
|
localToCanonical,
|
|
22
21
|
} from "./planner.js"
|
|
@@ -66,6 +65,12 @@ function endpointPayload(row) {
|
|
|
66
65
|
* least one of the markers we expect from a real mutation. Anything else
|
|
67
66
|
* (empty object, error string masquerading as data, missing id) becomes
|
|
68
67
|
* an error so the push doesn't lie to the agent.
|
|
68
|
+
*
|
|
69
|
+
* Layer 2: by default the API stages mutations as drafts and returns
|
|
70
|
+
* `{applied_to: "draft", draft: {...}, message: "..."}`. That counts as a
|
|
71
|
+
* successful mutation — the change was saved, just not applied to live yet.
|
|
72
|
+
* Power-user direct-write projects skip the draft stage and return the
|
|
73
|
+
* legacy live shape; both are valid successes.
|
|
69
74
|
*/
|
|
70
75
|
function assertMutationOK(result, op, name) {
|
|
71
76
|
if (!result || typeof result !== "object") {
|
|
@@ -75,15 +80,29 @@ function assertMutationOK(result, op, name) {
|
|
|
75
80
|
throw new Error(`${op} ${name}: ${typeof result.error === "string" ? result.error : JSON.stringify(result.error)}`)
|
|
76
81
|
}
|
|
77
82
|
// Successful create returns { id, name } or { ok: true }; update/delete return
|
|
78
|
-
// { message } or { ok: true }.
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
// { message } or { ok: true }. Draft responses return { applied_to: "draft", draft, message }.
|
|
84
|
+
const hasMarker = result.id
|
|
85
|
+
|| result.ok === true
|
|
86
|
+
|| result.message
|
|
87
|
+
|| result.endpoint_id
|
|
88
|
+
|| result.success === true
|
|
89
|
+
|| result.applied_to === "draft"
|
|
81
90
|
if (!hasMarker) {
|
|
82
91
|
throw new Error(`${op} ${name}: response missing success markers — ${JSON.stringify(result).slice(0, 200)}`)
|
|
83
92
|
}
|
|
84
93
|
return result
|
|
85
94
|
}
|
|
86
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Returns true when the API staged the change as a draft (default behavior)
|
|
98
|
+
* instead of applying it directly to live. Both the draft branch and the
|
|
99
|
+
* direct-write branch are valid successful outcomes — drafts just need to
|
|
100
|
+
* be reported back to the user so they know to run manage_drafts(publish).
|
|
101
|
+
*/
|
|
102
|
+
function isDraftResponse(result) {
|
|
103
|
+
return result && typeof result === "object" && result.applied_to === "draft"
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
async function applyCreate(canonicalRow, projectId) {
|
|
88
107
|
const res = await proxyToolCall("create_endpoint", {
|
|
89
108
|
...(projectId ? { project_id: projectId } : {}),
|
|
@@ -150,101 +169,66 @@ async function applyGroupDelete(name, projectId, mapsCtx) {
|
|
|
150
169
|
}
|
|
151
170
|
|
|
152
171
|
// ─── Realtime policies sync ────────────────────────────────────────────────
|
|
172
|
+
//
|
|
173
|
+
// Layer 2 update: we no longer compute the diff client-side and write
|
|
174
|
+
// directly via execute_sql. The whole bulk-sync goes through the API
|
|
175
|
+
// endpoint POST /{project_id}/realtime/sync (called via the cloud MCP
|
|
176
|
+
// tool `sync_realtime_policies`). The API:
|
|
177
|
+
// - computes the diff against live (and pending drafts in production)
|
|
178
|
+
// - branches dev/prod: production queues drafts, development applies
|
|
179
|
+
// - returns `{ applied_to: "live"|"draft", summary, details, drafts_queued? }`
|
|
180
|
+
// Cache invalidation in the engine is automatic via the SQL trigger
|
|
181
|
+
// `_dypai_notify_policy_change` (pg_notify → engine LISTEN), so neither
|
|
182
|
+
// dev nor draft-publish need anything extra here.
|
|
153
183
|
|
|
154
184
|
/**
|
|
155
|
-
* Reconcile local realtime.yaml with
|
|
156
|
-
*
|
|
185
|
+
* Reconcile local realtime.yaml with the engine via the API. Returns the
|
|
186
|
+
* API response unchanged (with `dry_run: true` short-circuit when requested
|
|
187
|
+
* — the API itself doesn't have dry-run yet, so we just return the local
|
|
188
|
+
* count without making the call).
|
|
157
189
|
*/
|
|
158
190
|
async function syncRealtimePolicies(projectId, rootDir, dryRun = false) {
|
|
159
191
|
const local = await readLocalRealtime(rootDir)
|
|
160
192
|
if (!local) return { skipped: true, reason: "no realtime.yaml found" }
|
|
161
193
|
|
|
162
|
-
const remote = await fetchRemoteRealtime(projectId)
|
|
163
|
-
if (remote === null) {
|
|
164
|
-
return { skipped: true, reason: "engine has no system.realtime_policies table yet (backward compat)" }
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const key = (p) => `${p.target_type}::${p.target_name}`
|
|
168
|
-
const remoteMap = Object.fromEntries(remote.map(p => [key(p), p]))
|
|
169
|
-
const localMap = Object.fromEntries(local.rows.map(p => [key(p), p]))
|
|
170
|
-
|
|
171
|
-
const toUpsert = []
|
|
172
|
-
const toDelete = []
|
|
173
|
-
for (const k of Object.keys(localMap)) {
|
|
174
|
-
const l = localMap[k]
|
|
175
|
-
const r = remoteMap[k]
|
|
176
|
-
if (!r || !policiesEqual(l, r)) toUpsert.push(l)
|
|
177
|
-
}
|
|
178
|
-
for (const k of Object.keys(remoteMap)) {
|
|
179
|
-
if (!localMap[k]) toDelete.push(remoteMap[k])
|
|
180
|
-
}
|
|
181
|
-
|
|
182
194
|
if (dryRun) {
|
|
183
|
-
|
|
195
|
+
// API doesn't expose dry-run today. We can still report the local count;
|
|
196
|
+
// the actual upsert/delete breakdown is computed server-side, so for the
|
|
197
|
+
// purposes of `dypai_diff` we just say "N local policies on disk".
|
|
198
|
+
return { local_count: local.rows.length, dry_run: true }
|
|
184
199
|
}
|
|
185
200
|
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (toUpsert.length > 0) {
|
|
191
|
-
const rowsJson = JSON.stringify(toUpsert.map(p => ({
|
|
201
|
+
// Forward the FULL local list — server diffs against live + drafts.
|
|
202
|
+
const result = await proxyToolCall("sync_realtime_policies", {
|
|
203
|
+
project_id: projectId,
|
|
204
|
+
policies: local.rows.map(p => ({
|
|
192
205
|
target_type: p.target_type,
|
|
193
206
|
target_name: p.target_name,
|
|
194
|
-
subscribe_filter: p.subscribe_filter,
|
|
195
|
-
events: p.events,
|
|
196
|
-
required_role: p.required_role,
|
|
197
|
-
auth_required: p.auth_required,
|
|
198
|
-
}))
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
project_id: projectId,
|
|
202
|
-
sql: `
|
|
203
|
-
INSERT INTO system.realtime_policies
|
|
204
|
-
(target_type, target_name, subscribe_filter, events, required_role, auth_required, updated_at)
|
|
205
|
-
SELECT target_type, target_name, subscribe_filter, events, required_role, auth_required, now()
|
|
206
|
-
FROM jsonb_to_recordset('${rowsJson}'::jsonb) AS r(
|
|
207
|
-
target_type text,
|
|
208
|
-
target_name text,
|
|
209
|
-
subscribe_filter text,
|
|
210
|
-
events text[],
|
|
211
|
-
required_role text,
|
|
212
|
-
auth_required boolean
|
|
213
|
-
)
|
|
214
|
-
ON CONFLICT (target_type, target_name) DO UPDATE SET
|
|
215
|
-
subscribe_filter = EXCLUDED.subscribe_filter,
|
|
216
|
-
events = EXCLUDED.events,
|
|
217
|
-
required_role = EXCLUDED.required_role,
|
|
218
|
-
auth_required = EXCLUDED.auth_required,
|
|
219
|
-
updated_at = now()
|
|
220
|
-
`,
|
|
221
|
-
})
|
|
222
|
-
}
|
|
207
|
+
subscribe_filter: p.subscribe_filter ?? null,
|
|
208
|
+
events: p.events ?? null,
|
|
209
|
+
required_role: p.required_role ?? null,
|
|
210
|
+
auth_required: p.auth_required !== false,
|
|
211
|
+
})),
|
|
212
|
+
delete_orphans: true,
|
|
213
|
+
})
|
|
223
214
|
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
215
|
+
if (result?.error) {
|
|
216
|
+
throw new Error(`sync_realtime_policies failed: ${result.error}`)
|
|
217
|
+
}
|
|
218
|
+
// Backward-compat: surface the legacy `{ upsert, delete }` shape that
|
|
219
|
+
// older code paths (and the diff tool) expect, in addition to the full
|
|
220
|
+
// API response.
|
|
221
|
+
const summary = result?.summary || { upserts: 0, deletes: 0, unchanged: 0 }
|
|
222
|
+
return {
|
|
223
|
+
upsert: summary.upserts ?? 0,
|
|
224
|
+
delete: summary.deletes ?? 0,
|
|
225
|
+
unchanged: summary.unchanged ?? 0,
|
|
226
|
+
applied_to: result?.applied_to ?? "live",
|
|
227
|
+
drafts_queued: result?.drafts_queued || undefined,
|
|
228
|
+
skipped: result?.skipped || undefined,
|
|
229
|
+
reason: result?.reason || undefined,
|
|
230
|
+
details: result?.details || undefined,
|
|
236
231
|
}
|
|
237
|
-
|
|
238
|
-
return { upsert: toUpsert.length, delete: toDelete.length }
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function policiesEqual(a, b) {
|
|
242
|
-
return a.target_type === b.target_type
|
|
243
|
-
&& a.target_name === b.target_name
|
|
244
|
-
&& (a.subscribe_filter || null) === (b.subscribe_filter || null)
|
|
245
|
-
&& JSON.stringify(a.events || []) === JSON.stringify(b.events || [])
|
|
246
|
-
&& (a.required_role || null) === (b.required_role || null)
|
|
247
|
-
&& !!a.auth_required === !!b.auth_required
|
|
248
232
|
}
|
|
249
233
|
|
|
250
234
|
// ─── Tool ──────────────────────────────────────────────────────────────────
|
|
@@ -252,9 +236,11 @@ function policiesEqual(a, b) {
|
|
|
252
236
|
export const dypaiPushTool = {
|
|
253
237
|
name: "dypai_push",
|
|
254
238
|
description:
|
|
255
|
-
"
|
|
256
|
-
"
|
|
257
|
-
"
|
|
239
|
+
"BACKEND ONLY — applies the local ./dypai/ state (endpoints, realtime policies) to the remote project as DRAFTS. " +
|
|
240
|
+
"Does NOT touch the live site by itself: changes are staged in `system.config_drafts` and only become live after `manage_drafts(operation:'publish', confirm:true)`. Safe to iterate freely. " +
|
|
241
|
+
"ALWAYS run dypai_diff first to preview what will be staged. " +
|
|
242
|
+
"Frontend code is not affected — use `manage_frontend` for that. " +
|
|
243
|
+
"By default, endpoints in remote but missing locally are kept (safe). Pass delete_orphans: true to stage their deletion as a draft as well.",
|
|
258
244
|
inputSchema: {
|
|
259
245
|
type: "object",
|
|
260
246
|
properties: {
|
|
@@ -397,7 +383,16 @@ export const dypaiPushTool = {
|
|
|
397
383
|
// Group operations are sequential because they're fast (a handful of
|
|
398
384
|
// groups typically) and we need mapsCtx mutations to be race-free.
|
|
399
385
|
const CONCURRENCY = 5
|
|
400
|
-
|
|
386
|
+
// `applied.*_drafts` tracks the per-op subset that the API staged as
|
|
387
|
+
// drafts (default behavior). The lists in `created/updated/deleted`
|
|
388
|
+
// include all successful ops — drafts and direct-write — so existing
|
|
389
|
+
// callers and tests don't break. Drafts are also broken out for the
|
|
390
|
+
// summary and `next_step` so the user knows to publish.
|
|
391
|
+
const applied = {
|
|
392
|
+
created: [], updated: [], deleted: [],
|
|
393
|
+
created_drafts: [], updated_drafts: [], deleted_drafts: [],
|
|
394
|
+
groups_created: [], groups_deleted: [],
|
|
395
|
+
}
|
|
401
396
|
const errors = []
|
|
402
397
|
|
|
403
398
|
// Phase 1 — create missing groups BEFORE any endpoint is created,
|
|
@@ -414,8 +409,9 @@ export const dypaiPushTool = {
|
|
|
414
409
|
await runWithConcurrency(plan.create, CONCURRENCY, async (item) => {
|
|
415
410
|
try {
|
|
416
411
|
const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
|
|
417
|
-
await applyCreate(canonical, targetProjectId)
|
|
412
|
+
const res = await applyCreate(canonical, targetProjectId)
|
|
418
413
|
applied.created.push(item.name)
|
|
414
|
+
if (isDraftResponse(res)) applied.created_drafts.push(item.name)
|
|
419
415
|
} catch (e) {
|
|
420
416
|
errors.push({ op: "create", endpoint: item.name, error: e.message })
|
|
421
417
|
}
|
|
@@ -426,8 +422,9 @@ export const dypaiPushTool = {
|
|
|
426
422
|
const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
|
|
427
423
|
const endpointId = remote.byName[item.name]?.id
|
|
428
424
|
if (!endpointId) throw new Error("endpoint_id missing from remote state")
|
|
429
|
-
await applyUpdate(canonical, endpointId, targetProjectId)
|
|
425
|
+
const res = await applyUpdate(canonical, endpointId, targetProjectId)
|
|
430
426
|
applied.updated.push({ name: item.name, changed_fields: item.changedFields })
|
|
427
|
+
if (isDraftResponse(res)) applied.updated_drafts.push(item.name)
|
|
431
428
|
} catch (e) {
|
|
432
429
|
errors.push({ op: "update", endpoint: item.name, error: e.message })
|
|
433
430
|
}
|
|
@@ -437,8 +434,9 @@ export const dypaiPushTool = {
|
|
|
437
434
|
try {
|
|
438
435
|
const endpointId = remote.byName[item.name]?.id
|
|
439
436
|
if (!endpointId) throw new Error("endpoint_id missing from remote state")
|
|
440
|
-
await applyDelete(endpointId, targetProjectId)
|
|
437
|
+
const res = await applyDelete(endpointId, targetProjectId)
|
|
441
438
|
applied.deleted.push(item.name)
|
|
439
|
+
if (isDraftResponse(res)) applied.deleted_drafts.push(item.name)
|
|
442
440
|
} catch (e) {
|
|
443
441
|
errors.push({ op: "delete", endpoint: item.name, error: e.message })
|
|
444
442
|
}
|
|
@@ -474,13 +472,44 @@ export const dypaiPushTool = {
|
|
|
474
472
|
...applied.updated.map(u => u.name),
|
|
475
473
|
]
|
|
476
474
|
|
|
475
|
+
// How many ops landed in draft state. When `draftCount > 0` the user
|
|
476
|
+
// must run `manage_drafts(operation:'publish')` to expose the changes
|
|
477
|
+
// on live; tests against live won't see them yet. Realtime drafts are
|
|
478
|
+
// also counted — the API returns `applied_to: "draft"` and a
|
|
479
|
+
// `drafts_queued` array when realtime policies were staged.
|
|
480
|
+
const realtimeDraftCount = realtime && realtime.applied_to === "draft"
|
|
481
|
+
? (realtime.drafts_queued?.length ?? ((realtime.upsert ?? 0) + (realtime.delete ?? 0)))
|
|
482
|
+
: 0
|
|
483
|
+
const draftCount =
|
|
484
|
+
applied.created_drafts.length +
|
|
485
|
+
applied.updated_drafts.length +
|
|
486
|
+
applied.deleted_drafts.length +
|
|
487
|
+
realtimeDraftCount
|
|
488
|
+
const endpointTotal =
|
|
489
|
+
applied.created.length + applied.updated.length + applied.deleted.length
|
|
490
|
+
const realtimeTotal = realtime ? ((realtime.upsert ?? 0) + (realtime.delete ?? 0)) : 0
|
|
491
|
+
const isDraftMode = draftCount > 0
|
|
492
|
+
&& draftCount === endpointTotal + realtimeTotal
|
|
493
|
+
|
|
477
494
|
return {
|
|
478
495
|
success: errors.length === 0,
|
|
479
496
|
applied: true,
|
|
497
|
+
// By default every endpoint mutation is staged as a draft, so the
|
|
498
|
+
// same push that "created 3 endpoints" actually queued 3 drafts.
|
|
499
|
+
// Surfacing this at the top level makes the difference unmissable
|
|
500
|
+
// for both human callers and the agent.
|
|
501
|
+
// - "draft": every mutation in this push was staged as a draft
|
|
502
|
+
// - "mixed": some drafts, some direct writes (rare)
|
|
503
|
+
// - "live": direct-write mode, mutation hit live immediately
|
|
504
|
+
mode: isDraftMode ? "draft" : draftCount > 0 ? "mixed" : "live",
|
|
505
|
+
pending_drafts: draftCount,
|
|
480
506
|
summary: {
|
|
481
507
|
created: applied.created.length,
|
|
508
|
+
created_drafts: applied.created_drafts.length,
|
|
482
509
|
updated: applied.updated.length,
|
|
510
|
+
updated_drafts: applied.updated_drafts.length,
|
|
483
511
|
deleted: applied.deleted.length,
|
|
512
|
+
deleted_drafts: applied.deleted_drafts.length,
|
|
484
513
|
unchanged: plan.unchanged.length,
|
|
485
514
|
groups_created: applied.groups_created.length,
|
|
486
515
|
groups_deleted: applied.groups_deleted.length,
|
|
@@ -489,12 +518,16 @@ export const dypaiPushTool = {
|
|
|
489
518
|
},
|
|
490
519
|
details: applied,
|
|
491
520
|
errors: errors.length ? errors : undefined,
|
|
492
|
-
// Only one next_step — and only when it's non-obvious
|
|
521
|
+
// Only one next_step — and only when it's non-obvious. Drafts win
|
|
522
|
+
// over the test suggestion because tests against live won't see
|
|
523
|
+
// the change until the drafts are promoted.
|
|
493
524
|
next_step: errors.length
|
|
494
525
|
? "Fix the offending YAMLs and push again."
|
|
495
|
-
:
|
|
496
|
-
?
|
|
497
|
-
:
|
|
526
|
+
: draftCount > 0
|
|
527
|
+
? `${draftCount} change(s) saved as draft(s) — they're not live yet. Review with manage_drafts(operation:'list'), verify with dypai_test_endpoint(mode:'draft', endpoint:'<name>'), then publish with manage_drafts(operation:'publish', confirm:true) (or discard with manage_drafts(operation:'discard', confirm:true) to throw them away).`
|
|
528
|
+
: changedNames.length
|
|
529
|
+
? `Test changed endpoint(s) with dypai_test_endpoint: ${changedNames.slice(0, 3).join(", ")}${changedNames.length > 3 ? "…" : ""}`
|
|
530
|
+
: undefined,
|
|
498
531
|
hint: errors.some(e => /credential/i.test(e.error))
|
|
499
532
|
? "A referenced credential doesn't exist remotely. Create it in the dashboard (same name), then retry."
|
|
500
533
|
: errors.some(e => /validation/i.test(e.error))
|