@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.
@@ -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
- "Manage the project's frontend: download the source code to disk AND the deploy lifecycle.\n\n" +
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. Returns immediately with build_status=\"queued\" poll with `build_status` until \"success\" or \"failure\".\n" +
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 }
@@ -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
- // Production engine URL: https://<project-id>.dypai.app (NOT .dypai.dev that's the dev tier).
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.app"
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 }
@@ -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
- // Conflicts and missing creds deserve surfacing; no-op diffs don't
88
- hint: hasConflicts
89
- ? "Remote changed since your last pull — dypai_pull first, or dypai_push will be blocked."
90
- : (plan.warnings || []).some(w => w.type === "missing_credential")
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
  }
@@ -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
- } : { id: resolvedProjectId || "(from token)" },
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
- next_steps: (endpoints || []).length === 0
777
- ? ["Empty project. Create tables via execute_sql, then write dypai/endpoints/<name>.yaml and dypai_push."]
778
- : ["Read dypai/schema.sql before writing queries.", "Edit YAML in dypai/endpoints/, then dypai_diff → dypai_push."],
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
- : endpoints.length === 0
795
- ? "Empty project. Create tables with execute_sql, then write endpoints/<name>.yaml and dypai_push."
796
- : undefined,
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
  }