@dypai-ai/mcp 1.4.3 → 1.4.5
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 +185 -17
- package/src/tools/deploy.js +49 -1
- package/src/tools/frontend.js +59 -6
- package/src/tools/scaffold.js +6 -2
- 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/diff.js
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dypai_diff — preview what dypai_push would change. Read-only, safe.
|
|
3
|
+
*
|
|
4
|
+
* The picture has THREE layers, not two:
|
|
5
|
+
* - LOCAL : what's on disk in dypai/
|
|
6
|
+
* - DRAFT : what's already been staged via prior pushes (system.config_drafts)
|
|
7
|
+
* - LIVE : what the engine actually serves at <project_id>.dypai.dev
|
|
8
|
+
*
|
|
9
|
+
* The diff body reports the LOCAL → LIVE plan (existing behavior) — that's
|
|
10
|
+
* what `dypai_push` will queue. We additionally surface the DRAFT layer so
|
|
11
|
+
* the agent can spot:
|
|
12
|
+
* 1. Endpoints with a pending draft AND a local change → pushing again
|
|
13
|
+
* will REPLACE the existing draft with the new local version.
|
|
14
|
+
* 2. Endpoints with only a pending draft → run manage_drafts to publish/discard.
|
|
15
|
+
* 3. Endpoints with only a local change → push will create a new draft.
|
|
16
|
+
*
|
|
17
|
+
* A full 3-way semantic diff (local vs draft vs live, field-by-field) would
|
|
18
|
+
* require reserializing each draft payload and comparing — deferred. The
|
|
19
|
+
* overlap signal here covers the common workflow without the extra cost.
|
|
3
20
|
*/
|
|
4
21
|
|
|
5
22
|
import { resolve as resolvePath } from "path"
|
|
6
23
|
import { fetchRemoteState, readLocalState, readLocalStateSnapshot, readLocalConfig, computePlan } from "./planner.js"
|
|
24
|
+
import { proxyToolCall } from "../proxy.js"
|
|
7
25
|
|
|
8
26
|
export const dypaiDiffTool = {
|
|
9
27
|
name: "dypai_diff",
|
|
@@ -37,10 +55,17 @@ export const dypaiDiffTool = {
|
|
|
37
55
|
const config = await readLocalConfig(rootDir)
|
|
38
56
|
const targetProjectId = project_id || config?.project_id || null
|
|
39
57
|
|
|
40
|
-
const [local, remote, stateSnapshot] = await Promise.all([
|
|
58
|
+
const [local, remote, stateSnapshot, draftsResult] = await Promise.all([
|
|
41
59
|
readLocalState(rootDir),
|
|
42
60
|
fetchRemoteState(targetProjectId),
|
|
43
61
|
readLocalStateSnapshot(rootDir),
|
|
62
|
+
// Pending drafts. Cheap on dev (always 0); on prod surfaces what's
|
|
63
|
+
// already staged so the agent can reason about overlap with the local
|
|
64
|
+
// change set. Old engines without the drafts API silently fall back.
|
|
65
|
+
proxyToolCall("manage_drafts", targetProjectId
|
|
66
|
+
? { project_id: targetProjectId, operation: "list" }
|
|
67
|
+
: { operation: "list" }
|
|
68
|
+
).catch(() => ({ total: 0, drafts: [], _unavailable: true })),
|
|
44
69
|
])
|
|
45
70
|
|
|
46
71
|
if (local.errors.length) {
|
|
@@ -69,6 +94,62 @@ export const dypaiDiffTool = {
|
|
|
69
94
|
const totalChanges = plan.create.length + plan.update.length + plan.delete.length + groupChanges
|
|
70
95
|
const hasConflicts = (plan.warnings || []).some(w => w.type === "remote_changed_since_pull")
|
|
71
96
|
|
|
97
|
+
// ─── Drafts overlay ───────────────────────────────────────────────────
|
|
98
|
+
// Build a `pending_drafts` view that tells the agent what's already
|
|
99
|
+
// staged AND highlights the overlap with this diff's local-change set.
|
|
100
|
+
// The overlap is the agent-relevant signal: same endpoint touched on
|
|
101
|
+
// BOTH sides means pushing now will overwrite the existing draft.
|
|
102
|
+
const draftItems = Array.isArray(draftsResult?.drafts) ? draftsResult.drafts : []
|
|
103
|
+
const draftCount = draftsResult?.total || 0
|
|
104
|
+
|
|
105
|
+
const localChangeNames = new Set([
|
|
106
|
+
...plan.create.map(p => p.name),
|
|
107
|
+
...plan.update.map(p => p.name),
|
|
108
|
+
...plan.delete.map(p => p.name),
|
|
109
|
+
])
|
|
110
|
+
|
|
111
|
+
const overlap = draftItems
|
|
112
|
+
.filter(d => d.resource_type === "endpoint" && localChangeNames.has(d.resource_name))
|
|
113
|
+
.map(d => ({
|
|
114
|
+
endpoint: d.resource_name,
|
|
115
|
+
existing_draft_op: d.op,
|
|
116
|
+
local_change_op: plan.create.find(p => p.name === d.resource_name) ? "create"
|
|
117
|
+
: plan.update.find(p => p.name === d.resource_name) ? "update"
|
|
118
|
+
: "delete",
|
|
119
|
+
}))
|
|
120
|
+
|
|
121
|
+
const pendingDrafts = draftCount > 0 ? {
|
|
122
|
+
total: draftCount,
|
|
123
|
+
counts_by_type: draftsResult?.counts_by_type || {},
|
|
124
|
+
items: draftItems
|
|
125
|
+
.map(d => ({
|
|
126
|
+
resource_type: d.resource_type,
|
|
127
|
+
resource_name: d.resource_name,
|
|
128
|
+
op: d.op,
|
|
129
|
+
}))
|
|
130
|
+
.sort((a, b) => (a.resource_name || "").localeCompare(b.resource_name || "")),
|
|
131
|
+
// Endpoints that are BOTH staged AND modified locally — pushing again
|
|
132
|
+
// replaces the existing draft. Empty array = no overlap (clean signal).
|
|
133
|
+
overlap_with_local: overlap,
|
|
134
|
+
} : null
|
|
135
|
+
|
|
136
|
+
// ─── Hint priority ────────────────────────────────────────────────────
|
|
137
|
+
// 1. Conflicts and missing creds always win (block push).
|
|
138
|
+
// 2. Overlap is next: explicitly tell the agent that re-push overwrites.
|
|
139
|
+
// 3. Pending drafts (no overlap): suggest publishing or discarding before
|
|
140
|
+
// stacking more on top.
|
|
141
|
+
const hint = hasConflicts
|
|
142
|
+
? "Remote changed since your last pull — dypai_pull first, or dypai_push will be blocked."
|
|
143
|
+
: (plan.warnings || []).some(w => w.type === "missing_credential")
|
|
144
|
+
? "Some YAMLs reference credentials missing remotely. Create them in the dashboard before pushing."
|
|
145
|
+
: overlap.length > 0
|
|
146
|
+
? `${overlap.length} endpoint(s) have BOTH a pending draft AND a local change — dypai_push will REPLACE the existing draft with the new version. Review pending_drafts.overlap_with_local before pushing.`
|
|
147
|
+
: draftCount > 0 && totalChanges > 0
|
|
148
|
+
? `${draftCount} draft(s) already pending; this diff would queue ${totalChanges} more. Consider manage_drafts(operation:'publish'|'discard', confirm:true) first to keep the staged set focused.`
|
|
149
|
+
: draftCount > 0
|
|
150
|
+
? `${draftCount} pending draft(s) — no local changes to push. Run manage_drafts(operation:'list') to review, then 'publish' (confirm:true) or 'discard' (confirm:true).`
|
|
151
|
+
: undefined
|
|
152
|
+
|
|
72
153
|
return {
|
|
73
154
|
success: true,
|
|
74
155
|
summary: {
|
|
@@ -81,15 +162,15 @@ export const dypaiDiffTool = {
|
|
|
81
162
|
groups_delete: plan.groups?.delete?.length || 0,
|
|
82
163
|
groups_unchanged: plan.groups?.unchanged?.length || 0,
|
|
83
164
|
warnings: plan.warnings?.length || 0,
|
|
165
|
+
pending_drafts: draftCount,
|
|
166
|
+
drafts_overlap_with_local: overlap.length,
|
|
84
167
|
},
|
|
85
168
|
// Only include plan details when there are actual changes or warnings
|
|
86
169
|
plan: (totalChanges > 0 || plan.warnings?.length) ? plan : undefined,
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
? "Some YAMLs reference credentials missing remotely. Create them in the dashboard before pushing."
|
|
92
|
-
: undefined,
|
|
170
|
+
// Always present so the agent knows the key exists. `null` = nothing
|
|
171
|
+
// staged (cleaner signal than omitted-or-zero ambiguity).
|
|
172
|
+
pending_drafts: pendingDrafts,
|
|
173
|
+
hint,
|
|
93
174
|
}
|
|
94
175
|
},
|
|
95
176
|
}
|
package/src/tools/sync/pull.js
CHANGED
|
@@ -558,7 +558,7 @@ export const dypaiPullTool = {
|
|
|
558
558
|
await writeFile(gitignorePath, "# Auto-generated by dypai_pull\n.dypai/\n.DS_Store\n", "utf8")
|
|
559
559
|
}
|
|
560
560
|
|
|
561
|
-
const [endpoints, credentials, groups, schemaSql, nodeCatalogResult, realtimePolicies] = await Promise.all([
|
|
561
|
+
const [endpoints, credentials, groups, schemaSql, nodeCatalogResult, realtimePolicies, draftsResult] = await Promise.all([
|
|
562
562
|
execSql(project_id, `
|
|
563
563
|
SELECT id, name, method, description, workflow_code, input, output,
|
|
564
564
|
allowed_roles, is_tool, tool_description, group_id, is_active, updated_at
|
|
@@ -584,6 +584,17 @@ export const dypaiPullTool = {
|
|
|
584
584
|
FROM system.realtime_policies
|
|
585
585
|
ORDER BY target_type, target_name
|
|
586
586
|
`).catch(() => []),
|
|
587
|
+
// Pending config drafts. Cheap on dev (always returns total=0); on prod
|
|
588
|
+
// surfaces what `dypai_push` queued so the agent doesn't need a separate
|
|
589
|
+
// `manage_drafts(list)` round-trip after pull. Old engines without the
|
|
590
|
+
// drafts API silently fall back to "no drafts" — pull stays usable.
|
|
591
|
+
proxyToolCall("manage_drafts", project_id
|
|
592
|
+
? { project_id, operation: "list" }
|
|
593
|
+
: { operation: "list" }
|
|
594
|
+
).catch(e => {
|
|
595
|
+
console.error(`[dypai_pull] manage_drafts(list) failed: ${e.message}`)
|
|
596
|
+
return { total: 0, drafts: [], counts_by_type: {}, _unavailable: true }
|
|
597
|
+
}),
|
|
587
598
|
])
|
|
588
599
|
|
|
589
600
|
// schema.sql: always regenerated (read-only reference)
|
|
@@ -756,12 +767,54 @@ export const dypaiPullTool = {
|
|
|
756
767
|
.filter(e => e.is_active === false)
|
|
757
768
|
.map(e => e.name)
|
|
758
769
|
|
|
770
|
+
// `environment` is an internal flag we still surface in `overview.project`
|
|
771
|
+
// for diagnostics / dashboard parity, but the agent-facing copy uses the
|
|
772
|
+
// universal "drafts → publish" vocabulary regardless of the value.
|
|
773
|
+
// Default is "production" (= draft-publish workflow); legacy projects
|
|
774
|
+
// may still report "development" (= zero-friction direct writes).
|
|
775
|
+
const environment = (projectInfo?.environment || "production").toLowerCase()
|
|
776
|
+
const usesDraftWorkflow = environment !== "development"
|
|
777
|
+
|
|
778
|
+
// Compact view of `manage_drafts(list)` so the agent doesn't need a
|
|
779
|
+
// second round-trip to know what's staged. We surface BOTH the raw count
|
|
780
|
+
// and a per-resource view (most useful: "what endpoints have pending
|
|
781
|
+
// edits?") so the agent can decide whether to publish, discard, or
|
|
782
|
+
// continue iterating without re-querying.
|
|
783
|
+
const draftsTotal = draftsResult?.total || 0
|
|
784
|
+
const draftItems = Array.isArray(draftsResult?.drafts) ? draftsResult.drafts : []
|
|
785
|
+
const pendingDrafts = draftsTotal > 0 ? {
|
|
786
|
+
total: draftsTotal,
|
|
787
|
+
counts_by_type: draftsResult?.counts_by_type || {},
|
|
788
|
+
// Each item: { resource_type, resource_name, op } — strip noisy payload.
|
|
789
|
+
// Sorted by name for stable output across pulls (helps diff-on-disk if
|
|
790
|
+
// we ever persist this).
|
|
791
|
+
items: draftItems
|
|
792
|
+
.map(d => ({
|
|
793
|
+
resource_type: d.resource_type,
|
|
794
|
+
resource_name: d.resource_name,
|
|
795
|
+
op: d.op,
|
|
796
|
+
}))
|
|
797
|
+
.sort((a, b) => (a.resource_name || "").localeCompare(b.resource_name || "")),
|
|
798
|
+
hint: `Test the staged version with dypai_test_endpoint(mode:'draft', endpoint:'<name>'), then ` +
|
|
799
|
+
`manage_drafts(operation:'publish', confirm:true) to apply (or 'discard' to throw away).`,
|
|
800
|
+
} : null
|
|
801
|
+
|
|
759
802
|
const overview = {
|
|
760
803
|
project: projectInfo ? {
|
|
761
804
|
id: projectInfo.id,
|
|
762
805
|
name: projectInfo.name,
|
|
763
806
|
plan: projectInfo.plan,
|
|
764
|
-
|
|
807
|
+
environment,
|
|
808
|
+
} : { id: resolvedProjectId || "(from token)", environment },
|
|
809
|
+
environment,
|
|
810
|
+
// User-facing hint speaks the universal "drafts → publish" language.
|
|
811
|
+
// We branch only on whether there are pending drafts and on the
|
|
812
|
+
// (uncommon) legacy direct-write mode.
|
|
813
|
+
environment_hint: usesDraftWorkflow
|
|
814
|
+
? (draftsTotal > 0
|
|
815
|
+
? `${draftsTotal} pending draft(s) — review with manage_drafts(operation:'list') before publishing. Confirm with the user before push, DDL, delete, deploy, or manage_drafts(publish/discard).`
|
|
816
|
+
: "Draft-and-publish workflow active. dypai_push stages changes as drafts; confirm with the user before publishing, DDL, delete, or deploy.")
|
|
817
|
+
: "Direct-write mode (legacy): mutations apply immediately, no draft stage. Iterate freely but confirm destructive ops with the user.",
|
|
765
818
|
endpoints: {
|
|
766
819
|
total: (endpoints || []).length,
|
|
767
820
|
active: (endpoints || []).filter(e => e.is_active !== false).length,
|
|
@@ -773,9 +826,18 @@ export const dypaiPullTool = {
|
|
|
773
826
|
},
|
|
774
827
|
credentials: (credentials || []).map(c => ({ name: c.name, type: c.type })),
|
|
775
828
|
realtime_policies: (realtimePolicies || []).length,
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
829
|
+
// Always present so the agent can rely on the key existing. `null`
|
|
830
|
+
// means "nothing pending" (cleaner than 0/empty-array which the agent
|
|
831
|
+
// might treat as "I should check this").
|
|
832
|
+
pending_drafts: pendingDrafts,
|
|
833
|
+
next_steps: pendingDrafts
|
|
834
|
+
? [
|
|
835
|
+
`${draftsTotal} pending draft(s). Review with manage_drafts(operation:'list'), verify with dypai_test_endpoint(mode:'draft'), then publish or discard.`,
|
|
836
|
+
"After resolving drafts, edit YAML in dypai/endpoints/ and dypai_diff → dypai_push to stage more.",
|
|
837
|
+
]
|
|
838
|
+
: (endpoints || []).length === 0
|
|
839
|
+
? ["Empty project. Create tables via execute_sql, then write dypai/endpoints/<name>.yaml and dypai_push."]
|
|
840
|
+
: ["Read dypai/schema.sql before writing queries.", "Edit YAML in dypai/endpoints/, then dypai_diff → dypai_push."],
|
|
779
841
|
}
|
|
780
842
|
|
|
781
843
|
return {
|
|
@@ -784,6 +846,9 @@ export const dypaiPullTool = {
|
|
|
784
846
|
files_written: filesWritten.length,
|
|
785
847
|
output_dir: outDir,
|
|
786
848
|
out_dir_resolved_via: outDirSource,
|
|
849
|
+
// Surface count at top level too — agents that ignore `overview` (e.g.
|
|
850
|
+
// pure scripted callers) still need to know the project has staged work.
|
|
851
|
+
pending_drafts: draftsTotal,
|
|
787
852
|
overview,
|
|
788
853
|
errors: errors.length ? errors : undefined,
|
|
789
854
|
warning: suspiciousWarning || undefined,
|
|
@@ -791,9 +856,11 @@ export const dypaiPullTool = {
|
|
|
791
856
|
? "Some endpoints failed to serialize. Check errors[] — usually malformed workflow_code."
|
|
792
857
|
: suspiciousWarning
|
|
793
858
|
? "Files were written but the path looks wrong. See `warning` above and re-run with an absolute out_dir."
|
|
794
|
-
:
|
|
795
|
-
?
|
|
796
|
-
:
|
|
859
|
+
: draftsTotal > 0
|
|
860
|
+
? `${draftsTotal} pending draft(s) on this project — see overview.pending_drafts and decide publish vs discard before pushing more.`
|
|
861
|
+
: endpoints.length === 0
|
|
862
|
+
? "Empty project. Create tables with execute_sql, then write endpoints/<name>.yaml and dypai_push."
|
|
863
|
+
: undefined,
|
|
797
864
|
}
|
|
798
865
|
},
|
|
799
866
|
}
|
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))
|