@dypai-ai/mcp 1.4.1 → 1.4.3
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 +6 -2
- package/src/index.js +6 -6
- package/src/tools/deploy.js +124 -127
- package/src/tools/frontend.js +7 -2
- package/src/tools/storage.js +6 -6
- package/src/tools/sync/codec.js +50 -15
- package/src/tools/sync/diff.js +5 -1
- package/src/tools/sync/planner.js +18 -0
- package/src/tools/sync/push.js +85 -5
- package/src/tools/sync/transforms.js +18 -10
- package/src/tools/sync.js +18 -7
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -18,11 +18,15 @@ function getConfig() {
|
|
|
18
18
|
return {
|
|
19
19
|
token: process.env.DYPAI_TOKEN || "",
|
|
20
20
|
apiUrl: process.env.DYPAI_API_URL || DEFAULT_API_URL,
|
|
21
|
+
// Escape hatch for local dev against an API that doesn't have the
|
|
22
|
+
// gzip decompression middleware yet. Set DYPAI_NO_GZIP=1 to send
|
|
23
|
+
// bodies as plain JSON regardless of size.
|
|
24
|
+
noGzip: process.env.DYPAI_NO_GZIP === "1" || process.env.DYPAI_NO_GZIP === "true",
|
|
21
25
|
}
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export function request(method, path, body) {
|
|
25
|
-
const { token, apiUrl } = getConfig()
|
|
29
|
+
const { token, apiUrl, noGzip } = getConfig()
|
|
26
30
|
const url = `${apiUrl}${path}`
|
|
27
31
|
|
|
28
32
|
return new Promise((resolve, reject) => {
|
|
@@ -33,7 +37,7 @@ export function request(method, path, body) {
|
|
|
33
37
|
let payload = jsonStr ? Buffer.from(jsonStr) : null
|
|
34
38
|
let useGzip = false
|
|
35
39
|
|
|
36
|
-
if (payload && payload.length > GZIP_THRESHOLD) {
|
|
40
|
+
if (payload && payload.length > GZIP_THRESHOLD && !noGzip) {
|
|
37
41
|
payload = gzipSync(payload)
|
|
38
42
|
useGzip = true
|
|
39
43
|
}
|
package/src/index.js
CHANGED
|
@@ -103,7 +103,7 @@ const REMOTE_TOOLS = [
|
|
|
103
103
|
// ── Project ───────────────────────────────────────────────────────────────
|
|
104
104
|
{ name: "list_projects", description: "Lists all projects you have access to across your organizations. Returns project id, name, description, organization, subscription plan, and status. Use this as the first step to discover which projects are available, then pass project_id to other tools.", inputSchema: { type: "object", properties: { organization_id: { type: "string", description: "Optional. Filter projects by organization UUID." } }, required: [] } },
|
|
105
105
|
{ name: "get_project", description: "Gets detailed information about a specific project. Returns project name, description, organization, plan, status, engine URL, frontend slug, and timestamps.", inputSchema: { type: "object", properties: { project_id: { type: "string" } }, required: ["project_id"] } },
|
|
106
|
-
{ name: "create_project", description: "Create a new DYPAI project (free plan). Creates a full project with database, engine, and hosting.
|
|
106
|
+
{ name: "create_project", description: "Create a new DYPAI project (free plan). Creates a full project with database, engine, GitHub repo, and frontend hosting. BLOCKS by default until provisioning finishes (~60s typical, 120s max) — when it returns, the project_id is ready to use with execute_sql, endpoint tools, etc. Pass wait_until_ready:false for batch flows.\n\nName collision: if another project in the same org already uses the name (case-insensitive), returns {error:'name_taken', existing_project_id, suggestions:[...]}. Pick a different name or use the existing project.\n\nIMPORTANT: before calling, check for a matching template with `search_project_templates`. Passing a `template_slug` drops in a ready-made schema + endpoints + UI that cover 70% of common app types. Only create a blank project if nothing matches.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Project name (e.g. 'My Veterinary App')" }, organization_id: { type: "string", description: "Optional. Uses default org if omitted." }, description: { type: "string" }, template_slug: { type: "string", description: "RECOMMENDED. Project template slug to start from (e.g. 'clinic', 'gym', 'waitlist', 'blank'). Always call search_project_templates first to find the best match." }, wait_until_ready: { type: "boolean", description: "If true (default), blocks until provisioning completes and the project is ready for all operations. If false, returns immediately with status='provisioning' — caller must poll get_project before using.", default: true } }, required: ["name"] } },
|
|
107
107
|
{ name: "get_app_credentials", description: "Lists available credentials in the current application. Returns API keys, anon key, service role key, and engine URL needed for SDK configuration.", inputSchema: { type: "object", properties: { project_id: { type: "string" } }, required: [] } },
|
|
108
108
|
|
|
109
109
|
// ── Database ──────────────────────────────────────────────────────────────
|
|
@@ -531,7 +531,7 @@ Any endpoint can be flagged \`tool: true\` to be callable by \`agent\` nodes. Ho
|
|
|
531
531
|
│ └── create-order.test.yaml
|
|
532
532
|
└── .dypai/ ← local cache, gitignored — DO NOT touch
|
|
533
533
|
├── deploy-manifest.json ← SHA hashes from last deploy (delta engine)
|
|
534
|
-
└── media-manifest.json ← media files uploaded to
|
|
534
|
+
└── media-manifest.json ← media files uploaded to the storage bucket
|
|
535
535
|
\`\`\`
|
|
536
536
|
|
|
537
537
|
### Where to put what
|
|
@@ -576,7 +576,7 @@ query_file: /absolute/path/sql/get-orders.sql
|
|
|
576
576
|
|
|
577
577
|
- \`/api/v0/<endpoint_name>\` — HTTP endpoints
|
|
578
578
|
- \`/api/v0/webhooks/<endpoint_name>\` — webhook endpoints (different path prefix)
|
|
579
|
-
- \`/public/<path>\` — media served from
|
|
579
|
+
- \`/public/<path>\` — media served from the storage bucket (auto-populated on deploy; see "Frontend deploy")
|
|
580
580
|
- \`https://<project_id>.dypai.app\` — the engine base URL (what the SDK points to)
|
|
581
581
|
|
|
582
582
|
## Endpoint YAML skeleton (top-level fields)
|
|
@@ -785,7 +785,7 @@ Pre-configured at \`src/lib/dypai.ts\`. Every method returns \`{ data, error }\`
|
|
|
785
785
|
- **Credentials not created** → workflow fails with "credential not found". Check \`get_app_credentials\` before referencing one in a node. Create in dashboard (not via MCP yet).
|
|
786
786
|
- **Binary files in \`dypai/code/\`** → only text code files here. Binary assets go to the frontend \`public/\` or to a bucket.
|
|
787
787
|
- **\`dypai_push\` without \`dypai_validate\`** → pushing a broken workflow. Always validate first.
|
|
788
|
-
- **Frontend dev server +
|
|
788
|
+
- **Frontend dev server + remote media** → media files are auto-uploaded to the storage bucket on deploy but \`vite dev\` doesn't proxy to it. Run \`manage_frontend(sync)\` first to pull media to disk.
|
|
789
789
|
|
|
790
790
|
## Frontend
|
|
791
791
|
|
|
@@ -821,7 +821,7 @@ The deploy is delta by default: only files changed since last deploy are sent. L
|
|
|
821
821
|
- \`manage_domain\` — custom domains. \`add\` returns the CNAME to configure. \`verify\` forces DNS/SSL recheck. → \`search_docs("manage domain")\`.
|
|
822
822
|
- \`manage_users\` / \`manage_roles\` — app-level RBAC (users via better-auth).
|
|
823
823
|
- \`manage_schedules\` / \`manage_webhooks\` — pause/resume/history. To change the DEFINITION, edit the endpoint YAML and push.
|
|
824
|
-
- \`manage_storage\` — buckets + objects. \`upload_file\` reads local path, signs URL, PUTs direct to
|
|
824
|
+
- \`manage_storage\` — buckets + objects. \`upload_file\` reads local path, signs URL, PUTs direct to the storage bucket, registers. Max 100MB/file. → \`search_docs("file storage")\`.
|
|
825
825
|
|
|
826
826
|
## Deep docs — search_docs topic map
|
|
827
827
|
|
|
@@ -897,7 +897,7 @@ async function handleRequest(msg) {
|
|
|
897
897
|
let finalArgs = withProjectContext(toolDef, args || {})
|
|
898
898
|
|
|
899
899
|
// manage_storage.upload_file is orchestrated LOCALLY (filesystem →
|
|
900
|
-
// sign → direct PUT to
|
|
900
|
+
// sign → direct PUT to the bucket → verify). It's listed under manage_storage
|
|
901
901
|
// so the agent sees one unified tool, but the binary never travels
|
|
902
902
|
// through the MCP transport — that would saturate the remote pod on
|
|
903
903
|
// large files. See tools/storage.js for the 3-step flow.
|
package/src/tools/deploy.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* deploy_frontend —
|
|
2
|
+
* deploy_frontend — full-project deploy with "skip if identical" optimization.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
6
|
-
*
|
|
4
|
+
* Architecture (deliberately simple, InsForge-style):
|
|
5
|
+
* 1. Walk the project, hash every file.
|
|
6
|
+
* 2. Compute a single project-wide hash (SHA-256 of sorted path+hash pairs).
|
|
7
|
+
* 3. If this hash matches the last-deploy hash on disk → skip entirely.
|
|
8
|
+
* 4. Otherwise send ALL files to the API (full deploy, replace_all=True).
|
|
9
|
+
* 5. Poll build-status to confirm GitHub commit happened.
|
|
10
|
+
* 6. On confirmation, persist the new project hash locally.
|
|
7
11
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
12
|
+
* Why not delta? A previous delta-based implementation (incremental commits,
|
|
13
|
+
* per-file manifest, deleted_paths list) generated too many edge cases:
|
|
14
|
+
* - Manifest drift after walk-rule changes (ghost "deletions")
|
|
15
|
+
* - Optimistic manifest updates on HTTP 202 where async commit failed
|
|
16
|
+
* - Migration triggers on deletion-only deploys
|
|
17
|
+
* Each required a new defensive hack. The failure mode was always the same:
|
|
18
|
+
* client-side state drifted from remote truth. Full deploys make drift
|
|
19
|
+
* impossible: every deploy is a self-contained snapshot of project state.
|
|
20
|
+
* GitHub's blob dedup handles unchanged-content efficiency at the server
|
|
21
|
+
* side — we don't need to reinvent it client-side.
|
|
10
22
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 3. If nothing changed → skip deploy ("No changes detected")
|
|
15
|
-
* 4. Send only new+changed files + deleted_paths to API (incremental mode)
|
|
16
|
-
* 5. API commits via GitHub Trees API with base_tree (preserves unchanged)
|
|
17
|
-
* 6. Update local manifest
|
|
18
|
-
*
|
|
19
|
-
* Files > 25 MB → surfaced in `assets_requiring_action` with upload_file cmd
|
|
23
|
+
* Trade-off: ~10s extra per deploy when only one file changed (because we
|
|
24
|
+
* send the whole project over gzip-compressed wire instead of just that
|
|
25
|
+
* file). For production reliability, that trade is the right one.
|
|
20
26
|
*/
|
|
21
27
|
|
|
22
28
|
import { createHash } from "crypto"
|
|
@@ -33,10 +39,16 @@ const MAX_BUNDLED_FILE = 25 * 1024 * 1024
|
|
|
33
39
|
|
|
34
40
|
const IGNORE_DIRS = new Set([
|
|
35
41
|
"node_modules", ".git",
|
|
42
|
+
// Build outputs
|
|
36
43
|
"dist", "build", "out", ".output", ".vercel", ".netlify",
|
|
44
|
+
// Framework caches
|
|
37
45
|
".next", ".nuxt", ".svelte-kit", ".astro", ".angular", ".docusaurus",
|
|
38
46
|
".cache", ".turbo", ".vite", ".parcel-cache", ".wrangler",
|
|
47
|
+
// Test / misc
|
|
39
48
|
"coverage", "storybook-static", "__pycache__", ".idea", ".vscode",
|
|
49
|
+
// DYPAI backend metadata — handled by dypai_pull/push, not shipped to the
|
|
50
|
+
// frontend build.
|
|
51
|
+
"dypai",
|
|
40
52
|
])
|
|
41
53
|
|
|
42
54
|
// ─── Accepted file extensions ───────────────────────────────────────────────
|
|
@@ -109,33 +121,53 @@ function mediaCategory(ext) {
|
|
|
109
121
|
return "other"
|
|
110
122
|
}
|
|
111
123
|
|
|
112
|
-
// ───
|
|
113
|
-
//
|
|
114
|
-
// Tracks SHA-256 of each file at last successful deploy.
|
|
115
|
-
// Path: <sourceDirectory>/dypai/.dypai/deploy-manifest.json
|
|
124
|
+
// ─── Last-deploy hash (single value, no per-file tracking) ──────────────────
|
|
116
125
|
//
|
|
117
|
-
//
|
|
126
|
+
// Replaces the old per-file manifest. Stores ONE hash representing the entire
|
|
127
|
+
// project state at last successful deploy. Drift is impossible: either the
|
|
128
|
+
// recomputed project hash matches (skip) or it doesn't (full deploy).
|
|
118
129
|
|
|
119
|
-
function
|
|
130
|
+
function lastDeployPath(sourceDirectory) {
|
|
120
131
|
const dypaiDir = join(sourceDirectory, "dypai", ".dypai")
|
|
121
|
-
if (existsSync(join(sourceDirectory, "dypai"))) return join(dypaiDir, "deploy
|
|
122
|
-
return join(sourceDirectory, ".dypai", "deploy
|
|
132
|
+
if (existsSync(join(sourceDirectory, "dypai"))) return join(dypaiDir, "last-deploy.json")
|
|
133
|
+
return join(sourceDirectory, ".dypai", "last-deploy.json")
|
|
123
134
|
}
|
|
124
135
|
|
|
125
|
-
function
|
|
126
|
-
const path =
|
|
136
|
+
function readLastDeployHash(sourceDirectory) {
|
|
137
|
+
const path = lastDeployPath(sourceDirectory)
|
|
127
138
|
if (!existsSync(path)) return null
|
|
128
|
-
try {
|
|
139
|
+
try {
|
|
140
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"))
|
|
141
|
+
return raw && typeof raw.hash === "string" ? raw.hash : null
|
|
142
|
+
} catch { return null }
|
|
129
143
|
}
|
|
130
144
|
|
|
131
|
-
function
|
|
132
|
-
const path =
|
|
145
|
+
function writeLastDeployHash(sourceDirectory, hash) {
|
|
146
|
+
const path = lastDeployPath(sourceDirectory)
|
|
133
147
|
const dir = dirname(path)
|
|
134
148
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
135
|
-
writeFileSync(path, JSON.stringify(
|
|
149
|
+
writeFileSync(path, JSON.stringify({ hash, at: new Date().toISOString() }, null, 2) + "\n")
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Produce a single SHA-256 fingerprint that uniquely identifies the set of
|
|
154
|
+
* files about to be deployed. Files are sorted by path for determinism;
|
|
155
|
+
* path and per-file hash are joined with a NUL byte (which can't appear in
|
|
156
|
+
* either) to avoid any ambiguity between e.g. "foo"+"bar" and "foob"+"ar".
|
|
157
|
+
*/
|
|
158
|
+
function computeProjectHash(files) {
|
|
159
|
+
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path))
|
|
160
|
+
const h = createHash("sha256")
|
|
161
|
+
for (const f of sorted) {
|
|
162
|
+
h.update(f.path)
|
|
163
|
+
h.update("\0")
|
|
164
|
+
h.update(f.hash)
|
|
165
|
+
h.update("\0")
|
|
166
|
+
}
|
|
167
|
+
return h.digest("hex")
|
|
136
168
|
}
|
|
137
169
|
|
|
138
|
-
// ─── Media manifest (
|
|
170
|
+
// ─── Media manifest (kept — tracks bucket uploads for the upload_file tool) ─
|
|
139
171
|
|
|
140
172
|
function mediaManifestPath(sourceDirectory) {
|
|
141
173
|
const dypaiDir = join(sourceDirectory, "dypai", ".dypai")
|
|
@@ -188,18 +220,9 @@ function classifySkip(path, ext, size) {
|
|
|
188
220
|
return null
|
|
189
221
|
}
|
|
190
222
|
|
|
191
|
-
/**
|
|
192
|
-
* Walk directory. Returns:
|
|
193
|
-
* allFiles: [{ path, content (base64), hash (sha256) }]
|
|
194
|
-
* skipped: [{ local_path, ext, size_bytes, ... }]
|
|
195
|
-
* total: bytes of accepted files
|
|
196
|
-
* hashMap: { path: sha256 }
|
|
197
|
-
* textByPath: Map<path, string>
|
|
198
|
-
*/
|
|
199
223
|
function collectSource(dir) {
|
|
200
224
|
const allFiles = []
|
|
201
225
|
const skipped = []
|
|
202
|
-
const hashMap = {}
|
|
203
226
|
const textByPath = new Map()
|
|
204
227
|
let total = 0
|
|
205
228
|
|
|
@@ -251,7 +274,6 @@ function collectSource(dir) {
|
|
|
251
274
|
|
|
252
275
|
total += content.length
|
|
253
276
|
const hash = sha256(content)
|
|
254
|
-
hashMap[path] = hash
|
|
255
277
|
allFiles.push({ path, content: content.toString("base64"), hash })
|
|
256
278
|
|
|
257
279
|
if (CODE_SEARCH_EXTS.has(ext)) {
|
|
@@ -262,36 +284,10 @@ function collectSource(dir) {
|
|
|
262
284
|
}
|
|
263
285
|
|
|
264
286
|
walk(dir, "")
|
|
265
|
-
return { allFiles, total, skipped,
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// ─── Delta computation ──────────────────────────────────────────────────────
|
|
269
|
-
|
|
270
|
-
function computeDelta(hashMap, prevManifest) {
|
|
271
|
-
if (!prevManifest) return { isFirstDeploy: true, changed: Object.keys(hashMap), deleted: [], unchanged: [] }
|
|
272
|
-
|
|
273
|
-
const changed = []
|
|
274
|
-
const unchanged = []
|
|
275
|
-
const deleted = []
|
|
276
|
-
|
|
277
|
-
for (const [path, hash] of Object.entries(hashMap)) {
|
|
278
|
-
if (prevManifest[path] === hash) {
|
|
279
|
-
unchanged.push(path)
|
|
280
|
-
} else {
|
|
281
|
-
changed.push(path)
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
for (const path of Object.keys(prevManifest)) {
|
|
286
|
-
if (!(path in hashMap)) {
|
|
287
|
-
deleted.push(path)
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return { isFirstDeploy: false, changed, deleted, unchanged }
|
|
287
|
+
return { allFiles, total, skipped, textByPath }
|
|
292
288
|
}
|
|
293
289
|
|
|
294
|
-
// ─── Reference grep
|
|
290
|
+
// ─── Reference grep for assets_requiring_action ─────────────────────────────
|
|
295
291
|
|
|
296
292
|
function grepReferences(skipped, textByPath) {
|
|
297
293
|
if (skipped.length === 0) return skipped
|
|
@@ -403,9 +399,36 @@ function detectFramework(dir) {
|
|
|
403
399
|
} catch { return null }
|
|
404
400
|
}
|
|
405
401
|
|
|
402
|
+
// ─── Post-deploy confirmation ───────────────────────────────────────────────
|
|
403
|
+
//
|
|
404
|
+
// Polls /build-status after the API accepts the deploy. The API returns 202
|
|
405
|
+
// queued immediately but the GitHub commit happens in a background task; we
|
|
406
|
+
// only persist the new "last deploy" hash once the commit has clearly
|
|
407
|
+
// happened (status moved past "queued"). If the background task fails, we
|
|
408
|
+
// leave the hash untouched — the next deploy will re-send everything.
|
|
409
|
+
|
|
410
|
+
async function confirmDeploySucceeded(project_id) {
|
|
411
|
+
const MAX_POLLS = 6
|
|
412
|
+
const INTERVAL_MS = 2000
|
|
413
|
+
|
|
414
|
+
for (let i = 0; i < MAX_POLLS; i++) {
|
|
415
|
+
await new Promise(r => setTimeout(r, INTERVAL_MS))
|
|
416
|
+
try {
|
|
417
|
+
const res = await api.get(`/api/engine/${project_id}/frontend/build-status`)
|
|
418
|
+
const status = res?.status || res?.build_status || null
|
|
419
|
+
if (status === "failed") return { confirmed: false, status, reason: "build_failed" }
|
|
420
|
+
if (status === "building" || status === "success") return { confirmed: true, status, reason: null }
|
|
421
|
+
} catch {
|
|
422
|
+
// Transient — keep polling
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Never left "queued" — treat as unconfirmed (safer to re-deploy next time)
|
|
426
|
+
return { confirmed: false, status: "queued", reason: "confirmation_timeout" }
|
|
427
|
+
}
|
|
428
|
+
|
|
406
429
|
// ─── Public entrypoint ──────────────────────────────────────────────────────
|
|
407
430
|
|
|
408
|
-
export async function deployFromSource({ sourceDirectory, project_id }) {
|
|
431
|
+
export async function deployFromSource({ sourceDirectory, project_id, force = false }) {
|
|
409
432
|
if (!existsSync(sourceDirectory)) {
|
|
410
433
|
return { error: `Directory not found: ${sourceDirectory}` }
|
|
411
434
|
}
|
|
@@ -416,76 +439,58 @@ export async function deployFromSource({ sourceDirectory, project_id }) {
|
|
|
416
439
|
_lastSourceDirectory = resolve(sourceDirectory)
|
|
417
440
|
|
|
418
441
|
const framework = detectFramework(sourceDirectory)
|
|
419
|
-
const { allFiles, total, skipped,
|
|
442
|
+
const { allFiles, total, skipped, textByPath } = collectSource(sourceDirectory)
|
|
420
443
|
|
|
421
444
|
if (!allFiles.length) {
|
|
422
445
|
return { error: "No source files found." }
|
|
423
446
|
}
|
|
424
447
|
|
|
425
|
-
// ──
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (!
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
448
|
+
// ── Skip-if-identical check ─────────────────────────────────────────────
|
|
449
|
+
// One hash represents the whole project state. Same hash → nothing to do.
|
|
450
|
+
// Different hash (or no previous hash) → full deploy.
|
|
451
|
+
const projectHash = computeProjectHash(allFiles)
|
|
452
|
+
|
|
453
|
+
if (!force) {
|
|
454
|
+
const lastHash = readLastDeployHash(sourceDirectory)
|
|
455
|
+
if (lastHash && lastHash === projectHash) {
|
|
456
|
+
return {
|
|
457
|
+
success: true,
|
|
458
|
+
deployed: false,
|
|
459
|
+
skipped_reason: "no_changes",
|
|
460
|
+
files_total: allFiles.length,
|
|
461
|
+
project_hash: projectHash,
|
|
462
|
+
message: `No changes since last deploy (${allFiles.length} files match). Pass force:true to re-deploy anyway.`,
|
|
463
|
+
}
|
|
437
464
|
}
|
|
438
465
|
}
|
|
439
466
|
|
|
440
|
-
// ── Build payload (
|
|
441
|
-
const isIncremental = !delta.isFirstDeploy && delta.unchanged.length > 0
|
|
442
|
-
let filesToSend, payloadSize
|
|
443
|
-
|
|
444
|
-
if (isIncremental) {
|
|
445
|
-
// Only send new + changed files
|
|
446
|
-
const changedSet = new Set(delta.changed)
|
|
447
|
-
filesToSend = allFiles.filter(f => changedSet.has(f.path))
|
|
448
|
-
payloadSize = filesToSend.reduce((sum, f) => sum + Buffer.byteLength(f.content, "base64") * 0.75, 0)
|
|
449
|
-
} else {
|
|
450
|
-
filesToSend = allFiles
|
|
451
|
-
payloadSize = total
|
|
452
|
-
}
|
|
453
|
-
|
|
467
|
+
// ── Build payload (always full) ────────────────────────────────────────
|
|
454
468
|
grepReferences(skipped, textByPath)
|
|
455
469
|
const mediaManifest = readMediaManifest(sourceDirectory)
|
|
456
470
|
|
|
457
471
|
try {
|
|
458
472
|
const body = {
|
|
459
|
-
files:
|
|
473
|
+
files: allFiles.map(f => ({ path: f.path, content: f.content })),
|
|
460
474
|
framework: framework?.id ?? null,
|
|
461
475
|
}
|
|
462
476
|
|
|
463
|
-
// Signal incremental mode to the API
|
|
464
|
-
if (isIncremental) {
|
|
465
|
-
body.incremental = true
|
|
466
|
-
if (delta.deleted.length > 0) {
|
|
467
|
-
body.deleted_paths = delta.deleted
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
477
|
const result = await api.post(
|
|
472
478
|
`/api/engine/${project_id}/frontend/deploy/source`,
|
|
473
479
|
body,
|
|
474
480
|
)
|
|
475
481
|
|
|
476
|
-
// ──
|
|
477
|
-
|
|
482
|
+
// ── Confirm the background task actually committed ──────────────────
|
|
483
|
+
const confirmation = await confirmDeploySucceeded(project_id)
|
|
484
|
+
if (confirmation.confirmed) {
|
|
485
|
+
writeLastDeployHash(sourceDirectory, projectHash)
|
|
486
|
+
}
|
|
478
487
|
|
|
479
488
|
const label = framework?.label ?? "Project"
|
|
480
489
|
const assetsAction = buildAssetsRequiringAction(skipped, mediaManifest, project_id)
|
|
481
490
|
const hasUnresolvedAssets = assetsAction && assetsAction.count > 0
|
|
482
491
|
|
|
483
|
-
const deltaInfo = isIncremental
|
|
484
|
-
? `Delta deploy: ${delta.changed.length} changed, ${delta.deleted.length} deleted, ${delta.unchanged.length} unchanged (sent ${formatBytes(payloadSize)} instead of ${formatBytes(total)})`
|
|
485
|
-
: `Full deploy: ${allFiles.length} files (${formatBytes(total)})`
|
|
486
|
-
|
|
487
492
|
const baseMessage =
|
|
488
|
-
`Deploy accepted — ${
|
|
493
|
+
`Deploy accepted — ${allFiles.length} files (${formatBytes(total)}). ` +
|
|
489
494
|
`Build running (${label}, ~20-60s). Poll manage_frontend({operation:"build_status"}) every ~5s.`
|
|
490
495
|
|
|
491
496
|
let message = baseMessage
|
|
@@ -498,20 +503,12 @@ export async function deployFromSource({ sourceDirectory, project_id }) {
|
|
|
498
503
|
deployed: false,
|
|
499
504
|
url: result.url,
|
|
500
505
|
framework: label,
|
|
501
|
-
build_status: "queued",
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
files_total: allFiles.length,
|
|
508
|
-
files_changed: delta.changed.length,
|
|
509
|
-
files_deleted: delta.deleted.length,
|
|
510
|
-
files_unchanged: delta.unchanged.length,
|
|
511
|
-
bytes_sent: Math.round(payloadSize),
|
|
512
|
-
bytes_total: total,
|
|
513
|
-
savings: isIncremental ? `${Math.round((1 - payloadSize / total) * 100)}%` : "0% (first deploy)",
|
|
514
|
-
},
|
|
506
|
+
build_status: confirmation.status || "queued",
|
|
507
|
+
files_total: allFiles.length,
|
|
508
|
+
bytes_total: total,
|
|
509
|
+
project_hash: projectHash,
|
|
510
|
+
last_deploy_hash_updated: confirmation.confirmed,
|
|
511
|
+
...(confirmation.reason ? { confirmation_warning: confirmation.reason } : {}),
|
|
515
512
|
|
|
516
513
|
...(hasUnresolvedAssets ? { assets_requiring_action: assetsAction } : {}),
|
|
517
514
|
...(assetsAction?.all_synced ? { assets_synced: assetsAction.already_in_bucket } : {}),
|
|
@@ -522,7 +519,7 @@ export async function deployFromSource({ sourceDirectory, project_id }) {
|
|
|
522
519
|
priority_order: [
|
|
523
520
|
"1. Execute each suggested_action in assets_requiring_action.files.",
|
|
524
521
|
"2. Edit source files to use the returned URL.",
|
|
525
|
-
"3. Re-deploy.
|
|
522
|
+
"3. Re-deploy.",
|
|
526
523
|
"4. Poll build_status until terminal.",
|
|
527
524
|
],
|
|
528
525
|
terminal_statuses: ["success", "failure"],
|
package/src/tools/frontend.js
CHANGED
|
@@ -60,6 +60,11 @@ export const manageFrontendTool = {
|
|
|
60
60
|
description: "sync only. When true, allows writing into a directory that already has a project. Local-only files (.env, node_modules, etc.) are NOT touched. Default: false.",
|
|
61
61
|
default: false,
|
|
62
62
|
},
|
|
63
|
+
force: {
|
|
64
|
+
type: "boolean",
|
|
65
|
+
description: "deploy only. Bypass the delta manifest and re-send ALL files (full deploy). Use when the previous deploy's remote build FAILED — the manifest says 'synced' but the remote never built, so a normal delta incorrectly reports no_changes. Default: false.",
|
|
66
|
+
default: false,
|
|
67
|
+
},
|
|
63
68
|
deployment_id: {
|
|
64
69
|
type: "string",
|
|
65
70
|
description: "logs only. Deployment UUID obtained from list_deployments.",
|
|
@@ -72,7 +77,7 @@ export const manageFrontendTool = {
|
|
|
72
77
|
required: ["operation"],
|
|
73
78
|
},
|
|
74
79
|
|
|
75
|
-
async execute({ operation, project_id, sourceDirectory, targetDirectory, overwrite, deployment_id, limit } = {}) {
|
|
80
|
+
async execute({ operation, project_id, sourceDirectory, targetDirectory, overwrite, force, deployment_id, limit } = {}) {
|
|
76
81
|
if (!operation) {
|
|
77
82
|
return { success: false, error: "operation is required (deploy | sync | status | build_status | list_deployments | logs)." }
|
|
78
83
|
}
|
|
@@ -86,7 +91,7 @@ export const manageFrontendTool = {
|
|
|
86
91
|
if (!sourceDirectory) {
|
|
87
92
|
return { success: false, error: "operation 'deploy' requires 'sourceDirectory' (absolute path to your frontend project root)." }
|
|
88
93
|
}
|
|
89
|
-
return await deployFromSource({ sourceDirectory, project_id })
|
|
94
|
+
return await deployFromSource({ sourceDirectory, project_id, force: !!force })
|
|
90
95
|
|
|
91
96
|
case "sync":
|
|
92
97
|
if (!targetDirectory) {
|
package/src/tools/storage.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* the frontend SDK:
|
|
11
11
|
*
|
|
12
12
|
* 1. Ask the remote MCP → API for a signed PUT URL (op: sign_upload).
|
|
13
|
-
* 2. PUT the file binary DIRECTLY to
|
|
13
|
+
* 2. PUT the file binary DIRECTLY to the storage bucket from the local machine (no infra hop).
|
|
14
14
|
* 3. Ask the remote MCP → API to register the object (op: verify_upload).
|
|
15
15
|
*
|
|
16
16
|
* Net effect: the agent just calls `manage_storage(operation:"upload_file",
|
|
@@ -34,7 +34,7 @@ import { updateMediaManifest } from "./deploy.js"
|
|
|
34
34
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
|
|
35
35
|
|
|
36
36
|
// Extensions we know how to MIME-type without shelling out. Everything else
|
|
37
|
-
// falls back to application/octet-stream, which
|
|
37
|
+
// falls back to application/octet-stream, which the bucket accepts fine — the browser
|
|
38
38
|
// just won't preview it inline.
|
|
39
39
|
const MIME_BY_EXT = {
|
|
40
40
|
// Images
|
|
@@ -169,7 +169,7 @@ export async function uploadFile({
|
|
|
169
169
|
return { success: false, error: "sign_upload returned an invalid payload (no upload_url or file_path)." }
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
// ── Step 2: direct PUT to
|
|
172
|
+
// ── Step 2: direct PUT to the bucket ────────────────────────────────────
|
|
173
173
|
let buf
|
|
174
174
|
try {
|
|
175
175
|
buf = readFileSync(local_path)
|
|
@@ -190,11 +190,11 @@ export async function uploadFile({
|
|
|
190
190
|
const detail = await safeReadBody(res)
|
|
191
191
|
return {
|
|
192
192
|
success: false,
|
|
193
|
-
error: `
|
|
193
|
+
error: `Storage upload failed with status ${res.status}. ${detail ? `Detail: ${detail.slice(0, 300)}` : ""}`.trim(),
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
196
|
} catch (e) {
|
|
197
|
-
return { success: false, error: `Direct upload to
|
|
197
|
+
return { success: false, error: `Direct upload to storage failed: ${e.message}` }
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
// ── Step 3: verify_upload ───────────────────────────────────────────────
|
|
@@ -213,7 +213,7 @@ export async function uploadFile({
|
|
|
213
213
|
} catch (e) {
|
|
214
214
|
return {
|
|
215
215
|
success: false,
|
|
216
|
-
error: `verify_upload failed (the file WAS uploaded to
|
|
216
|
+
error: `verify_upload failed (the file WAS uploaded to storage but not registered): ${e.message}. You can retry the upload — verify_upload is idempotent on (bucket, name).`,
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
219
|
|
package/src/tools/sync/codec.js
CHANGED
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
* "doc" is the YAML-ready declarative object.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
pullNodeParams,
|
|
17
|
+
pushNodeParams,
|
|
18
|
+
SQL_INLINE_MAX_CHARS,
|
|
19
|
+
PROMPT_INLINE_MAX_CHARS,
|
|
20
|
+
} from "./transforms.js"
|
|
16
21
|
|
|
17
22
|
// ─── Triggers (execution_config.triggers ↔ doc.trigger) ─────────────────────
|
|
18
23
|
|
|
@@ -23,14 +28,23 @@ function triggersToYaml(triggers = {}) {
|
|
|
23
28
|
const telegramEnabled = triggers.telegram?.enabled
|
|
24
29
|
for (const [kind, cfg] of Object.entries(triggers)) {
|
|
25
30
|
if (!cfg) continue
|
|
31
|
+
// Strip `enabled` from every block — it's an engine-internal flag. The
|
|
32
|
+
// presence of the key in the YAML already means "enabled". Everything else
|
|
33
|
+
// (auth_mode, path, cron, timezone, payload, stripe_webhook,
|
|
34
|
+
// hmac_secret_env, methods, …) MUST round-trip unchanged — without this,
|
|
35
|
+
// webhook signature verification and schedule payloads silently disappear
|
|
36
|
+
// on pull → push.
|
|
26
37
|
if (kind === "http_api" && cfg.enabled !== false) {
|
|
27
|
-
|
|
38
|
+
const { enabled, ...rest } = cfg
|
|
39
|
+
out.http_api = { auth_mode: rest.auth_mode || "jwt", ...rest }
|
|
28
40
|
} else if (kind === "webhook" && cfg.enabled && !telegramEnabled) {
|
|
29
|
-
|
|
41
|
+
const { enabled, ...rest } = cfg
|
|
42
|
+
// Ensure `path` is always present (default empty) but keep every other
|
|
43
|
+
// field the engine stored (methods, stripe_webhook, hmac_secret_env, etc.).
|
|
44
|
+
out.webhook = { path: rest.path || "", ...rest }
|
|
30
45
|
} else if (kind === "schedule" && cfg.enabled) {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
out.schedule = s
|
|
46
|
+
const { enabled, ...rest } = cfg
|
|
47
|
+
out.schedule = { ...rest }
|
|
34
48
|
} else if (kind === "manual" && cfg.enabled) {
|
|
35
49
|
out.manual = true
|
|
36
50
|
} else if (kind === "telegram" && cfg.enabled) {
|
|
@@ -54,15 +68,19 @@ function triggersToDb(trigger = {}) {
|
|
|
54
68
|
const httpApi = trigger.http_api === true ? {} : trigger.http_api
|
|
55
69
|
const hasNonHttpTrigger = trigger.webhook || trigger.schedule || trigger.manual || trigger.telegram
|
|
56
70
|
if (httpApi) {
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
// Preserve all extra http_api fields (methods, rate_limit, etc.) while
|
|
72
|
+
// normalizing auth_mode / mode into auth_mode.
|
|
73
|
+
const { mode, auth_mode, ...rest } = httpApi
|
|
74
|
+
out.http_api = { enabled: true, auth_mode: auth_mode || mode || "jwt", ...rest }
|
|
59
75
|
} else if (hasNonHttpTrigger) {
|
|
60
76
|
// Non-HTTP trigger: disable http_api
|
|
61
77
|
out.http_api = { enabled: false, auth_mode: "jwt" }
|
|
62
78
|
}
|
|
63
79
|
if (trigger.webhook) {
|
|
64
80
|
const wh = trigger.webhook === true ? {} : trigger.webhook
|
|
65
|
-
|
|
81
|
+
// Preserve all webhook fields (methods, stripe_webhook, hmac_secret_env,
|
|
82
|
+
// auth_mode, etc.) — previously only `path` survived push.
|
|
83
|
+
out.webhook = { path: wh.path || "", ...wh, enabled: true }
|
|
66
84
|
}
|
|
67
85
|
if (trigger.schedule) {
|
|
68
86
|
out.schedule = { ...trigger.schedule, enabled: true }
|
|
@@ -84,13 +102,18 @@ function triggersToDb(trigger = {}) {
|
|
|
84
102
|
// ─── Edges (source/target ↔ from/to) ────────────────────────────────────────
|
|
85
103
|
|
|
86
104
|
function edgesToYaml(edges = []) {
|
|
87
|
-
|
|
105
|
+
// Pull-side: be symmetric with edgesToDb which throws on missing source/target.
|
|
106
|
+
// Instead of silently producing `{ from: undefined, to: undefined }`, we skip
|
|
107
|
+
// malformed edges entirely. This shouldn't happen with a healthy engine but
|
|
108
|
+
// protects the YAML from half-broken entries that would confuse diff/push.
|
|
109
|
+
return (edges || []).map(e => {
|
|
110
|
+
if (!e || typeof e !== "object" || !e.source || !e.target) return null
|
|
88
111
|
const out = { from: e.source, to: e.target }
|
|
89
112
|
if (e.condition) out.condition = e.condition
|
|
90
113
|
if (e.source_handle) out.source_handle = e.source_handle
|
|
91
114
|
if (e.target_handle) out.target_handle = e.target_handle
|
|
92
115
|
return out
|
|
93
|
-
})
|
|
116
|
+
}).filter(Boolean)
|
|
94
117
|
}
|
|
95
118
|
|
|
96
119
|
function edgesToDb(edges = []) {
|
|
@@ -127,12 +150,24 @@ export function serializeEndpoint(row, mapsCtx) {
|
|
|
127
150
|
const extractedFiles = []
|
|
128
151
|
const emitFile = (path, content) => extractedFiles.push({ path, content })
|
|
129
152
|
|
|
130
|
-
// Pre-count nodes that produce extracted files — used to decide flat vs
|
|
153
|
+
// Pre-count nodes that produce extracted files — used to decide flat vs
|
|
154
|
+
// dotted filenames. Only count nodes whose content will actually be
|
|
155
|
+
// extracted (i.e. exceeds the inline threshold). Short content stays
|
|
156
|
+
// inline in the YAML and doesn't contribute to the count, so files don't
|
|
157
|
+
// get spurious `.node-id` suffixes when there's only one actual file.
|
|
158
|
+
// Thresholds are imported from transforms.js to keep the two sides in sync.
|
|
131
159
|
const rawNodes = wf.nodes || []
|
|
132
|
-
const sqlNodeCount = rawNodes.filter(n =>
|
|
133
|
-
|
|
160
|
+
const sqlNodeCount = rawNodes.filter(n =>
|
|
161
|
+
n.node_type === "dypai_database" &&
|
|
162
|
+
n.parameters?.query && n.parameters.query.length > SQL_INLINE_MAX_CHARS
|
|
163
|
+
).length
|
|
164
|
+
const promptNodeCount = rawNodes.filter(n =>
|
|
165
|
+
n.node_type === "agent" &&
|
|
166
|
+
n.parameters?.system_prompt && n.parameters.system_prompt.length > PROMPT_INLINE_MAX_CHARS
|
|
167
|
+
).length
|
|
134
168
|
const codeNodeCount = rawNodes.filter(n =>
|
|
135
|
-
(n.node_type === "javascript_code" || n.node_type === "python_code") &&
|
|
169
|
+
(n.node_type === "javascript_code" || n.node_type === "python_code") &&
|
|
170
|
+
n.parameters?.code && n.parameters.code.length > SQL_INLINE_MAX_CHARS
|
|
136
171
|
).length
|
|
137
172
|
|
|
138
173
|
const nodes = rawNodes.map(node => {
|
package/src/tools/sync/diff.js
CHANGED
|
@@ -65,7 +65,8 @@ export const dypaiDiffTool = {
|
|
|
65
65
|
stateSnapshot,
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
-
const
|
|
68
|
+
const groupChanges = (plan.groups?.create?.length || 0) + (plan.groups?.delete?.length || 0)
|
|
69
|
+
const totalChanges = plan.create.length + plan.update.length + plan.delete.length + groupChanges
|
|
69
70
|
const hasConflicts = (plan.warnings || []).some(w => w.type === "remote_changed_since_pull")
|
|
70
71
|
|
|
71
72
|
return {
|
|
@@ -76,6 +77,9 @@ export const dypaiDiffTool = {
|
|
|
76
77
|
delete: plan.delete.length,
|
|
77
78
|
unchanged: plan.unchanged.length,
|
|
78
79
|
orphans_ignored: plan.orphansIgnored?.length || 0,
|
|
80
|
+
groups_create: plan.groups?.create?.length || 0,
|
|
81
|
+
groups_delete: plan.groups?.delete?.length || 0,
|
|
82
|
+
groups_unchanged: plan.groups?.unchanged?.length || 0,
|
|
79
83
|
warnings: plan.warnings?.length || 0,
|
|
80
84
|
},
|
|
81
85
|
// Only include plan details when there are actual changes or warnings
|
|
@@ -389,6 +389,24 @@ export function computePlan(local, remote, mapsCtx, { deleteOrphans = false, sta
|
|
|
389
389
|
const plan = { create: [], update: [], delete: [], unchanged: [] }
|
|
390
390
|
const warnings = []
|
|
391
391
|
|
|
392
|
+
// ── Groups plan ──────────────────────────────────────────────────────
|
|
393
|
+
// Groups are 100% folder-driven: each first-level subfolder in endpoints/
|
|
394
|
+
// IS a group. The remote is made to match the local folder structure
|
|
395
|
+
// unconditionally — new folders create groups, disappeared folders delete
|
|
396
|
+
// them. No flag, no opt-in. If a user later re-creates the folder, the
|
|
397
|
+
// group is re-created automatically. Zero friction.
|
|
398
|
+
const localGroups = new Set()
|
|
399
|
+
for (const entry of Object.values(local.byName)) {
|
|
400
|
+
const g = entry?.doc?.group
|
|
401
|
+
if (g) localGroups.add(g)
|
|
402
|
+
}
|
|
403
|
+
const remoteGroupNames = new Set(Object.keys(mapsCtx.groupNameToId || {}))
|
|
404
|
+
plan.groups = {
|
|
405
|
+
create: [...localGroups].filter(g => !remoteGroupNames.has(g)).sort(),
|
|
406
|
+
delete: [...remoteGroupNames].filter(g => !localGroups.has(g)).sort(),
|
|
407
|
+
unchanged: [...localGroups].filter(g => remoteGroupNames.has(g)).sort(),
|
|
408
|
+
}
|
|
409
|
+
|
|
392
410
|
for (const name of Object.keys(local.byName)) {
|
|
393
411
|
const entry = local.byName[name]
|
|
394
412
|
const localCanonical = localToComparable(entry, mapsCtx)
|
package/src/tools/sync/push.js
CHANGED
|
@@ -109,6 +109,46 @@ async function applyDelete(endpointId, projectId) {
|
|
|
109
109
|
return assertMutationOK(res, "delete", endpointId)
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// ─── Group apply helpers ───────────────────────────────────────────────────
|
|
113
|
+
//
|
|
114
|
+
// Groups are fully folder-driven: create missing, delete orphans. The push
|
|
115
|
+
// flow calls these BEFORE endpoint creates (so new endpoints can reference
|
|
116
|
+
// freshly-created groups) and AFTER endpoint deletes (so groups are only
|
|
117
|
+
// orphan once their endpoints are gone).
|
|
118
|
+
|
|
119
|
+
async function applyGroupCreate(name, projectId, mapsCtx) {
|
|
120
|
+
const res = await proxyToolCall("manage_endpoint_groups", {
|
|
121
|
+
...(projectId ? { project_id: projectId } : {}),
|
|
122
|
+
operation: "create",
|
|
123
|
+
name,
|
|
124
|
+
})
|
|
125
|
+
// Response shape: { group_id | id, name, ... }. Accept both.
|
|
126
|
+
const id = res?.group_id || res?.id
|
|
127
|
+
if (!id) {
|
|
128
|
+
throw new Error(`manage_endpoint_groups(create) did not return a group id for '${name}': ${JSON.stringify(res).slice(0, 200)}`)
|
|
129
|
+
}
|
|
130
|
+
// Update the in-memory map so subsequent endpoint resolutions use the new id.
|
|
131
|
+
mapsCtx.groupNameToId[name] = id
|
|
132
|
+
mapsCtx.groupIdToName[id] = name
|
|
133
|
+
return id
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function applyGroupDelete(name, projectId, mapsCtx) {
|
|
137
|
+
const id = mapsCtx.groupNameToId[name]
|
|
138
|
+
if (!id) {
|
|
139
|
+
throw new Error(`cannot delete group '${name}': no id in remote map (already gone?)`)
|
|
140
|
+
}
|
|
141
|
+
const res = await proxyToolCall("manage_endpoint_groups", {
|
|
142
|
+
...(projectId ? { project_id: projectId } : {}),
|
|
143
|
+
operation: "delete",
|
|
144
|
+
group_id: id,
|
|
145
|
+
})
|
|
146
|
+
assertMutationOK(res, "delete group", name)
|
|
147
|
+
delete mapsCtx.groupNameToId[name]
|
|
148
|
+
delete mapsCtx.groupIdToName[id]
|
|
149
|
+
return id
|
|
150
|
+
}
|
|
151
|
+
|
|
112
152
|
// ─── Realtime policies sync ────────────────────────────────────────────────
|
|
113
153
|
|
|
114
154
|
/**
|
|
@@ -320,7 +360,8 @@ export const dypaiPushTool = {
|
|
|
320
360
|
deleteOrphans: delete_orphans,
|
|
321
361
|
stateSnapshot,
|
|
322
362
|
})
|
|
323
|
-
const
|
|
363
|
+
const groupChanges = (plan.groups?.create?.length || 0) + (plan.groups?.delete?.length || 0)
|
|
364
|
+
const totalChanges = plan.create.length + plan.update.length + plan.delete.length + groupChanges
|
|
324
365
|
|
|
325
366
|
// Block push on conflicts unless forced
|
|
326
367
|
const conflicts = (plan.warnings || []).filter(w => w.type === "remote_changed_since_pull")
|
|
@@ -344,13 +385,32 @@ export const dypaiPushTool = {
|
|
|
344
385
|
}
|
|
345
386
|
}
|
|
346
387
|
|
|
347
|
-
// Apply in order:
|
|
348
|
-
//
|
|
349
|
-
// endpoints
|
|
388
|
+
// Apply in order:
|
|
389
|
+
// 1. Create missing groups (so endpoint creates can reference them)
|
|
390
|
+
// 2. Create endpoints
|
|
391
|
+
// 3. Update endpoints
|
|
392
|
+
// 4. Delete endpoints
|
|
393
|
+
// 5. Delete orphan groups (only once their endpoints are gone)
|
|
394
|
+
//
|
|
395
|
+
// Within each endpoint phase we run with bounded concurrency (5) so
|
|
396
|
+
// pushing 50 endpoints doesn't take 50 * 200ms = 10s — drops to ~2s.
|
|
397
|
+
// Group operations are sequential because they're fast (a handful of
|
|
398
|
+
// groups typically) and we need mapsCtx mutations to be race-free.
|
|
350
399
|
const CONCURRENCY = 5
|
|
351
|
-
const applied = { created: [], updated: [], deleted: [] }
|
|
400
|
+
const applied = { created: [], updated: [], deleted: [], groups_created: [], groups_deleted: [] }
|
|
352
401
|
const errors = []
|
|
353
402
|
|
|
403
|
+
// Phase 1 — create missing groups BEFORE any endpoint is created,
|
|
404
|
+
// so endpoint creates can resolve their group_id correctly.
|
|
405
|
+
for (const groupName of plan.groups?.create || []) {
|
|
406
|
+
try {
|
|
407
|
+
await applyGroupCreate(groupName, targetProjectId, remote.mapsCtx)
|
|
408
|
+
applied.groups_created.push(groupName)
|
|
409
|
+
} catch (e) {
|
|
410
|
+
errors.push({ op: "create_group", group: groupName, error: e.message })
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
354
414
|
await runWithConcurrency(plan.create, CONCURRENCY, async (item) => {
|
|
355
415
|
try {
|
|
356
416
|
const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
|
|
@@ -384,6 +444,21 @@ export const dypaiPushTool = {
|
|
|
384
444
|
}
|
|
385
445
|
})
|
|
386
446
|
|
|
447
|
+
// Phase 5 — delete orphan groups AFTER endpoint deletes, so a group
|
|
448
|
+
// is only orphan once all its endpoints are gone. This runs only when
|
|
449
|
+
// delete_orphans=true OR when the group has no endpoints on either
|
|
450
|
+
// side (safe case). We always try: the remote API will refuse to
|
|
451
|
+
// delete a group that still has endpoints, which is the correct
|
|
452
|
+
// safety net.
|
|
453
|
+
for (const groupName of plan.groups?.delete || []) {
|
|
454
|
+
try {
|
|
455
|
+
await applyGroupDelete(groupName, targetProjectId, remote.mapsCtx)
|
|
456
|
+
applied.groups_deleted.push(groupName)
|
|
457
|
+
} catch (e) {
|
|
458
|
+
errors.push({ op: "delete_group", group: groupName, error: e.message })
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
387
462
|
// Also reconcile realtime policies if realtime.yaml exists
|
|
388
463
|
let realtime = null
|
|
389
464
|
try {
|
|
@@ -407,6 +482,8 @@ export const dypaiPushTool = {
|
|
|
407
482
|
updated: applied.updated.length,
|
|
408
483
|
deleted: applied.deleted.length,
|
|
409
484
|
unchanged: plan.unchanged.length,
|
|
485
|
+
groups_created: applied.groups_created.length,
|
|
486
|
+
groups_deleted: applied.groups_deleted.length,
|
|
410
487
|
errors: errors.length,
|
|
411
488
|
realtime: realtime || { skipped: true, reason: "no realtime.yaml" },
|
|
412
489
|
},
|
|
@@ -434,5 +511,8 @@ function summaryFromPlan(plan) {
|
|
|
434
511
|
delete: plan.delete.length,
|
|
435
512
|
unchanged: plan.unchanged.length,
|
|
436
513
|
orphans_ignored: plan.orphansIgnored?.length || 0,
|
|
514
|
+
groups_create: plan.groups?.create?.length || 0,
|
|
515
|
+
groups_delete: plan.groups?.delete?.length || 0,
|
|
516
|
+
groups_unchanged: plan.groups?.unchanged?.length || 0,
|
|
437
517
|
}
|
|
438
518
|
}
|
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
|
|
26
26
|
// Only extract truly large content. Below these thresholds, SQL and prompts
|
|
27
27
|
// stay inline in the YAML so the endpoint is one self-contained file.
|
|
28
|
-
|
|
29
|
-
const
|
|
28
|
+
// Exported so codec.js can use the same cutoffs when deciding file naming.
|
|
29
|
+
export const SQL_INLINE_MAX_CHARS = 500
|
|
30
|
+
export const PROMPT_INLINE_MAX_CHARS = 800
|
|
30
31
|
|
|
31
32
|
const shouldInlineSql = (q) => !q || q.length <= SQL_INLINE_MAX_CHARS
|
|
32
33
|
|
|
@@ -36,16 +37,23 @@ export const NODE_FIELD_TRANSFORMS = [
|
|
|
36
37
|
{
|
|
37
38
|
name: "credential",
|
|
38
39
|
pull(params, ctx) {
|
|
39
|
-
if (params.credential_id
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
40
|
+
if (!params.credential_id) return {}
|
|
41
|
+
const name = ctx.credIdToName[params.credential_id]
|
|
42
|
+
if (name) return { credential: name }
|
|
43
|
+
// Credential UUID doesn't resolve to a known name (deleted cred, or map
|
|
44
|
+
// not loaded). Preserve the UUID as-is so the reference isn't silently
|
|
45
|
+
// lost on the next push — the agent or user can see there's a stale
|
|
46
|
+
// reference to fix.
|
|
47
|
+
return { credential: params.credential_id }
|
|
43
48
|
},
|
|
44
49
|
push(params, ctx) {
|
|
45
|
-
if (params.credential
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
50
|
+
if (!params.credential) return {}
|
|
51
|
+
const id = ctx.credNameToId[params.credential]
|
|
52
|
+
if (id) return { credential_id: id }
|
|
53
|
+
// Name didn't resolve — could be a UUID the user wrote directly, or a
|
|
54
|
+
// stale reference. Pass through; the engine validates on execute and
|
|
55
|
+
// returns a clean error.
|
|
56
|
+
return { credential_id: params.credential }
|
|
49
57
|
},
|
|
50
58
|
pullConsumes: ["credential_id"],
|
|
51
59
|
pushConsumes: ["credential"],
|
package/src/tools/sync.js
CHANGED
|
@@ -92,22 +92,33 @@ export async function syncFromRemote({ project_id, targetDirectory, overwrite =
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
// Seed the deploy
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// after a sync
|
|
95
|
+
// Seed the last-deploy hash so the next deploy can skip if nothing
|
|
96
|
+
// changed. We compute the same project-wide hash deploy.js produces
|
|
97
|
+
// (sorted path+file-hash pairs). If the user runs `manage_frontend(deploy)`
|
|
98
|
+
// right after a sync and hasn't edited anything, it returns "no_changes"
|
|
99
|
+
// instantly without re-uploading 30 MB over the wire.
|
|
99
100
|
try {
|
|
100
101
|
const dypaiBase = existsSync(join(targetDirectory, "dypai"))
|
|
101
102
|
? join(targetDirectory, "dypai", ".dypai")
|
|
102
103
|
: join(targetDirectory, ".dypai")
|
|
103
104
|
mkdirSync(dypaiBase, { recursive: true })
|
|
105
|
+
|
|
106
|
+
// Same algorithm as deploy.js:computeProjectHash — sort by path, join
|
|
107
|
+
// path + hash with NUL, SHA-256 the concatenation. Keep in sync.
|
|
108
|
+
const entries = Object.entries(hashMap).sort(([a], [b]) => a.localeCompare(b))
|
|
109
|
+
const h = createHash("sha256")
|
|
110
|
+
for (const [path, fileHash] of entries) {
|
|
111
|
+
h.update(path); h.update("\0"); h.update(fileHash); h.update("\0")
|
|
112
|
+
}
|
|
113
|
+
const projectHash = h.digest("hex")
|
|
114
|
+
|
|
104
115
|
writeFileSync(
|
|
105
|
-
join(dypaiBase, "deploy
|
|
106
|
-
JSON.stringify(
|
|
116
|
+
join(dypaiBase, "last-deploy.json"),
|
|
117
|
+
JSON.stringify({ hash: projectHash, at: new Date().toISOString() }, null, 2) + "\n",
|
|
107
118
|
)
|
|
108
119
|
} catch (e) {
|
|
109
120
|
// Non-fatal: the first deploy will just be a full deploy.
|
|
110
|
-
failures.push({ path: ".dypai/deploy
|
|
121
|
+
failures.push({ path: ".dypai/last-deploy.json", reason: `Hash seed failed: ${e.message}` })
|
|
111
122
|
}
|
|
112
123
|
|
|
113
124
|
// Structured next_steps so the agent can act without re-prompting the LLM.
|