@dypai-ai/mcp 1.7.1 → 1.7.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/README.md +150 -0
- package/package.json +1 -1
- package/src/generated/serverInstructions.js +3 -3
- package/src/index.js +10 -15
- package/src/searchDocsFilter.js +19 -8
- package/src/toolProfiles.js +11 -8
- package/src/tools/project-deploy-production.js +7 -8
- package/src/tools/project-push.js +17 -3
- package/src/tools/sync/endpoint-names.js +29 -0
- package/src/tools/sync/generate-types.js +236 -48
- package/src/tools/sync/planner.js +73 -19
- package/src/tools/sync/pull.js +49 -6
- package/src/tools/sync/push.js +237 -32
package/src/toolProfiles.js
CHANGED
|
@@ -8,6 +8,7 @@ export const DEFAULT_MCP_PROFILE = "local";
|
|
|
8
8
|
|
|
9
9
|
const STUDIO_WORKER_TOOLS = [
|
|
10
10
|
"bulk_upsert",
|
|
11
|
+
"dypai_generate_types",
|
|
11
12
|
"dypai_test_endpoint",
|
|
12
13
|
"dypai_validate",
|
|
13
14
|
"execute_sql",
|
|
@@ -247,15 +248,17 @@ export function assertStudioInstructionsSanitized(instructions, { allowDebugTool
|
|
|
247
248
|
throw new Error("Studio instructions must describe orchestrator sync/build/validate");
|
|
248
249
|
}
|
|
249
250
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
)
|
|
254
|
-
|
|
255
|
-
|
|
251
|
+
if (!/\bdypai_generate_types\b/.test(instructions)) {
|
|
252
|
+
throw new Error("Studio instructions must mention dypai_generate_types");
|
|
253
|
+
}
|
|
254
|
+
if (!/\bendpoints\.gen\.ts\b/.test(instructions)) {
|
|
255
|
+
throw new Error("Studio instructions must mention endpoints.gen.ts");
|
|
256
|
+
}
|
|
257
|
+
if (!/dypai_validate[\s\S]{0,240}dypai_generate_types|dypai_generate_types[\s\S]{0,240}dypai_validate/i.test(instructions)) {
|
|
258
|
+
throw new Error("Studio instructions must describe validate then generate types");
|
|
256
259
|
}
|
|
257
|
-
if (
|
|
258
|
-
throw new Error("Studio instructions must
|
|
260
|
+
if (!/read[^\n]*endpoints\.gen\.ts|read `dypai\/types\//i.test(instructions)) {
|
|
261
|
+
throw new Error("Studio instructions must tell the agent to read generated endpoint types");
|
|
259
262
|
}
|
|
260
263
|
|
|
261
264
|
}
|
|
@@ -4,7 +4,6 @@ import YAML from "yaml"
|
|
|
4
4
|
import { deployFromSource } from "./deploy.js"
|
|
5
5
|
import { proxyToolCall } from "./proxy.js"
|
|
6
6
|
import { resolveOutDir } from "./sync/path-resolver.js"
|
|
7
|
-
import { refreshLocalStateSnapshot } from "./sync/planner.js"
|
|
8
7
|
|
|
9
8
|
function resolveWorkspace({ workspace_root, sourceDirectory, root_dir } = {}) {
|
|
10
9
|
if (workspace_root) {
|
|
@@ -173,13 +172,13 @@ export const dypaiDeployProductionTool = {
|
|
|
173
172
|
live_changed: false,
|
|
174
173
|
}
|
|
175
174
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
175
|
+
backend = {
|
|
176
|
+
...backend,
|
|
177
|
+
state_baseline_refresh: {
|
|
178
|
+
skipped: true,
|
|
179
|
+
reason: "publish_does_not_prove_local_source_ownership",
|
|
180
|
+
hint: "Run dypai_pull before editing a just-published endpoint again. Automatic project-wide refresh is disabled to preserve multi-user conflict detection.",
|
|
181
|
+
},
|
|
183
182
|
}
|
|
184
183
|
}
|
|
185
184
|
}
|
|
@@ -65,8 +65,9 @@ function readProjectIdFromConfig(rootDir) {
|
|
|
65
65
|
export const dypaiPushProjectTool = {
|
|
66
66
|
name: "dypai_push",
|
|
67
67
|
description:
|
|
68
|
-
"SAVE CHANGES — push
|
|
68
|
+
"SAVE CHANGES — push local DYPAI project changes to Studio/DYPAI without publishing production. " +
|
|
69
69
|
"Backend files under dypai/ are staged as drafts. Frontend files under src/, public/, package.json, and versionable dypai/ source are saved to the Studio branch. " +
|
|
70
|
+
"Use only_endpoints for an isolated backend push; endpoint-scoped pushes intentionally skip frontend saves and unrelated backend resources. " +
|
|
70
71
|
"Production is never changed by this tool. Use dypai_deploy_production(confirm:true) after explicit user approval to publish live.",
|
|
71
72
|
inputSchema: {
|
|
72
73
|
type: "object",
|
|
@@ -113,6 +114,13 @@ export const dypaiPushProjectTool = {
|
|
|
113
114
|
description: "If false, only pushes backend drafts. Default: true.",
|
|
114
115
|
default: true,
|
|
115
116
|
},
|
|
117
|
+
only_endpoints: {
|
|
118
|
+
type: "array",
|
|
119
|
+
minItems: 1,
|
|
120
|
+
uniqueItems: true,
|
|
121
|
+
items: { type: "string" },
|
|
122
|
+
description: "Explicit backend endpoint allowlist. When set, unrelated backend resources and the frontend save are skipped.",
|
|
123
|
+
},
|
|
116
124
|
},
|
|
117
125
|
required: [],
|
|
118
126
|
},
|
|
@@ -127,6 +135,7 @@ export const dypaiPushProjectTool = {
|
|
|
127
135
|
force = false,
|
|
128
136
|
skip_validation = false,
|
|
129
137
|
save_frontend = true,
|
|
138
|
+
only_endpoints = null,
|
|
130
139
|
} = {}) {
|
|
131
140
|
const { workspaceRoot, rootDir, source } = resolveWorkspace({ workspace_root, sourceDirectory, root_dir })
|
|
132
141
|
const targetProjectId = project_id || readProjectIdFromConfig(rootDir)
|
|
@@ -165,6 +174,7 @@ export const dypaiPushProjectTool = {
|
|
|
165
174
|
dry_run,
|
|
166
175
|
force,
|
|
167
176
|
skip_validation,
|
|
177
|
+
only_endpoints,
|
|
168
178
|
}))
|
|
169
179
|
}
|
|
170
180
|
|
|
@@ -187,6 +197,8 @@ export const dypaiPushProjectTool = {
|
|
|
187
197
|
skipped: true,
|
|
188
198
|
reason: dry_run
|
|
189
199
|
? "dry_run"
|
|
200
|
+
: only_endpoints
|
|
201
|
+
? "endpoint_scoped_push"
|
|
190
202
|
: !save_frontend
|
|
191
203
|
? "save_frontend_false"
|
|
192
204
|
: hasFrontend
|
|
@@ -194,7 +206,7 @@ export const dypaiPushProjectTool = {
|
|
|
194
206
|
: "no_package_json",
|
|
195
207
|
}
|
|
196
208
|
|
|
197
|
-
if (!dry_run && save_frontend && hasFrontend) {
|
|
209
|
+
if (!dry_run && !only_endpoints && save_frontend && hasFrontend) {
|
|
198
210
|
frontend = await deployFromSource({
|
|
199
211
|
sourceDirectory: workspaceRoot,
|
|
200
212
|
project_id: targetProjectId,
|
|
@@ -214,7 +226,9 @@ export const dypaiPushProjectTool = {
|
|
|
214
226
|
frontend_saved: hasFrontend && frontendOk && frontend.skipped !== true,
|
|
215
227
|
live_changed: false,
|
|
216
228
|
next_step: frontendOk
|
|
217
|
-
?
|
|
229
|
+
? only_endpoints
|
|
230
|
+
? `Selected backend endpoint drafts are saved. Test them, then publish only the same allowlist with manage_drafts(operation:'publish', resource_names:${JSON.stringify(only_endpoints)}, confirm:true) after explicit approval.`
|
|
231
|
+
: "Changes are saved to Studio/DYPAI. Test the preview. To publish live, use dypai_deploy_production(confirm:true) after explicit user approval."
|
|
218
232
|
: "Frontend save failed. Fix the frontend/source issue and run dypai_push again.",
|
|
219
233
|
}
|
|
220
234
|
},
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize endpoint names only for migration diagnostics.
|
|
3
|
+
*
|
|
4
|
+
* Underscores and hyphens remain distinct endpoint identities at runtime.
|
|
5
|
+
* This normalized value must never be used for reads or mutations; it only
|
|
6
|
+
* surfaces legacy pairs such as `create_task` + `create-task`.
|
|
7
|
+
*/
|
|
8
|
+
export function migrationCanonicalEndpointName(name) {
|
|
9
|
+
return typeof name === "string" ? name.replace(/_/g, "-") : ""
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function findEndpointNameCollisions(endpointsOrNames) {
|
|
13
|
+
const buckets = new Map()
|
|
14
|
+
for (const item of endpointsOrNames || []) {
|
|
15
|
+
const name = typeof item === "string" ? item : item?.name
|
|
16
|
+
if (!name) continue
|
|
17
|
+
const canonicalName = migrationCanonicalEndpointName(name)
|
|
18
|
+
if (!buckets.has(canonicalName)) buckets.set(canonicalName, new Set())
|
|
19
|
+
buckets.get(canonicalName).add(name)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return [...buckets.entries()]
|
|
23
|
+
.map(([canonical_name, names]) => ({
|
|
24
|
+
canonical_name,
|
|
25
|
+
endpoints: [...names].sort(),
|
|
26
|
+
}))
|
|
27
|
+
.filter((item) => item.endpoints.length > 1)
|
|
28
|
+
.sort((a, b) => a.canonical_name.localeCompare(b.canonical_name))
|
|
29
|
+
}
|
|
@@ -2,13 +2,94 @@
|
|
|
2
2
|
* Regenerate dypai/types/endpoints.gen.ts from effective Flow/YAML contracts.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { createHash } from "node:crypto"
|
|
6
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs"
|
|
5
7
|
import { mkdir, writeFile } from "fs/promises"
|
|
6
|
-
import { dirname, join, resolve as resolvePath } from "path"
|
|
8
|
+
import { dirname, isAbsolute, join, resolve as resolvePath, sep } from "path"
|
|
7
9
|
import { runEffectiveWorkflows } from "../../lib/effective-workflows-runner.js"
|
|
10
|
+
import { resolveAndGuard } from "./path-resolver.js"
|
|
11
|
+
import { isStudioProfile, resolveMcpProfile } from "../../toolProfiles.js"
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
function assertUnderRoot(root, target) {
|
|
14
|
+
const resolvedRoot = resolvePath(root)
|
|
15
|
+
const resolvedTarget = resolvePath(target)
|
|
16
|
+
if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(resolvedRoot + sep)) {
|
|
17
|
+
throw new Error("Path escapes workspace root")
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertNoSymlinkPath(root, target) {
|
|
22
|
+
const resolvedRoot = resolvePath(root)
|
|
23
|
+
const resolvedTarget = resolvePath(target)
|
|
24
|
+
assertUnderRoot(resolvedRoot, resolvedTarget)
|
|
25
|
+
if (!existsSync(resolvedRoot)) {
|
|
26
|
+
throw new Error("Workspace root does not exist")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const realRoot = realpathSync(resolvedRoot)
|
|
30
|
+
let cursor = resolvedTarget
|
|
31
|
+
while (true) {
|
|
32
|
+
if (existsSync(cursor)) {
|
|
33
|
+
if (lstatSync(cursor).isSymbolicLink()) {
|
|
34
|
+
throw new Error(`Symlink paths are not allowed for generated artifacts: ${cursor}`)
|
|
35
|
+
}
|
|
36
|
+
assertUnderRoot(realRoot, realpathSync(cursor))
|
|
37
|
+
}
|
|
38
|
+
if (cursor === resolvedRoot) break
|
|
39
|
+
const parent = dirname(cursor)
|
|
40
|
+
if (
|
|
41
|
+
parent === cursor
|
|
42
|
+
|| (parent !== resolvedRoot && !parent.startsWith(resolvedRoot + sep))
|
|
43
|
+
) {
|
|
44
|
+
throw new Error("Path escapes workspace root")
|
|
45
|
+
}
|
|
46
|
+
cursor = parent
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hashSnapshotFiles(files) {
|
|
51
|
+
const parts = (files || [])
|
|
52
|
+
.map((file) => `${file.path}\0${file.content}`)
|
|
53
|
+
.sort((a, b) => a.localeCompare(b))
|
|
54
|
+
return createHash("sha256").update(parts.join("\n")).digest("hex").slice(0, 16)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function writeGeneratedArtifacts(resolvedRoot, payload) {
|
|
58
|
+
const outDir = join(resolvedRoot, "types")
|
|
59
|
+
assertNoSymlinkPath(resolvedRoot, outDir)
|
|
60
|
+
await mkdir(outDir, { recursive: true })
|
|
61
|
+
const typesPath = join(outDir, "endpoints.gen.ts")
|
|
62
|
+
const contractPath = join(outDir, "endpoints.contract.json")
|
|
63
|
+
assertNoSymlinkPath(resolvedRoot, typesPath)
|
|
64
|
+
assertNoSymlinkPath(resolvedRoot, contractPath)
|
|
65
|
+
|
|
66
|
+
const writes = []
|
|
67
|
+
if (payload.typesContent) {
|
|
68
|
+
writes.push({ path: typesPath, content: payload.typesContent })
|
|
69
|
+
}
|
|
70
|
+
if (payload.contractJsonContent) {
|
|
71
|
+
writes.push({ path: contractPath, content: payload.contractJsonContent })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const writtenPaths = []
|
|
75
|
+
const unchangedPaths = []
|
|
76
|
+
|
|
77
|
+
for (const item of writes) {
|
|
78
|
+
const current = existsSync(item.path) ? readFileSync(item.path, "utf8") : null
|
|
79
|
+
if (current === item.content) {
|
|
80
|
+
unchangedPaths.push(item.path)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
await writeFile(item.path, item.content, "utf8")
|
|
84
|
+
writtenPaths.push(item.path)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { typesPath, contractPath, writtenPaths, unchangedPaths }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runGenerateEndpointTypes(rootDir, projectId = null, options = {}) {
|
|
10
91
|
const resolvedRoot = resolvePath(rootDir)
|
|
11
|
-
const result = await runEffectiveWorkflows(resolvedRoot, projectId, ["generate-types"])
|
|
92
|
+
const result = await runEffectiveWorkflows(resolvedRoot, projectId, ["generate-types"], options.deps)
|
|
12
93
|
|
|
13
94
|
if (result.unavailable) {
|
|
14
95
|
return {
|
|
@@ -35,36 +116,150 @@ export async function runGenerateEndpointTypes(rootDir, projectId = null) {
|
|
|
35
116
|
}
|
|
36
117
|
}
|
|
37
118
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
119
|
+
const errorCount = payload.errorCount ?? 0
|
|
120
|
+
const warningCount = payload.warningCount ?? 0
|
|
121
|
+
const diagnosticCount = payload.diagnosticCount ?? 0
|
|
122
|
+
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics.slice(0, 10) : []
|
|
123
|
+
const endpoints = payload.endpoints || []
|
|
124
|
+
const endpointCount = payload.endpointCount ?? 0
|
|
125
|
+
const snapshotHash = Array.isArray(payload.files)
|
|
126
|
+
? hashSnapshotFiles(payload.files)
|
|
127
|
+
: options.snapshotHash ?? null
|
|
128
|
+
|
|
129
|
+
if (
|
|
130
|
+
typeof payload.typesContent !== "string"
|
|
131
|
+
|| !payload.typesContent.trim()
|
|
132
|
+
|| typeof payload.contractJsonContent !== "string"
|
|
133
|
+
|| !payload.contractJsonContent.trim()
|
|
134
|
+
) {
|
|
45
135
|
return {
|
|
46
|
-
ok:
|
|
47
|
-
|
|
48
|
-
typesPath,
|
|
49
|
-
contractPath,
|
|
50
|
-
endpoints: payload.endpoints || [],
|
|
51
|
-
diagnosticCount: payload.diagnosticCount ?? 0,
|
|
52
|
-
errorCount: payload.errorCount ?? 0,
|
|
53
|
-
warningCount: payload.warningCount ?? 0,
|
|
54
|
-
diagnostics: Array.isArray(payload.diagnostics) ? payload.diagnostics.slice(0, 10) : [],
|
|
136
|
+
ok: false,
|
|
137
|
+
error: "generate-types returned an incomplete artifact payload",
|
|
55
138
|
}
|
|
56
139
|
}
|
|
57
140
|
|
|
141
|
+
try {
|
|
142
|
+
const contract = JSON.parse(payload.contractJsonContent)
|
|
143
|
+
if (!contract || typeof contract !== "object" || Array.isArray(contract)) {
|
|
144
|
+
throw new Error("contract JSON must be an object")
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
error: `generate-types returned invalid contract JSON: ${error?.message || String(error)}`,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const { typesPath, contractPath, writtenPaths, unchangedPaths } = await writeGeneratedArtifacts(
|
|
154
|
+
resolvedRoot,
|
|
155
|
+
payload,
|
|
156
|
+
)
|
|
58
157
|
return {
|
|
59
158
|
ok: true,
|
|
60
|
-
endpointCount
|
|
61
|
-
typesPath
|
|
62
|
-
contractPath
|
|
63
|
-
endpoints
|
|
64
|
-
diagnosticCount
|
|
65
|
-
errorCount
|
|
66
|
-
warningCount
|
|
67
|
-
diagnostics
|
|
159
|
+
endpointCount,
|
|
160
|
+
typesPath,
|
|
161
|
+
contractPath,
|
|
162
|
+
endpoints,
|
|
163
|
+
diagnosticCount,
|
|
164
|
+
errorCount,
|
|
165
|
+
warningCount,
|
|
166
|
+
diagnostics,
|
|
167
|
+
contractReady: errorCount === 0,
|
|
168
|
+
writtenPaths,
|
|
169
|
+
unchangedPaths,
|
|
170
|
+
snapshotHash,
|
|
171
|
+
cached: result.cached === true,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function resolveGenerateTypesRoot(root_dir = "./dypai") {
|
|
176
|
+
const profile = resolveMcpProfile(process.env)
|
|
177
|
+
if (isStudioProfile(profile)) {
|
|
178
|
+
const workspaceRoot = process.env.DYPAI_WORKSPACE_ROOT
|
|
179
|
+
if (!workspaceRoot) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
error: {
|
|
183
|
+
success: false,
|
|
184
|
+
generated: false,
|
|
185
|
+
error: "Studio worker requires DYPAI_WORKSPACE_ROOT for endpoint type generation.",
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const resolvedWorkspace = resolvePath(workspaceRoot)
|
|
191
|
+
const resolvedDypai = resolvePath(resolvedWorkspace, "dypai")
|
|
192
|
+
const suppliedPath = isAbsolute(root_dir)
|
|
193
|
+
? resolvePath(root_dir)
|
|
194
|
+
: resolvePath(resolvedWorkspace, root_dir)
|
|
195
|
+
if (suppliedPath !== resolvedDypai) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
error: {
|
|
199
|
+
success: false,
|
|
200
|
+
generated: false,
|
|
201
|
+
error: "Studio endpoint type generation is restricted to the canonical dypai/ directory.",
|
|
202
|
+
resolved_to: suppliedPath,
|
|
203
|
+
required_root: resolvedDypai,
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
assertNoSymlinkPath(resolvedWorkspace, resolvedDypai)
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
error: {
|
|
214
|
+
success: false,
|
|
215
|
+
generated: false,
|
|
216
|
+
error: "Studio dypai/ must be a real directory inside the workspace (symlinks are not allowed).",
|
|
217
|
+
detail: error?.message || String(error),
|
|
218
|
+
resolved_to: resolvedDypai,
|
|
219
|
+
workspace_root: resolvedWorkspace,
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { ok: true, path: resolvedDypai, source: "env:DYPAI_WORKSPACE_ROOT" }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const guarded = resolveAndGuard(root_dir, { tool: "dypai_generate_types", arg_name: "root_dir" })
|
|
228
|
+
if (!guarded.ok) {
|
|
229
|
+
return guarded
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { ok: true, path: guarded.path, source: guarded.source }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function buildSuccessResponse(outcome) {
|
|
236
|
+
const generated = outcome.writtenPaths.length > 0
|
|
237
|
+
const refreshed = generated || outcome.unchangedPaths.length > 0
|
|
238
|
+
return {
|
|
239
|
+
success: true,
|
|
240
|
+
generated: refreshed,
|
|
241
|
+
files_written: outcome.writtenPaths.map((path) => path.replace(/^.*\/dypai\//, "dypai/")),
|
|
242
|
+
files_unchanged: outcome.unchangedPaths.map((path) => path.replace(/^.*\/dypai\//, "dypai/")),
|
|
243
|
+
contract_ready: outcome.contractReady,
|
|
244
|
+
endpoint_count: outcome.endpointCount,
|
|
245
|
+
types_path: "dypai/types/endpoints.gen.ts",
|
|
246
|
+
contract_path: "dypai/types/endpoints.contract.json",
|
|
247
|
+
endpoints: outcome.endpoints.slice(0, 50),
|
|
248
|
+
snapshot_hash: outcome.snapshotHash ?? undefined,
|
|
249
|
+
cached: outcome.cached === true,
|
|
250
|
+
diagnostics_summary: outcome.diagnosticCount
|
|
251
|
+
? {
|
|
252
|
+
total: outcome.diagnosticCount,
|
|
253
|
+
errors: outcome.errorCount,
|
|
254
|
+
warnings: outcome.warningCount,
|
|
255
|
+
sample: outcome.diagnostics,
|
|
256
|
+
}
|
|
257
|
+
: undefined,
|
|
258
|
+
hint: outcome.contractReady
|
|
259
|
+
? outcome.endpointCount
|
|
260
|
+
? `Types refreshed for ${outcome.endpointCount} endpoint(s). Read dypai/types/endpoints.gen.ts before wiring frontend.`
|
|
261
|
+
: "No effective endpoints found — types file is empty."
|
|
262
|
+
: "Types were written but backend validation reported errors. Fix backend issues, run dypai_validate, then call dypai_generate_types again before frontend work.",
|
|
68
263
|
}
|
|
69
264
|
}
|
|
70
265
|
|
|
@@ -73,7 +268,8 @@ export const dypaiGenerateTypesTool = {
|
|
|
73
268
|
description:
|
|
74
269
|
"Regenerate frontend endpoint types from the local dypai/ backend (Flow IR + legacy YAML). " +
|
|
75
270
|
"Writes dypai/types/endpoints.gen.ts and dypai/types/endpoints.contract.json. " +
|
|
76
|
-
"Call after editing dypai/flows/*.flow.ts or endpoint YAML, before frontend work. " +
|
|
271
|
+
"Call after editing dypai/flows/*.flow.ts or endpoint YAML and after dypai_validate, before frontend work. " +
|
|
272
|
+
"In Studio worker mode, call this after backend contract edits and before wiring src/. " +
|
|
77
273
|
"Also runs automatically during dypai_push after validation.",
|
|
78
274
|
inputSchema: {
|
|
79
275
|
type: "object",
|
|
@@ -91,9 +287,14 @@ export const dypaiGenerateTypesTool = {
|
|
|
91
287
|
},
|
|
92
288
|
|
|
93
289
|
async execute({ project_id, root_dir = "./dypai" } = {}) {
|
|
290
|
+
const resolved = resolveGenerateTypesRoot(root_dir)
|
|
291
|
+
if (!resolved.ok) {
|
|
292
|
+
return resolved.error
|
|
293
|
+
}
|
|
294
|
+
|
|
94
295
|
const { readLocalConfig } = await import("./planner.js")
|
|
95
296
|
const { getEnvBoundProjectId } = await import("../project-context.js")
|
|
96
|
-
const rootDir =
|
|
297
|
+
const rootDir = resolved.path
|
|
97
298
|
const config = await readLocalConfig(rootDir)
|
|
98
299
|
const projectId = project_id || getEnvBoundProjectId() || config?.project_id || null
|
|
99
300
|
const outcome = await runGenerateEndpointTypes(rootDir, projectId)
|
|
@@ -101,6 +302,7 @@ export const dypaiGenerateTypesTool = {
|
|
|
101
302
|
return {
|
|
102
303
|
success: false,
|
|
103
304
|
generated: false,
|
|
305
|
+
contract_ready: false,
|
|
104
306
|
reason: outcome.reason,
|
|
105
307
|
hint: outcome.warning,
|
|
106
308
|
}
|
|
@@ -109,32 +311,18 @@ export const dypaiGenerateTypesTool = {
|
|
|
109
311
|
return {
|
|
110
312
|
success: false,
|
|
111
313
|
generated: false,
|
|
314
|
+
contract_ready: false,
|
|
112
315
|
error: outcome.error,
|
|
113
316
|
stderr: outcome.stderr,
|
|
114
317
|
}
|
|
115
318
|
}
|
|
116
|
-
return
|
|
117
|
-
success: true,
|
|
118
|
-
generated: true,
|
|
119
|
-
endpoint_count: outcome.endpointCount,
|
|
120
|
-
types_path: "dypai/types/endpoints.gen.ts",
|
|
121
|
-
contract_path: "dypai/types/endpoints.contract.json",
|
|
122
|
-
endpoints: outcome.endpoints.slice(0, 50),
|
|
123
|
-
diagnostics_summary: outcome.diagnosticCount
|
|
124
|
-
? {
|
|
125
|
-
total: outcome.diagnosticCount,
|
|
126
|
-
errors: outcome.errorCount,
|
|
127
|
-
warnings: outcome.warningCount,
|
|
128
|
-
sample: outcome.diagnostics,
|
|
129
|
-
}
|
|
130
|
-
: undefined,
|
|
131
|
-
hint: outcome.endpointCount
|
|
132
|
-
? `Types refreshed for ${outcome.endpointCount} endpoint(s). Frontend can import from dypai/types/endpoints.gen.ts.${outcome.errorCount ? " Backend errors were found; run dypai_validate before trusting frontend call sites." : ""}`
|
|
133
|
-
: "No effective endpoints found — types file is empty.",
|
|
134
|
-
}
|
|
319
|
+
return buildSuccessResponse(outcome)
|
|
135
320
|
},
|
|
136
321
|
}
|
|
137
322
|
|
|
138
323
|
export const __testing = {
|
|
139
324
|
runGenerateEndpointTypes,
|
|
325
|
+
resolveGenerateTypesRoot,
|
|
326
|
+
buildSuccessResponse,
|
|
327
|
+
writeGeneratedArtifacts,
|
|
140
328
|
}
|
|
@@ -16,6 +16,7 @@ import { deserializeEndpoint, serializeEndpoint } from "./codec.js"
|
|
|
16
16
|
import {
|
|
17
17
|
runEffectiveWorkflows,
|
|
18
18
|
} from "../../lib/effective-workflows-runner.js"
|
|
19
|
+
import { findEndpointNameCollisions } from "./endpoint-names.js"
|
|
19
20
|
|
|
20
21
|
const ENDPOINT_NAME_RE = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/
|
|
21
22
|
|
|
@@ -71,27 +72,51 @@ export async function readLocalStateSnapshot(rootDir) {
|
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
export function mergeLocalStateSnapshot({ currentState, rows, endpointResults, projectId, now = new Date() }) {
|
|
76
|
+
const compatibleState = currentState?.project_id === projectId ? currentState : null
|
|
77
|
+
const endpoints = { ...(compatibleState?.endpoints || {}) }
|
|
78
|
+
const unresolvedEndpoints = { ...(compatibleState?.unresolved_endpoints || {}) }
|
|
79
|
+
const rowsByName = Object.fromEntries((rows || []).map((row) => [row.name, row]))
|
|
80
|
+
|
|
81
|
+
for (const result of endpointResults || []) {
|
|
82
|
+
if (result?.resource_type && result.resource_type !== "endpoint") continue
|
|
83
|
+
const name = result?.name
|
|
84
|
+
if (!name) continue
|
|
85
|
+
if (result.action === "delete") {
|
|
86
|
+
delete endpoints[name]
|
|
87
|
+
delete unresolvedEndpoints[name]
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
const row = rowsByName[name]
|
|
91
|
+
if (!row) continue
|
|
92
|
+
endpoints[name] = { id: row.id, updated_at: row.updated_at, revision: row.revision }
|
|
93
|
+
delete unresolvedEndpoints[name]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
pulled_at: compatibleState?.pulled_at || null,
|
|
98
|
+
refreshed_at: now.toISOString(),
|
|
99
|
+
project_id: projectId,
|
|
100
|
+
endpoints,
|
|
101
|
+
unresolved_endpoints: unresolvedEndpoints,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
74
105
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
106
|
+
* Merge only the endpoints that a publish response proves were promoted.
|
|
107
|
+
* A project-wide timestamp refresh is unsafe after a scoped push: unrelated
|
|
108
|
+
* local files may still be stale and must keep their previous/unresolved
|
|
109
|
+
* baseline so the next push can block correctly.
|
|
77
110
|
*/
|
|
78
|
-
export async function refreshLocalStateSnapshot({ projectId, rootDir }) {
|
|
79
|
-
if (!projectId || !rootDir) return null
|
|
111
|
+
export async function refreshLocalStateSnapshot({ projectId, rootDir, endpointResults = [] }) {
|
|
112
|
+
if (!projectId || !rootDir || !endpointResults.length) return null
|
|
80
113
|
const rows = await execSql(projectId, `
|
|
81
|
-
SELECT id, name, updated_at
|
|
114
|
+
SELECT id, name, updated_at, revision
|
|
82
115
|
FROM system.endpoints
|
|
83
116
|
ORDER BY name
|
|
84
117
|
`)
|
|
85
|
-
const
|
|
86
|
-
const state = {
|
|
87
|
-
pulled_at: new Date().toISOString(),
|
|
88
|
-
project_id: projectId,
|
|
89
|
-
endpoints: Object.fromEntries(
|
|
90
|
-
rows
|
|
91
|
-
.filter((row) => successfullyPulledIds.has(row.id))
|
|
92
|
-
.map((row) => [row.name, { id: row.id, updated_at: row.updated_at }]),
|
|
93
|
-
),
|
|
94
|
-
}
|
|
118
|
+
const currentState = await readLocalStateSnapshot(rootDir)
|
|
119
|
+
const state = mergeLocalStateSnapshot({ currentState, rows, endpointResults, projectId })
|
|
95
120
|
const statePath = join(rootDir, ".dypai", "state.json")
|
|
96
121
|
await mkdir(join(rootDir, ".dypai"), { recursive: true })
|
|
97
122
|
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8")
|
|
@@ -166,7 +191,7 @@ export async function fetchRemoteState(projectId) {
|
|
|
166
191
|
const endpoints = await execSql(projectId, `
|
|
167
192
|
SELECT id, name, method, description, workflow_code, input, output,
|
|
168
193
|
allowed_roles, is_tool, tool_description, group_id, is_active,
|
|
169
|
-
response_cardinality, updated_at
|
|
194
|
+
response_cardinality, updated_at, revision
|
|
170
195
|
FROM system.endpoints
|
|
171
196
|
ORDER BY name
|
|
172
197
|
`)
|
|
@@ -507,6 +532,7 @@ export async function readLocalEffectiveState(rootDir, projectId = null, deps =
|
|
|
507
532
|
byName[payload.name] = {
|
|
508
533
|
source: "flow",
|
|
509
534
|
file,
|
|
535
|
+
group: payload.group_name || null,
|
|
510
536
|
pushPayload: payload,
|
|
511
537
|
...(shadowedMeta ? { shadowed: shadowedMeta } : {}),
|
|
512
538
|
}
|
|
@@ -703,19 +729,36 @@ function findMissingCredentials(doc, credNameToId) {
|
|
|
703
729
|
* was pulled at time T and its remote updated_at is now > T, someone modified
|
|
704
730
|
* it out-of-band since the pull. Report these so the user can pull first.
|
|
705
731
|
*/
|
|
706
|
-
function detectConflicts(stateSnapshot, remoteByName) {
|
|
732
|
+
export function detectConflicts(stateSnapshot, remoteByName) {
|
|
707
733
|
if (!stateSnapshot?.endpoints) return []
|
|
708
734
|
const conflicts = []
|
|
735
|
+
for (const [name, unresolved] of Object.entries(stateSnapshot.unresolved_endpoints || {})) {
|
|
736
|
+
if (!remoteByName[name]) continue
|
|
737
|
+
conflicts.push({
|
|
738
|
+
endpoint: name,
|
|
739
|
+
snapshot_at: null,
|
|
740
|
+
remote_updated_at: remoteByName[name]?.updated_at || unresolved.remote_updated_at,
|
|
741
|
+
remote_revision: remoteByName[name]?.revision || unresolved.remote_revision,
|
|
742
|
+
reason: unresolved.reason || "local_source_not_reconciled",
|
|
743
|
+
local_file: unresolved.file,
|
|
744
|
+
})
|
|
745
|
+
}
|
|
709
746
|
for (const [name, snap] of Object.entries(stateSnapshot.endpoints)) {
|
|
747
|
+
if (stateSnapshot.unresolved_endpoints?.[name]) continue
|
|
710
748
|
const remote = remoteByName[name]
|
|
711
749
|
if (!remote) continue
|
|
712
750
|
const snapT = snap.updated_at ? new Date(snap.updated_at).getTime() : 0
|
|
713
751
|
const remoteT = remote.updated_at ? new Date(remote.updated_at).getTime() : 0
|
|
714
|
-
|
|
752
|
+
const snapRevision = Number(snap.revision || 0)
|
|
753
|
+
const remoteRevision = Number(remote.revision || 0)
|
|
754
|
+
const revisionChanged = snapRevision > 0 && remoteRevision > 0 && remoteRevision !== snapRevision
|
|
755
|
+
if (revisionChanged || remoteT > snapT + 1000) { // timestamp fallback for old snapshots
|
|
715
756
|
conflicts.push({
|
|
716
757
|
endpoint: name,
|
|
717
758
|
snapshot_at: snap.updated_at,
|
|
718
759
|
remote_updated_at: remote.updated_at,
|
|
760
|
+
snapshot_revision: snapRevision || undefined,
|
|
761
|
+
remote_revision: remoteRevision || undefined,
|
|
719
762
|
})
|
|
720
763
|
}
|
|
721
764
|
}
|
|
@@ -729,6 +772,17 @@ export function computePlan(local, remote, mapsCtx, { deleteOrphans = false, sta
|
|
|
729
772
|
const plan = { create: [], update: [], delete: [], unchanged: [] }
|
|
730
773
|
const warnings = []
|
|
731
774
|
|
|
775
|
+
const endpointNameCollisions = findEndpointNameCollisions(Object.keys(remote?.byName || {}))
|
|
776
|
+
if (endpointNameCollisions.length) {
|
|
777
|
+
warnings.push({
|
|
778
|
+
type: "canonical_endpoint_name_collisions",
|
|
779
|
+
count: endpointNameCollisions.length,
|
|
780
|
+
examples: endpointNameCollisions.slice(0, 25),
|
|
781
|
+
truncated: endpointNameCollisions.length > 25,
|
|
782
|
+
hint: "Legacy underscore and canonical hyphen slugs coexist remotely. They are separate APIs; audit callers before deleting either form.",
|
|
783
|
+
})
|
|
784
|
+
}
|
|
785
|
+
|
|
732
786
|
// ── Groups plan ──────────────────────────────────────────────────────
|
|
733
787
|
// Groups are 100% folder-driven: each first-level subfolder in endpoints/
|
|
734
788
|
// IS a group. The remote is made to match the local folder structure
|
|
@@ -737,7 +791,7 @@ export function computePlan(local, remote, mapsCtx, { deleteOrphans = false, sta
|
|
|
737
791
|
// group is re-created automatically. Zero friction.
|
|
738
792
|
const localGroups = new Set()
|
|
739
793
|
for (const entry of Object.values(local.byName)) {
|
|
740
|
-
const g = entry?.doc?.group
|
|
794
|
+
const g = entry?.group || entry?.doc?.group || entry?.pushPayload?.group_name
|
|
741
795
|
if (g) localGroups.add(g)
|
|
742
796
|
}
|
|
743
797
|
const remoteGroupNames = new Set(Object.keys(mapsCtx.groupNameToId || {}))
|