@dypai-ai/mcp 1.4.3 → 1.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api.js +14 -2
- package/src/auto-update.js +44 -1
- package/src/index.js +260 -19
- package/src/tools/deploy.js +49 -1
- package/src/tools/frontend.js +59 -6
- package/src/tools/scaffold.js +6 -2
- package/src/tools/search-logs-offload.js +151 -0
- package/src/tools/sync/diff.js +88 -7
- package/src/tools/sync/pull.js +75 -8
- package/src/tools/sync/push.js +129 -96
- package/src/tools/sync/test-endpoint.js +217 -73
- package/src/tools/sync/validate.js +415 -48
- package/src/tools/sync.js +85 -13
- package/src/tools/status.js +0 -94
package/src/tools/frontend.js
CHANGED
|
@@ -14,11 +14,13 @@
|
|
|
14
14
|
import { api } from "../api.js"
|
|
15
15
|
import { deployFromSource } from "./deploy.js"
|
|
16
16
|
import { syncFromRemote } from "./sync.js"
|
|
17
|
+
import { proxyToolCall } from "./proxy.js"
|
|
17
18
|
|
|
18
19
|
export const manageFrontendTool = {
|
|
19
20
|
name: "manage_frontend",
|
|
20
21
|
description:
|
|
21
|
-
"
|
|
22
|
+
"FRONTEND ONLY — manages the project's static frontend (HTML/CSS/JS bundle). For BACKEND endpoints/workflows use dypai_push + manage_drafts; never use this tool for backend work.\n\n" +
|
|
23
|
+
"Two phases: download source to local disk (`sync`), then ship changes back (`deploy`).\n\n" +
|
|
22
24
|
"Use `sync` FIRST whenever you start working on a project whose frontend code isn't already on this machine — " +
|
|
23
25
|
"without it you have no React/Vite source to read or edit. Call `deploy` to ship your changes.\n\n" +
|
|
24
26
|
"Operations:\n" +
|
|
@@ -28,19 +30,25 @@ export const manageFrontendTool = {
|
|
|
28
30
|
"Writes only; does NOT delete local files that were removed upstream — you may have stale files after sync (call them out to the user). " +
|
|
29
31
|
"By default refuses to overwrite a directory that already has a package.json — pass overwrite:true to allow it (local-only files like .env, node_modules, .vscode are always preserved). " +
|
|
30
32
|
"AFTER SYNC: .env is gitignored so it's NOT included in the download. If the target directory has no .env, the response sets `env_file_missing: true` and adds a `next_steps` line with the exact VITE_DYPAI_URL / NEXT_PUBLIC_DYPAI_URL value to write. Follow it — without .env the SDK can't reach the engine.\n" +
|
|
31
|
-
" - deploy: Upload source files from a local directory and queue a build.
|
|
33
|
+
" - deploy: Upload source files from a local directory and queue a build. **DESTRUCTIVE: replaces the LIVE site immediately, no draft stage, no rollback button.** " +
|
|
34
|
+
"Requires `confirm: true` — without it the tool returns a confirmation_required hint instead of deploying. " +
|
|
35
|
+
"If backend drafts are pending, the hint includes a warning to publish backend FIRST (otherwise the new frontend may call endpoints that don't exist yet). " +
|
|
36
|
+
"Returns immediately with build_status=\"queued\" — poll with `build_status` until \"success\" or \"failure\". " +
|
|
37
|
+
"The response includes `build_quota` with remaining monthly build minutes. If minutes_remaining is low, tell the user. If 0, DO NOT retry — suggest upgrading the plan.\n" +
|
|
32
38
|
" - status: Current live deploy info (URL, last deploy time, size).\n" +
|
|
33
39
|
" - build_status: Progress of the current/latest build (queued/building/success/failure + stage + %).\n" +
|
|
40
|
+
" - usage: Full frontend usage snapshot including build_quota (minutes used/limit/remaining, deploy counts, resets_at). Call BEFORE deploy if unsure how much quota is left.\n" +
|
|
34
41
|
" - list_deployments: Recent deploy history (status, commit, duration, URL).\n" +
|
|
35
42
|
" - logs: Build logs for a specific deployment (needs deployment_id from list_deployments).\n\n" +
|
|
36
|
-
"Related: `dypai_pull` brings BACKEND state (YAML endpoints, SQL, prompts). The two are independent — run both when starting fresh on a full-stack project
|
|
43
|
+
"Related: `dypai_pull` brings BACKEND state (YAML endpoints, SQL, prompts). The two are independent — run both when starting fresh on a full-stack project.\n\n" +
|
|
44
|
+
"Order rule when both backend AND frontend changed: 1) dypai_push (saves backend as draft) → 2) manage_drafts(publish, confirm:true) → 3) manage_frontend(deploy, confirm:true). Inverting steps 2 and 3 may serve a frontend that calls non-existent endpoints.",
|
|
37
45
|
|
|
38
46
|
inputSchema: {
|
|
39
47
|
type: "object",
|
|
40
48
|
properties: {
|
|
41
49
|
operation: {
|
|
42
50
|
type: "string",
|
|
43
|
-
enum: ["deploy", "sync", "status", "build_status", "list_deployments", "logs"],
|
|
51
|
+
enum: ["deploy", "sync", "status", "build_status", "usage", "list_deployments", "logs"],
|
|
44
52
|
description: "Which action to run.",
|
|
45
53
|
},
|
|
46
54
|
project_id: {
|
|
@@ -65,6 +73,11 @@ export const manageFrontendTool = {
|
|
|
65
73
|
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
74
|
default: false,
|
|
67
75
|
},
|
|
76
|
+
confirm: {
|
|
77
|
+
type: "boolean",
|
|
78
|
+
description: "Required `true` for `deploy`. Without it the tool returns a confirmation_required hint (with a ready-to-call next_call) instead of replacing the live site. The agent MUST get explicit user approval before passing confirm:true.",
|
|
79
|
+
default: false,
|
|
80
|
+
},
|
|
68
81
|
deployment_id: {
|
|
69
82
|
type: "string",
|
|
70
83
|
description: "logs only. Deployment UUID obtained from list_deployments.",
|
|
@@ -77,7 +90,7 @@ export const manageFrontendTool = {
|
|
|
77
90
|
required: ["operation"],
|
|
78
91
|
},
|
|
79
92
|
|
|
80
|
-
async execute({ operation, project_id, sourceDirectory, targetDirectory, overwrite, force, deployment_id, limit } = {}) {
|
|
93
|
+
async execute({ operation, project_id, sourceDirectory, targetDirectory, overwrite, force, confirm, deployment_id, limit } = {}) {
|
|
81
94
|
if (!operation) {
|
|
82
95
|
return { success: false, error: "operation is required (deploy | sync | status | build_status | list_deployments | logs)." }
|
|
83
96
|
}
|
|
@@ -91,6 +104,43 @@ export const manageFrontendTool = {
|
|
|
91
104
|
if (!sourceDirectory) {
|
|
92
105
|
return { success: false, error: "operation 'deploy' requires 'sourceDirectory' (absolute path to your frontend project root)." }
|
|
93
106
|
}
|
|
107
|
+
// Defense-in-depth gate: deploy replaces the live site immediately
|
|
108
|
+
// with no rollback. Without explicit confirm we return a structured
|
|
109
|
+
// hint (with ready-to-execute next_call). We also surface any
|
|
110
|
+
// pending backend drafts as warnings — the agent should ALWAYS
|
|
111
|
+
// publish backend drafts before deploying frontend, otherwise the
|
|
112
|
+
// new frontend may call endpoints that don't exist yet on live.
|
|
113
|
+
if (confirm !== true) {
|
|
114
|
+
const warnings = []
|
|
115
|
+
try {
|
|
116
|
+
const draftsResult = await proxyToolCall("manage_drafts", { project_id, operation: "list" })
|
|
117
|
+
const draftsTotal = draftsResult?.total || 0
|
|
118
|
+
if (draftsTotal > 0) {
|
|
119
|
+
warnings.push(
|
|
120
|
+
`${draftsTotal} backend draft(s) pending. Publish them BEFORE deploying the frontend with manage_drafts(operation:'publish', confirm:true) — otherwise the new frontend may call endpoints that don't exist on live yet.`,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// Soft-fail — drafts check is advisory, not gating.
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
confirmation_required: true,
|
|
128
|
+
summary: `About to replace the LIVE frontend at this project's public URL with the contents of '${sourceDirectory}'. This is IMMEDIATE and there is NO automatic rollback.`,
|
|
129
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
130
|
+
next_call: {
|
|
131
|
+
tool: "manage_frontend",
|
|
132
|
+
operation: "deploy",
|
|
133
|
+
project_id,
|
|
134
|
+
sourceDirectory,
|
|
135
|
+
...(force ? { force: true } : {}),
|
|
136
|
+
confirm: true,
|
|
137
|
+
},
|
|
138
|
+
hint:
|
|
139
|
+
"Summarize the change to the user (what visual/functional changes are about to go live, " +
|
|
140
|
+
"and any pending backend drafts) and wait for explicit user approval. Then re-call this " +
|
|
141
|
+
"tool with confirm:true.",
|
|
142
|
+
}
|
|
143
|
+
}
|
|
94
144
|
return await deployFromSource({ sourceDirectory, project_id, force: !!force })
|
|
95
145
|
|
|
96
146
|
case "sync":
|
|
@@ -105,6 +155,9 @@ export const manageFrontendTool = {
|
|
|
105
155
|
case "build_status":
|
|
106
156
|
return await api.get(`/api/engine/${project_id}/frontend/build-status`)
|
|
107
157
|
|
|
158
|
+
case "usage":
|
|
159
|
+
return await api.get(`/api/engine/${project_id}/frontend/usage`)
|
|
160
|
+
|
|
108
161
|
case "list_deployments":
|
|
109
162
|
return await api.get(`/api/engine/${project_id}/frontend/deployments?limit=${limit || 10}`)
|
|
110
163
|
|
|
@@ -115,7 +168,7 @@ export const manageFrontendTool = {
|
|
|
115
168
|
return await api.get(`/api/engine/${project_id}/frontend/deployments/${deployment_id}/logs`)
|
|
116
169
|
|
|
117
170
|
default:
|
|
118
|
-
return { success: false, error: `Unknown operation '${operation}'. Use deploy | sync | status | build_status | list_deployments | logs.` }
|
|
171
|
+
return { success: false, error: `Unknown operation '${operation}'. Use deploy | sync | status | build_status | usage | list_deployments | logs.` }
|
|
119
172
|
}
|
|
120
173
|
} catch (e) {
|
|
121
174
|
return { success: false, error: e.message, operation }
|
package/src/tools/scaffold.js
CHANGED
|
@@ -47,9 +47,13 @@ Or use "blank" for an empty starter project.`,
|
|
|
47
47
|
return { error: `Directory already has a package.json. Pick an empty directory or a new name.` }
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
//
|
|
50
|
+
// Engine base URL — `<project_id>.dypai.dev` serves LIVE traffic; the
|
|
51
|
+
// local-development URL `dev-<project_id>.dypai.dev` (Layer 2.5 draft
|
|
52
|
+
// overlay) is written into `.env.local` separately by `sync` once the
|
|
53
|
+
// user has the frontend on disk. Scaffold writes the production URL
|
|
54
|
+
// because at scaffold-time we expect the user to deploy soon.
|
|
51
55
|
// Override with DYPAI_ENGINE_BASE for self-hosted / staging setups.
|
|
52
|
-
const engineBase = process.env.DYPAI_ENGINE_BASE || "dypai.
|
|
56
|
+
const engineBase = process.env.DYPAI_ENGINE_BASE || "dypai.dev"
|
|
53
57
|
const engineUrl = `https://${project_id}.${engineBase}`
|
|
54
58
|
|
|
55
59
|
// Try to download template from API
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* maybeOffloadSearchLogs — keep `search_logs` responses from blowing up the
|
|
3
|
+
* agent's context window.
|
|
4
|
+
*
|
|
5
|
+
* `search_logs` (especially with `include_trace=true`) can return hundreds of
|
|
6
|
+
* KB of JSON. Inlining that into the tool-result `text` field forces the model
|
|
7
|
+
* to load the whole payload into context, which is wasteful for the typical
|
|
8
|
+
* "show me what failed and let me drill into one of them" workflow.
|
|
9
|
+
*
|
|
10
|
+
* Strategy:
|
|
11
|
+
* - If the serialized response exceeds OFFLOAD_THRESHOLD_BYTES, write the
|
|
12
|
+
* full JSON to a temp file and return a compact summary that includes:
|
|
13
|
+
* · the absolute file path (so the agent can `Read` it on demand)
|
|
14
|
+
* · counts by level/type/environment
|
|
15
|
+
* · the first 5 items, trace-stripped
|
|
16
|
+
* - Otherwise, return the response unchanged.
|
|
17
|
+
*
|
|
18
|
+
* The offload threshold is intentionally loose (~60 KB). A normal search
|
|
19
|
+
* without `include_trace` is well under that and stays inline.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from "fs"
|
|
23
|
+
import os from "os"
|
|
24
|
+
import path from "path"
|
|
25
|
+
|
|
26
|
+
// ~60 KB. Claude/GPT swallow this comfortably, but full traces (200-500 KB)
|
|
27
|
+
// are forced to disk so the agent can consume them selectively.
|
|
28
|
+
const OFFLOAD_THRESHOLD_BYTES = 60 * 1024
|
|
29
|
+
|
|
30
|
+
// Keep the on-disk dir manageable: prune files older than this on every
|
|
31
|
+
// offload. Cheap because it only runs when we actually offload.
|
|
32
|
+
const FILE_TTL_MS = 24 * 60 * 60 * 1000 // 24h
|
|
33
|
+
|
|
34
|
+
const OFFLOAD_DIR = path.join(os.tmpdir(), "dypai-mcp-search-logs")
|
|
35
|
+
|
|
36
|
+
function ensureDir() {
|
|
37
|
+
try {
|
|
38
|
+
fs.mkdirSync(OFFLOAD_DIR, { recursive: true })
|
|
39
|
+
} catch {
|
|
40
|
+
/* race-safe; mkdirSync with recursive doesn't throw on existing dirs */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pruneOldFiles() {
|
|
45
|
+
try {
|
|
46
|
+
const cutoff = Date.now() - FILE_TTL_MS
|
|
47
|
+
for (const name of fs.readdirSync(OFFLOAD_DIR)) {
|
|
48
|
+
const full = path.join(OFFLOAD_DIR, name)
|
|
49
|
+
try {
|
|
50
|
+
const stat = fs.statSync(full)
|
|
51
|
+
if (stat.mtimeMs < cutoff) fs.unlinkSync(full)
|
|
52
|
+
} catch { /* ignore individual file errors */ }
|
|
53
|
+
}
|
|
54
|
+
} catch { /* ignore — best-effort housekeeping */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function lightItem(item) {
|
|
58
|
+
// Drop the heavy `trace` field from each item for the inline summary.
|
|
59
|
+
// Everything else stays so the agent can decide which one to drill into.
|
|
60
|
+
if (!item || typeof item !== "object") return item
|
|
61
|
+
const { trace, ...rest } = item
|
|
62
|
+
return trace ? { ...rest, trace_omitted: true } : rest
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function bucket(items, key) {
|
|
66
|
+
const out = {}
|
|
67
|
+
for (const it of items) {
|
|
68
|
+
const v = it && it[key] != null ? String(it[key]) : "null"
|
|
69
|
+
out[v] = (out[v] || 0) + 1
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Returns either the original `result` (small enough to inline) OR a compact
|
|
76
|
+
* summary object that points at a temp file holding the full JSON.
|
|
77
|
+
*
|
|
78
|
+
* Never throws — on any FS error it falls back to returning the original
|
|
79
|
+
* payload so the agent at least gets the data, even if it's big.
|
|
80
|
+
*/
|
|
81
|
+
export function maybeOffloadSearchLogs(result) {
|
|
82
|
+
if (!result || typeof result !== "object" || !Array.isArray(result.items)) {
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let serialized
|
|
87
|
+
try {
|
|
88
|
+
serialized = JSON.stringify(result, null, 2)
|
|
89
|
+
} catch {
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (Buffer.byteLength(serialized, "utf8") <= OFFLOAD_THRESHOLD_BYTES) {
|
|
94
|
+
return result
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
ensureDir()
|
|
99
|
+
pruneOldFiles()
|
|
100
|
+
|
|
101
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-")
|
|
102
|
+
const rand = Math.random().toString(36).slice(2, 8)
|
|
103
|
+
const filePath = path.join(OFFLOAD_DIR, `search-logs-${ts}-${rand}.json`)
|
|
104
|
+
fs.writeFileSync(filePath, serialized, "utf8")
|
|
105
|
+
|
|
106
|
+
const sizeBytes = Buffer.byteLength(serialized, "utf8")
|
|
107
|
+
const sizeKb = Math.round(sizeBytes / 1024)
|
|
108
|
+
const items = result.items
|
|
109
|
+
const firstFive = items.slice(0, 5).map(lightItem)
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
offloaded_to_file: true,
|
|
113
|
+
file_path: filePath,
|
|
114
|
+
size_bytes: sizeBytes,
|
|
115
|
+
guidance: (
|
|
116
|
+
`Response was too large to inline (${sizeKb} KB > 60 KB threshold). ` +
|
|
117
|
+
`Full JSON written to disk — open it with the Read tool when you want ` +
|
|
118
|
+
`to inspect a specific item or its trace:\n Read("${filePath}")\n\n` +
|
|
119
|
+
`The summary below covers the whole result. Only read the file if you ` +
|
|
120
|
+
`need fields beyond the first 5 items or any 'trace' contents.`
|
|
121
|
+
),
|
|
122
|
+
summary: {
|
|
123
|
+
total_returned: items.length,
|
|
124
|
+
by_level: bucket(items, "level"),
|
|
125
|
+
by_type: bucket(items, "type"),
|
|
126
|
+
by_environment: bucket(items, "environment"),
|
|
127
|
+
first_5: firstFive,
|
|
128
|
+
},
|
|
129
|
+
filters: {
|
|
130
|
+
project_id: result.project_id,
|
|
131
|
+
since: result.since,
|
|
132
|
+
level: result.level,
|
|
133
|
+
environment: result.environment,
|
|
134
|
+
endpoint: result.endpoint,
|
|
135
|
+
query: result.query,
|
|
136
|
+
include_trace: result.include_trace,
|
|
137
|
+
},
|
|
138
|
+
// Mirror the upstream guidance so the agent doesn't lose it.
|
|
139
|
+
upstream_guidance: result.guidance,
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
// Disk full / permissions / whatever — just return the original. The
|
|
143
|
+
// agent's context will take a hit but the data still gets through.
|
|
144
|
+
return {
|
|
145
|
+
...result,
|
|
146
|
+
offload_warning: `Could not write large payload to disk: ${err.message}`,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const _internals = { OFFLOAD_THRESHOLD_BYTES, OFFLOAD_DIR }
|
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
|
}
|