@dypai-ai/mcp 1.6.16 → 1.6.18
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/generated/serverInstructions.js +3 -3
- package/src/index.js +4 -1
- package/src/lib/backendSnapshot.js +8 -0
- package/src/lib/effective-workflows-runner.js +6 -3
- package/src/toolProfiles.js +6 -5
- package/src/tools/automation-setup.js +227 -0
- package/src/tools/frontend.js +42 -8
- package/src/tools/project-deploy-production.js +54 -31
- package/src/tools/project-push.js +0 -5
- package/src/tools/storage.js +2 -0
- package/src/tools/sync/diff.js +28 -130
- package/src/tools/sync/planner.js +20 -1
- package/src/tools/sync/pull.js +38 -15
- package/src/tools/sync/push.js +46 -10
- package/src/tools/sync/source-diff.js +217 -0
package/src/tools/sync/push.js
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "./planner.js"
|
|
26
26
|
import { runValidation } from "./validate.js"
|
|
27
27
|
import { runGenerateEndpointTypes } from "./generate-types.js"
|
|
28
|
+
import { writeBackendSourceBaseline } from "./source-diff.js"
|
|
28
29
|
// Codegen removed from v1 — see pull.js note.
|
|
29
30
|
// import { regenerateTypes } from "../codegen.js"
|
|
30
31
|
|
|
@@ -34,7 +35,7 @@ const VALID_RESPONSE_CARDINALITY = new Set(["single", "many", "zero_or_one"])
|
|
|
34
35
|
const PUSH_CONCURRENCY = Math.max(1, Number(process.env.DYPAI_PUSH_CONCURRENCY || 1))
|
|
35
36
|
const PUSH_RETRY_ATTEMPTS = Math.max(1, Number(process.env.DYPAI_PUSH_RETRY_ATTEMPTS || 3))
|
|
36
37
|
const PUSH_RETRY_DELAY_MS = Math.max(100, Number(process.env.DYPAI_PUSH_RETRY_DELAY_MS || 500))
|
|
37
|
-
const BACKEND_SOURCE_CHECKPOINT_DIRS = ["flows", "endpoints", "migrations", "types", "lib"]
|
|
38
|
+
const BACKEND_SOURCE_CHECKPOINT_DIRS = ["flows", "automations", "endpoints", "migrations", "types", "lib"]
|
|
38
39
|
const BACKEND_SOURCE_CHECKPOINT_EXACT = ["schema.sql", "realtime.yaml"]
|
|
39
40
|
const BACKEND_SOURCE_CHECKPOINT_EXTS = new Set([
|
|
40
41
|
".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts", ".cjs", ".cts",
|
|
@@ -136,6 +137,8 @@ function normalizeWorkflowSourceFile(row) {
|
|
|
136
137
|
const raw = typeof source?.file === "string" ? source.file.trim().replace(/\\/g, "/") : ""
|
|
137
138
|
if (raw.startsWith("dypai/flows/") && raw.endsWith(".flow.ts")) return raw
|
|
138
139
|
if (raw.startsWith("flows/") && raw.endsWith(".flow.ts")) return `dypai/${raw}`
|
|
140
|
+
if (raw.startsWith("dypai/automations/") && raw.endsWith(".automation.ts")) return raw
|
|
141
|
+
if (raw.startsWith("automations/") && raw.endsWith(".automation.ts")) return `dypai/${raw}`
|
|
139
142
|
return null
|
|
140
143
|
}
|
|
141
144
|
|
|
@@ -195,6 +198,29 @@ async function checkpointBackendSource(projectId, rootDir, plan, remote) {
|
|
|
195
198
|
}
|
|
196
199
|
}
|
|
197
200
|
|
|
201
|
+
async function refreshBackendSourceBaseline(projectId, rootDir, sourceCheckpoint) {
|
|
202
|
+
if (sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
skipped: true,
|
|
206
|
+
reason: "source_checkpoint_failed",
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const baseline = await writeBackendSourceBaseline(rootDir, projectId)
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
files: Array.isArray(baseline.files) ? baseline.files.length : 0,
|
|
214
|
+
path: "dypai/.dypai/backend-baseline.json",
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: error?.message || String(error),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
198
224
|
/**
|
|
199
225
|
* Run an async worker over an array with bounded concurrency. Workers never
|
|
200
226
|
* throw (they handle their own errors via the push errors[] accumulator),
|
|
@@ -275,7 +301,7 @@ function assertMutationOK(result, op, name) {
|
|
|
275
301
|
* Returns true when the API staged the change as a draft (default behavior)
|
|
276
302
|
* instead of applying it directly to live. Both the draft branch and the
|
|
277
303
|
* direct-write branch are valid successful outcomes — drafts just need to
|
|
278
|
-
* be reported back to the user so they know to
|
|
304
|
+
* be reported back to the user so they know to publish the drafts explicitly.
|
|
279
305
|
*/
|
|
280
306
|
function isDraftResponse(result) {
|
|
281
307
|
return result && typeof result === "object" && result.applied_to === "draft"
|
|
@@ -439,7 +465,7 @@ export const dypaiPushTool = {
|
|
|
439
465
|
},
|
|
440
466
|
dry_run: {
|
|
441
467
|
type: "boolean",
|
|
442
|
-
description: "If true, compute the plan
|
|
468
|
+
description: "If true, compile and compute the backend plan without applying or regenerating types. For a fast source-only preview use dypai_diff.",
|
|
443
469
|
default: false,
|
|
444
470
|
},
|
|
445
471
|
force: {
|
|
@@ -499,11 +525,13 @@ export const dypaiPushTool = {
|
|
|
499
525
|
}
|
|
500
526
|
}
|
|
501
527
|
|
|
502
|
-
let typesGenerated = null
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
528
|
+
let typesGenerated = dry_run ? { ok: true, skipped: true, reason: "dry_run" } : null
|
|
529
|
+
if (!dry_run) {
|
|
530
|
+
try {
|
|
531
|
+
typesGenerated = await runGenerateEndpointTypes(rootDir, targetProjectId)
|
|
532
|
+
} catch (e) {
|
|
533
|
+
typesGenerated = { ok: false, error: e.message }
|
|
534
|
+
}
|
|
507
535
|
}
|
|
508
536
|
|
|
509
537
|
let local
|
|
@@ -594,6 +622,9 @@ export const dypaiPushTool = {
|
|
|
594
622
|
? null
|
|
595
623
|
: await checkpointBackendSource(targetProjectId, rootDir, plan, remote)
|
|
596
624
|
const sourceCheckpointFailed = sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true
|
|
625
|
+
const sourceBaseline = !dry_run && !sourceCheckpointFailed
|
|
626
|
+
? await refreshBackendSourceBaseline(targetProjectId, rootDir, sourceCheckpoint)
|
|
627
|
+
: undefined
|
|
597
628
|
return {
|
|
598
629
|
success: !sourceCheckpointFailed,
|
|
599
630
|
applied: false,
|
|
@@ -602,6 +633,7 @@ export const dypaiPushTool = {
|
|
|
602
633
|
plan,
|
|
603
634
|
types: typesGenerated,
|
|
604
635
|
source_checkpoint: sourceCheckpoint || undefined,
|
|
636
|
+
source_baseline: sourceBaseline,
|
|
605
637
|
hint: sourceCheckpointFailed
|
|
606
638
|
? "Backend runtime was unchanged, but DYPAI could not save the local dypai/ source to the Studio branch. Retry dypai_push once the GitHub/source checkpoint issue is resolved."
|
|
607
639
|
: undefined,
|
|
@@ -725,7 +757,7 @@ export const dypaiPushTool = {
|
|
|
725
757
|
]
|
|
726
758
|
|
|
727
759
|
// How many ops landed in draft state. When `draftCount > 0` the user
|
|
728
|
-
// must
|
|
760
|
+
// must publish drafts explicitly to expose the changes
|
|
729
761
|
// on live; tests against live won't see them yet. Realtime drafts are
|
|
730
762
|
// also counted — the API returns `applied_to: "draft"` and a
|
|
731
763
|
// `drafts_queued` array when realtime policies were staged.
|
|
@@ -752,6 +784,9 @@ export const dypaiPushTool = {
|
|
|
752
784
|
reason: "push_had_errors",
|
|
753
785
|
}
|
|
754
786
|
const sourceCheckpointFailed = sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true
|
|
787
|
+
const sourceBaseline = errors.length === 0 && !sourceCheckpointFailed
|
|
788
|
+
? await refreshBackendSourceBaseline(targetProjectId, rootDir, sourceCheckpoint)
|
|
789
|
+
: undefined
|
|
755
790
|
|
|
756
791
|
return {
|
|
757
792
|
success: errors.length === 0 && !sourceCheckpointFailed,
|
|
@@ -786,6 +821,7 @@ export const dypaiPushTool = {
|
|
|
786
821
|
errors: errors.length ? errors : undefined,
|
|
787
822
|
types: typesGenerated,
|
|
788
823
|
source_checkpoint: sourceCheckpoint,
|
|
824
|
+
source_baseline: sourceBaseline,
|
|
789
825
|
// Only one next_step — and only when it's non-obvious. Drafts win
|
|
790
826
|
// over the test suggestion because tests against live won't see
|
|
791
827
|
// the change until the drafts are promoted.
|
|
@@ -794,7 +830,7 @@ export const dypaiPushTool = {
|
|
|
794
830
|
? `${endpointTotal} endpoint(s) saved, ${failedTotal} failed. Fix failed endpoints and push again. Failed: ${errors.slice(0, 3).map((item) => item.endpoint || item.group || item.op).join(", ")}${errors.length > 3 ? "…" : ""}`
|
|
795
831
|
: "Fix the offending YAMLs and push again."
|
|
796
832
|
: draftCount > 0
|
|
797
|
-
? `${draftCount} backend change(s) saved
|
|
833
|
+
? `${draftCount} backend change(s) saved as drafts — they're not live yet. Review with manage_drafts(operation:'list'), verify with dypai_test_endpoint(mode:'draft', endpoint:'<name>') when useful, then publish with manage_drafts(operation:'publish', confirm:true) or dypai_deploy_production(target:'backend', confirm:true) ONLY after explicit approval.`
|
|
798
834
|
: changedNames.length
|
|
799
835
|
? `Test changed endpoint(s) with dypai_test_endpoint: ${changedNames.slice(0, 3).join(", ")}${changedNames.length > 3 ? "…" : ""}`
|
|
800
836
|
: undefined,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "fs/promises"
|
|
3
|
+
import { dirname, join } from "path"
|
|
4
|
+
|
|
5
|
+
const BACKEND_SOURCE_DIRS = ["flows", "automations", "endpoints", "migrations", "types", "lib"]
|
|
6
|
+
const BACKEND_SOURCE_EXACT = [
|
|
7
|
+
"schema.sql",
|
|
8
|
+
"realtime.yaml",
|
|
9
|
+
"credentials.yaml",
|
|
10
|
+
"node-catalog.json",
|
|
11
|
+
"capability-catalog.json",
|
|
12
|
+
"capability-brief.md",
|
|
13
|
+
"dypai.config.yaml",
|
|
14
|
+
]
|
|
15
|
+
const BACKEND_SOURCE_EXTS = new Set([
|
|
16
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts", ".cjs", ".cts",
|
|
17
|
+
".json", ".yaml", ".yml", ".sql", ".md", ".txt",
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
function extOfPath(path) {
|
|
21
|
+
const filename = path.split("/").pop() || path
|
|
22
|
+
return filename.includes(".") ? `.${filename.split(".").pop().toLowerCase()}` : ""
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isTextBackendPath(path) {
|
|
26
|
+
return BACKEND_SOURCE_EXTS.has(extOfPath(path))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizePath(path) {
|
|
30
|
+
return String(path || "").replace(/\\/g, "/").replace(/^\/+/, "")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isTrackedBackendSourcePath(path) {
|
|
34
|
+
const normalized = normalizePath(path)
|
|
35
|
+
if (!normalized.startsWith("dypai/")) return false
|
|
36
|
+
if (normalized.startsWith("dypai/.dypai/")) return false
|
|
37
|
+
const rel = normalized.slice("dypai/".length)
|
|
38
|
+
if (BACKEND_SOURCE_EXACT.includes(rel)) return true
|
|
39
|
+
return BACKEND_SOURCE_DIRS.some((dir) => rel.startsWith(`${dir}/`)) && isTextBackendPath(rel)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hashContent(content) {
|
|
43
|
+
return createHash("sha256").update(content).digest("hex")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function addFile(files, absPath, relPath) {
|
|
47
|
+
if (!isTextBackendPath(relPath)) return
|
|
48
|
+
const content = await readFile(absPath, "utf8")
|
|
49
|
+
files.push({
|
|
50
|
+
path: `dypai/${relPath}`,
|
|
51
|
+
content,
|
|
52
|
+
sha256: hashContent(content),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function walkDir(files, absDir, relBase) {
|
|
57
|
+
let entries = []
|
|
58
|
+
try {
|
|
59
|
+
entries = await readdir(absDir, { withFileTypes: true })
|
|
60
|
+
} catch {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (entry.name.startsWith(".")) continue
|
|
65
|
+
const relPath = `${relBase}/${entry.name}`.replace(/^\/+/, "")
|
|
66
|
+
const absPath = join(absDir, entry.name)
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
await walkDir(files, absPath, relPath)
|
|
69
|
+
} else if (entry.isFile()) {
|
|
70
|
+
await addFile(files, absPath, relPath)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function collectBackendSourceFiles(rootDir) {
|
|
76
|
+
const files = []
|
|
77
|
+
|
|
78
|
+
for (const relPath of BACKEND_SOURCE_EXACT) {
|
|
79
|
+
const absPath = join(rootDir, relPath)
|
|
80
|
+
try {
|
|
81
|
+
const s = await stat(absPath)
|
|
82
|
+
if (s.isFile()) await addFile(files, absPath, relPath)
|
|
83
|
+
} catch {
|
|
84
|
+
// optional
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const dir of BACKEND_SOURCE_DIRS) {
|
|
89
|
+
await walkDir(files, join(rootDir, dir), dir)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
files.sort((a, b) => a.path.localeCompare(b.path))
|
|
93
|
+
return files
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeBaseline(raw) {
|
|
97
|
+
const parsed = JSON.parse(raw)
|
|
98
|
+
const files = parsed?.files
|
|
99
|
+
const result = new Map()
|
|
100
|
+
if (Array.isArray(files)) {
|
|
101
|
+
for (const file of files) {
|
|
102
|
+
if (typeof file?.path === "string" && typeof file?.sha256 === "string" && isTrackedBackendSourcePath(file.path)) {
|
|
103
|
+
result.set(file.path, file.sha256)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} else if (files && typeof files === "object") {
|
|
107
|
+
for (const [path, value] of Object.entries(files)) {
|
|
108
|
+
if (!isTrackedBackendSourcePath(path)) continue
|
|
109
|
+
if (typeof value === "string") result.set(path, value)
|
|
110
|
+
else if (value && typeof value === "object" && typeof value.sha256 === "string") result.set(path, value.sha256)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function readBackendSourceBaseline(rootDir) {
|
|
117
|
+
try {
|
|
118
|
+
const raw = await readFile(join(rootDir, ".dypai", "backend-baseline.json"), "utf8")
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
baseline: normalizeBaseline(raw),
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error?.code === "ENOENT") {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
missing: true,
|
|
128
|
+
baseline: new Map(),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: error?.message || String(error),
|
|
134
|
+
baseline: new Map(),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function diffBackendSource(rootDir) {
|
|
140
|
+
const currentFiles = await collectBackendSourceFiles(rootDir)
|
|
141
|
+
const current = new Map(currentFiles.map((file) => [file.path, file.sha256]))
|
|
142
|
+
const baselineResult = await readBackendSourceBaseline(rootDir)
|
|
143
|
+
|
|
144
|
+
if (!baselineResult.ok && !baselineResult.missing) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
reason: "baseline_parse_error",
|
|
148
|
+
error: baselineResult.error,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (baselineResult.missing) {
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
baseline: false,
|
|
156
|
+
files: {
|
|
157
|
+
created: currentFiles.map((file) => file.path),
|
|
158
|
+
updated: [],
|
|
159
|
+
deleted: [],
|
|
160
|
+
},
|
|
161
|
+
summary: {
|
|
162
|
+
create: currentFiles.length,
|
|
163
|
+
update: 0,
|
|
164
|
+
delete: 0,
|
|
165
|
+
unchanged: 0,
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const baseline = baselineResult.baseline
|
|
171
|
+
const created = []
|
|
172
|
+
const updated = []
|
|
173
|
+
const deleted = []
|
|
174
|
+
const unchanged = []
|
|
175
|
+
|
|
176
|
+
for (const [path, hash] of current) {
|
|
177
|
+
if (!baseline.has(path)) created.push(path)
|
|
178
|
+
else if (baseline.get(path) !== hash) updated.push(path)
|
|
179
|
+
else unchanged.push(path)
|
|
180
|
+
}
|
|
181
|
+
for (const path of baseline.keys()) {
|
|
182
|
+
if (!current.has(path)) deleted.push(path)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
baseline: true,
|
|
188
|
+
files: {
|
|
189
|
+
created,
|
|
190
|
+
updated,
|
|
191
|
+
deleted,
|
|
192
|
+
},
|
|
193
|
+
summary: {
|
|
194
|
+
create: created.length,
|
|
195
|
+
update: updated.length,
|
|
196
|
+
delete: deleted.length,
|
|
197
|
+
unchanged: unchanged.length,
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function writeBackendSourceBaseline(rootDir, projectId = null) {
|
|
203
|
+
const files = await collectBackendSourceFiles(rootDir)
|
|
204
|
+
const payload = {
|
|
205
|
+
synced_at: new Date().toISOString(),
|
|
206
|
+
...(projectId ? { project_id: projectId } : {}),
|
|
207
|
+
files: files.map((file) => ({
|
|
208
|
+
path: file.path,
|
|
209
|
+
sha256: file.sha256,
|
|
210
|
+
})),
|
|
211
|
+
}
|
|
212
|
+
const target = join(rootDir, ".dypai", "backend-baseline.json")
|
|
213
|
+
await mkdir(dirname(target), { recursive: true })
|
|
214
|
+
await writeFile(target, JSON.stringify(payload, null, 2) + "\n", "utf8")
|
|
215
|
+
return payload
|
|
216
|
+
}
|
|
217
|
+
|