@dypai-ai/mcp 1.6.20 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/generated/serverInstructions.js +3 -3
- package/src/index.js +82 -8
- package/src/tools/deploy.js +142 -4
- package/src/tools/project-deploy-production.js +9 -0
- package/src/tools/storage.js +437 -72
- package/src/tools/sync/generate-types.js +17 -1
- package/src/tools/sync/planner.js +40 -3
- package/src/tools/sync/push.js +22 -3
- package/src/tools/sync/test-endpoint.js +7 -6
- package/src/tools/sync/validate.js +74 -1
- package/src/tools/trace-summarize.js +109 -1
package/src/tools/storage.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* manage_storage — local extension adding
|
|
2
|
+
* manage_storage — local extension adding `upload_file` and `upload_files`.
|
|
3
3
|
*
|
|
4
4
|
* The remote `manage_storage` tool already covers list / create / delete buckets
|
|
5
5
|
* and list_objects / delete_object / get_signed_download_url. What it CAN'T do
|
|
@@ -24,14 +24,20 @@
|
|
|
24
24
|
* operation grafted on top — the dispatcher logic lives there too.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { statSync, readFileSync, existsSync } from "fs"
|
|
28
|
-
import { basename } from "path"
|
|
27
|
+
import { statSync, readFileSync, existsSync, readdirSync } from "fs"
|
|
28
|
+
import { basename, join, relative } from "path"
|
|
29
29
|
import { proxyToolCall } from "./proxy.js"
|
|
30
|
-
import { updateMediaManifest } from "./deploy.js"
|
|
30
|
+
import { updateMediaManifest, writeMediaManifestBatch } from "./deploy.js"
|
|
31
31
|
|
|
32
32
|
// 100 MB — enforced by the API (MAX_UPLOAD_SIZE_BYTES). We check client-side too
|
|
33
33
|
// so the user sees a clean error before we waste a round-trip.
|
|
34
34
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
|
|
35
|
+
const DEFAULT_UPLOAD_CONCURRENCY = 5
|
|
36
|
+
const MAX_UPLOAD_CONCURRENCY = 20
|
|
37
|
+
const DEFAULT_MAX_RETRIES = 3
|
|
38
|
+
const RETRY_BASE_MS = 300
|
|
39
|
+
const RETRY_MAX_MS = 4000
|
|
40
|
+
const LIST_OBJECTS_PAGE_SIZE = 100
|
|
35
41
|
|
|
36
42
|
// Extensions we know how to MIME-type without shelling out. Everything else
|
|
37
43
|
// falls back to application/octet-stream, which the bucket accepts fine — the browser
|
|
@@ -75,6 +81,223 @@ function formatBytes(n) {
|
|
|
75
81
|
return `${(n / 1024 / 1024).toFixed(1)} MB`
|
|
76
82
|
}
|
|
77
83
|
|
|
84
|
+
function sleep(ms) {
|
|
85
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function retryDelayMs(attempt) {
|
|
89
|
+
const exp = Math.min(RETRY_BASE_MS * (2 ** attempt), RETRY_MAX_MS)
|
|
90
|
+
const jitter = Math.floor(Math.random() * 100)
|
|
91
|
+
return exp + jitter
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** HTTP statuses worth retrying (transient server/rate-limit errors). */
|
|
95
|
+
export function isRetryableStatus(status) {
|
|
96
|
+
return [408, 429, 500, 502, 503, 504].includes(Number(status))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Classify thrown errors for retry logic. Exported for unit tests. */
|
|
100
|
+
export function isRetryableError(err) {
|
|
101
|
+
if (!err) return false
|
|
102
|
+
if (err.retryable === false) return false
|
|
103
|
+
if (err.retryable === true) return true
|
|
104
|
+
if (err.status != null) return isRetryableStatus(err.status)
|
|
105
|
+
|
|
106
|
+
const code = err.code
|
|
107
|
+
if (code === "ECONNRESET" || code === "ETIMEDOUT" || code === "ENOTFOUND" || code === "EAI_AGAIN") {
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const msg = String(err.message || "").toLowerCase()
|
|
112
|
+
if (msg.includes("fetch failed") || msg.includes("network") || msg.includes("socket")) {
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const statusMatch = msg.match(/status (\d{3})/)
|
|
117
|
+
if (statusMatch) return isRetryableStatus(Number(statusMatch[1]))
|
|
118
|
+
|
|
119
|
+
// Proxy/API throws without status — assume transient unless clearly client error
|
|
120
|
+
if (msg.includes("403") || msg.includes("404") || msg.includes("413") || msg.includes("400")) {
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function markRetryable(err, retryable) {
|
|
128
|
+
err.retryable = retryable
|
|
129
|
+
return err
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Retry fn on transient failures with exponential backoff + jitter. */
|
|
133
|
+
export async function withRetry(fn, { retries = DEFAULT_MAX_RETRIES } = {}) {
|
|
134
|
+
let lastErr
|
|
135
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
136
|
+
try {
|
|
137
|
+
return await fn(attempt)
|
|
138
|
+
} catch (e) {
|
|
139
|
+
lastErr = e
|
|
140
|
+
if (!isRetryableError(e) || attempt >= retries) throw e
|
|
141
|
+
await sleep(retryDelayMs(attempt))
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
throw lastErr
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function joinStorageKey(prefix, name) {
|
|
148
|
+
const p = (prefix || "").replace(/^\/+|\/+$/g, "")
|
|
149
|
+
const n = (name || "").replace(/^\/+/, "")
|
|
150
|
+
if (p && n) return `${p}/${n}`
|
|
151
|
+
return p || n
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function matchesInclude(filename, include) {
|
|
155
|
+
if (!include?.length) return true
|
|
156
|
+
const ext = filename.includes(".") ? `.${filename.split(".").pop().toLowerCase()}` : ""
|
|
157
|
+
return include.some((item) => {
|
|
158
|
+
const normalized = item.startsWith(".") ? item.toLowerCase() : `.${item.toLowerCase()}`
|
|
159
|
+
return ext === normalized
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function walkDirectory(rootDir, dir, recursive, include, out) {
|
|
164
|
+
let entries
|
|
165
|
+
try {
|
|
166
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
167
|
+
} catch {
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const ent of entries) {
|
|
172
|
+
const full = join(dir, ent.name)
|
|
173
|
+
if (ent.isDirectory()) {
|
|
174
|
+
if (recursive) walkDirectory(rootDir, full, recursive, include, out)
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
if (!ent.isFile()) continue
|
|
178
|
+
if (ent.name.startsWith(".")) continue
|
|
179
|
+
if (!matchesInclude(ent.name, include)) continue
|
|
180
|
+
const rel = relative(rootDir, full).split("\\").join("/")
|
|
181
|
+
out.push({ local_path: full, relative_path: rel })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolve upload entries from either an explicit file list or a directory walk.
|
|
187
|
+
* Exported for unit tests.
|
|
188
|
+
*/
|
|
189
|
+
export function collectUploadEntries({
|
|
190
|
+
files,
|
|
191
|
+
directory,
|
|
192
|
+
recursive = true,
|
|
193
|
+
prefix,
|
|
194
|
+
include,
|
|
195
|
+
}) {
|
|
196
|
+
if (Array.isArray(files) && files.length > 0) {
|
|
197
|
+
return files.map((file) => {
|
|
198
|
+
const objectName = file.object_name || basename(file.local_path)
|
|
199
|
+
return {
|
|
200
|
+
local_path: file.local_path,
|
|
201
|
+
object_name: joinStorageKey(file.prefix ?? prefix, objectName),
|
|
202
|
+
content_type: file.content_type || undefined,
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (directory) {
|
|
208
|
+
if (!existsSync(directory)) {
|
|
209
|
+
return { error: `Directory not found: ${directory}` }
|
|
210
|
+
}
|
|
211
|
+
const stat = statSync(directory)
|
|
212
|
+
if (!stat.isDirectory()) {
|
|
213
|
+
return { error: `Not a directory: ${directory}` }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const discovered = []
|
|
217
|
+
walkDirectory(directory, directory, bool(recursive), include, discovered)
|
|
218
|
+
return discovered.map((entry) => ({
|
|
219
|
+
local_path: entry.local_path,
|
|
220
|
+
object_name: joinStorageKey(prefix, entry.relative_path),
|
|
221
|
+
content_type: undefined,
|
|
222
|
+
}))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { error: "Either `files` (non-empty array) or `directory` is required for upload_files." }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function ensureBucketExists({ bucket, bucket_public, project_id }) {
|
|
229
|
+
try {
|
|
230
|
+
const buckets = await proxyToolCall("manage_storage", {
|
|
231
|
+
operation: "list",
|
|
232
|
+
project_id,
|
|
233
|
+
})
|
|
234
|
+
const list = Array.isArray(buckets) ? buckets : (buckets?.data || [])
|
|
235
|
+
const exists = list.some(b => (b.name || b) === bucket)
|
|
236
|
+
if (!exists) {
|
|
237
|
+
await proxyToolCall("manage_storage", {
|
|
238
|
+
operation: "create",
|
|
239
|
+
project_id,
|
|
240
|
+
name: bucket,
|
|
241
|
+
public: bool(bucket_public),
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
return null
|
|
245
|
+
} catch (e) {
|
|
246
|
+
return `ensure_bucket failed: ${e.message}`
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Paginated list_objects sweep; returns null on failure (best-effort). */
|
|
251
|
+
async function listExistingObjectNames({ bucket, project_id, prefix }) {
|
|
252
|
+
const names = new Set()
|
|
253
|
+
let offset = 0
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
while (true) {
|
|
257
|
+
const resp = await proxyToolCall("manage_storage", {
|
|
258
|
+
operation: "list_objects",
|
|
259
|
+
project_id,
|
|
260
|
+
bucket,
|
|
261
|
+
prefix: prefix || undefined,
|
|
262
|
+
limit: LIST_OBJECTS_PAGE_SIZE,
|
|
263
|
+
offset,
|
|
264
|
+
})
|
|
265
|
+
const list = Array.isArray(resp) ? resp : (resp?.data || [])
|
|
266
|
+
for (const obj of list) {
|
|
267
|
+
const name = typeof obj === "string" ? obj : obj?.name
|
|
268
|
+
if (name) names.add(name)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const hasMore = resp?.has_more === true
|
|
272
|
+
|| (resp?.has_more == null && list.length >= LIST_OBJECTS_PAGE_SIZE)
|
|
273
|
+
if (!hasMore || list.length === 0) break
|
|
274
|
+
offset += LIST_OBJECTS_PAGE_SIZE
|
|
275
|
+
}
|
|
276
|
+
return names
|
|
277
|
+
} catch {
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function runPool(items, concurrency, fn) {
|
|
283
|
+
const results = new Array(items.length)
|
|
284
|
+
let next = 0
|
|
285
|
+
|
|
286
|
+
async function worker() {
|
|
287
|
+
while (next < items.length) {
|
|
288
|
+
const idx = next++
|
|
289
|
+
results[idx] = await fn(items[idx], idx)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const workers = Array.from(
|
|
294
|
+
{ length: Math.min(concurrency, items.length) },
|
|
295
|
+
() => worker(),
|
|
296
|
+
)
|
|
297
|
+
await Promise.all(workers)
|
|
298
|
+
return results
|
|
299
|
+
}
|
|
300
|
+
|
|
78
301
|
/**
|
|
79
302
|
* Core upload. Returns { success, bucket, name, size, content_type,
|
|
80
303
|
* signed_url, public_url, object_id, ... } or { success: false, error }.
|
|
@@ -99,6 +322,8 @@ export async function uploadFile({
|
|
|
99
322
|
// Optional: if provided, the manifest at <source_directory>/dypai/.dypai/media-manifest.json
|
|
100
323
|
// is updated after a successful upload so future deploys know this file is in the bucket.
|
|
101
324
|
source_directory,
|
|
325
|
+
skip_preview_url = false,
|
|
326
|
+
max_retries = DEFAULT_MAX_RETRIES,
|
|
102
327
|
}) {
|
|
103
328
|
// ── Validation ──────────────────────────────────────────────────────────
|
|
104
329
|
if (!local_path) return { success: false, error: "`local_path` is required." }
|
|
@@ -123,51 +348,47 @@ export async function uploadFile({
|
|
|
123
348
|
|
|
124
349
|
const filename = object_name || basename(local_path)
|
|
125
350
|
const resolvedContentType = content_type || mimeFor(filename)
|
|
351
|
+
const retries = Math.max(0, Number(max_retries) || DEFAULT_MAX_RETRIES)
|
|
126
352
|
|
|
127
353
|
// ── Ensure bucket (optional) ────────────────────────────────────────────
|
|
128
354
|
// If the agent passed ensure_bucket:true and the bucket doesn't exist yet,
|
|
129
355
|
// create it. Saves a round-trip of "bucket not found → go create → retry".
|
|
130
356
|
if (ensure_bucket) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
operation: "list",
|
|
134
|
-
project_id,
|
|
135
|
-
})
|
|
136
|
-
const list = Array.isArray(buckets) ? buckets : (buckets?.data || [])
|
|
137
|
-
const exists = list.some(b => (b.name || b) === bucket)
|
|
138
|
-
if (!exists) {
|
|
139
|
-
await proxyToolCall("manage_storage", {
|
|
140
|
-
operation: "create",
|
|
141
|
-
project_id,
|
|
142
|
-
name: bucket,
|
|
143
|
-
public: bool(bucket_public),
|
|
144
|
-
})
|
|
145
|
-
}
|
|
146
|
-
} catch (e) {
|
|
147
|
-
return { success: false, error: `ensure_bucket failed: ${e.message}` }
|
|
148
|
-
}
|
|
357
|
+
const bucketError = await ensureBucketExists({ bucket, bucket_public, project_id })
|
|
358
|
+
if (bucketError) return { success: false, error: bucketError }
|
|
149
359
|
}
|
|
150
360
|
|
|
151
361
|
// ── Step 1: sign_upload ─────────────────────────────────────────────────
|
|
152
362
|
let signed
|
|
153
363
|
try {
|
|
154
|
-
signed = await
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
364
|
+
signed = await withRetry(async () => {
|
|
365
|
+
try {
|
|
366
|
+
const result = await proxyToolCall("manage_storage", {
|
|
367
|
+
operation: "sign_upload",
|
|
368
|
+
project_id,
|
|
369
|
+
bucket,
|
|
370
|
+
filename,
|
|
371
|
+
content_type: resolvedContentType,
|
|
372
|
+
size_bytes: stat.size,
|
|
373
|
+
prefix,
|
|
374
|
+
})
|
|
375
|
+
if (!result?.upload_url || !result?.file_path) {
|
|
376
|
+
throw markRetryable(
|
|
377
|
+
new Error("sign_upload returned an invalid payload (no upload_url or file_path)."),
|
|
378
|
+
false,
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
return result
|
|
382
|
+
} catch (e) {
|
|
383
|
+
if (e.retryable === false) throw e
|
|
384
|
+
throw markRetryable(new Error(e.message || "sign_upload failed"), isRetryableError(e))
|
|
385
|
+
}
|
|
386
|
+
}, { retries })
|
|
163
387
|
} catch (e) {
|
|
164
388
|
return { success: false, error: `sign_upload failed: ${e.message}`, hint: bucketHint(e.message, bucket) }
|
|
165
389
|
}
|
|
166
390
|
|
|
167
391
|
const { upload_url, method = "PUT", headers = {}, file_path, public_url } = signed || {}
|
|
168
|
-
if (!upload_url || !file_path) {
|
|
169
|
-
return { success: false, error: "sign_upload returned an invalid payload (no upload_url or file_path)." }
|
|
170
|
-
}
|
|
171
392
|
|
|
172
393
|
// ── Step 2: direct PUT to the bucket ────────────────────────────────────
|
|
173
394
|
let buf
|
|
@@ -178,38 +399,48 @@ export async function uploadFile({
|
|
|
178
399
|
}
|
|
179
400
|
|
|
180
401
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
402
|
+
await withRetry(async () => {
|
|
403
|
+
const res = await fetch(upload_url, {
|
|
404
|
+
method,
|
|
405
|
+
headers: {
|
|
406
|
+
"Content-Type": resolvedContentType,
|
|
407
|
+
...headers,
|
|
408
|
+
},
|
|
409
|
+
body: buf,
|
|
410
|
+
})
|
|
411
|
+
if (!res.ok) {
|
|
412
|
+
const detail = await safeReadBody(res)
|
|
413
|
+
const err = new Error(
|
|
414
|
+
`Storage upload failed with status ${res.status}. ${detail ? `Detail: ${detail.slice(0, 300)}` : ""}`.trim(),
|
|
415
|
+
)
|
|
416
|
+
err.status = res.status
|
|
417
|
+
throw markRetryable(err, isRetryableStatus(res.status))
|
|
194
418
|
}
|
|
195
|
-
}
|
|
419
|
+
}, { retries })
|
|
196
420
|
} catch (e) {
|
|
197
|
-
return { success: false, error:
|
|
421
|
+
return { success: false, error: e.message || "Direct upload to storage failed." }
|
|
198
422
|
}
|
|
199
423
|
|
|
200
424
|
// ── Step 3: verify_upload ───────────────────────────────────────────────
|
|
201
425
|
let verified
|
|
202
426
|
try {
|
|
203
|
-
verified = await
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
427
|
+
verified = await withRetry(async () => {
|
|
428
|
+
try {
|
|
429
|
+
return await proxyToolCall("manage_storage", {
|
|
430
|
+
operation: "verify_upload",
|
|
431
|
+
project_id,
|
|
432
|
+
bucket,
|
|
433
|
+
file_path,
|
|
434
|
+
original_filename: filename,
|
|
435
|
+
content_type: resolvedContentType,
|
|
436
|
+
size_bytes: stat.size,
|
|
437
|
+
prefix,
|
|
438
|
+
})
|
|
439
|
+
} catch (e) {
|
|
440
|
+
if (e.retryable === false) throw e
|
|
441
|
+
throw markRetryable(new Error(e.message || "verify_upload failed"), isRetryableError(e))
|
|
442
|
+
}
|
|
443
|
+
}, { retries })
|
|
213
444
|
} catch (e) {
|
|
214
445
|
return {
|
|
215
446
|
success: false,
|
|
@@ -220,18 +451,20 @@ export async function uploadFile({
|
|
|
220
451
|
// ── Step 4: fetch a preview signed download URL ─────────────────────────
|
|
221
452
|
// Best-effort: if it fails we just skip it, the upload succeeded regardless.
|
|
222
453
|
let signedDownloadUrl = null
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
454
|
+
if (!skip_preview_url) {
|
|
455
|
+
try {
|
|
456
|
+
const dl = await proxyToolCall("manage_storage", {
|
|
457
|
+
operation: "get_signed_download_url",
|
|
458
|
+
project_id,
|
|
459
|
+
bucket,
|
|
460
|
+
name: verified?.name || filename,
|
|
461
|
+
expires_minutes: 15,
|
|
462
|
+
download: false,
|
|
463
|
+
})
|
|
464
|
+
signedDownloadUrl = dl?.signed_url || null
|
|
465
|
+
} catch {
|
|
466
|
+
// non-fatal
|
|
467
|
+
}
|
|
235
468
|
}
|
|
236
469
|
|
|
237
470
|
// ── Update manifest (if source_directory provided) ──────────────────────
|
|
@@ -264,7 +497,139 @@ export async function uploadFile({
|
|
|
264
497
|
signed_url: signedDownloadUrl,
|
|
265
498
|
signed_url_expires_minutes: signedDownloadUrl ? 15 : null,
|
|
266
499
|
public_url: public_url || null,
|
|
267
|
-
message: `✓ Uploaded '${filename}' (${formatBytes(stat.size)}) to bucket '${bucket}'
|
|
500
|
+
message: `✓ Uploaded '${filename}' (${formatBytes(stat.size)}) to bucket '${bucket}'.${source_directory ? " Manifest updated — future deploys will skip this file." : ""}`,
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Batch upload many local files in one call. Reuses uploadFile() per entry with
|
|
506
|
+
* bounded concurrency. Returns an aggregated summary instead of per-file noise.
|
|
507
|
+
*/
|
|
508
|
+
export async function uploadFiles({
|
|
509
|
+
bucket,
|
|
510
|
+
files,
|
|
511
|
+
directory,
|
|
512
|
+
recursive = true,
|
|
513
|
+
prefix,
|
|
514
|
+
include,
|
|
515
|
+
ensure_bucket = false,
|
|
516
|
+
bucket_public = false,
|
|
517
|
+
project_id,
|
|
518
|
+
source_directory,
|
|
519
|
+
concurrency = DEFAULT_UPLOAD_CONCURRENCY,
|
|
520
|
+
max_retries = DEFAULT_MAX_RETRIES,
|
|
521
|
+
skip_existing = true,
|
|
522
|
+
overwrite = false,
|
|
523
|
+
}) {
|
|
524
|
+
if (!bucket) return { success: false, error: "`bucket` is required (e.g. 'public')." }
|
|
525
|
+
|
|
526
|
+
const collected = collectUploadEntries({ files, directory, recursive, prefix, include })
|
|
527
|
+
if (collected.error) return { success: false, error: collected.error }
|
|
528
|
+
|
|
529
|
+
const entries = collected
|
|
530
|
+
if (entries.length === 0) {
|
|
531
|
+
return { success: false, error: "No files to upload. Check `files`, `directory`, or `include` filters." }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (ensure_bucket) {
|
|
535
|
+
const bucketError = await ensureBucketExists({ bucket, bucket_public, project_id })
|
|
536
|
+
if (bucketError) return { success: false, error: bucketError }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const shouldSkip = bool(skip_existing) && !bool(overwrite)
|
|
540
|
+
const skippedResults = []
|
|
541
|
+
let toUpload = entries
|
|
542
|
+
|
|
543
|
+
if (shouldSkip) {
|
|
544
|
+
const existingNames = await listExistingObjectNames({ bucket, project_id, prefix })
|
|
545
|
+
if (existingNames) {
|
|
546
|
+
toUpload = []
|
|
547
|
+
for (const entry of entries) {
|
|
548
|
+
if (existingNames.has(entry.object_name)) {
|
|
549
|
+
skippedResults.push({
|
|
550
|
+
local_path: entry.local_path,
|
|
551
|
+
name: entry.object_name,
|
|
552
|
+
status: "skipped",
|
|
553
|
+
error: null,
|
|
554
|
+
})
|
|
555
|
+
} else {
|
|
556
|
+
toUpload.push(entry)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const poolSize = Math.max(
|
|
563
|
+
1,
|
|
564
|
+
Math.min(Number(concurrency) || DEFAULT_UPLOAD_CONCURRENCY, MAX_UPLOAD_CONCURRENCY),
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
const manifestEntries = {}
|
|
568
|
+
|
|
569
|
+
const poolResults = toUpload.length > 0
|
|
570
|
+
? await runPool(toUpload, poolSize, async (entry) => {
|
|
571
|
+
const result = await uploadFile({
|
|
572
|
+
local_path: entry.local_path,
|
|
573
|
+
bucket,
|
|
574
|
+
object_name: entry.object_name,
|
|
575
|
+
content_type: entry.content_type,
|
|
576
|
+
ensure_bucket: false,
|
|
577
|
+
bucket_public,
|
|
578
|
+
project_id,
|
|
579
|
+
skip_preview_url: true,
|
|
580
|
+
max_retries,
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
if (result.success && source_directory) {
|
|
584
|
+
manifestEntries[entry.local_path] = {
|
|
585
|
+
size: result.size_bytes,
|
|
586
|
+
bucket,
|
|
587
|
+
object_name: result.storage_path || result.name || entry.object_name,
|
|
588
|
+
content_type: result.content_type,
|
|
589
|
+
uploaded_at: new Date().toISOString(),
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
local_path: entry.local_path,
|
|
595
|
+
name: result.storage_path || result.name || entry.object_name,
|
|
596
|
+
status: result.success ? "uploaded" : "failed",
|
|
597
|
+
error: result.success ? null : (result.error || "Upload failed"),
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
: []
|
|
601
|
+
|
|
602
|
+
if (source_directory && Object.keys(manifestEntries).length > 0) {
|
|
603
|
+
try {
|
|
604
|
+
writeMediaManifestBatch(source_directory, manifestEntries)
|
|
605
|
+
} catch {
|
|
606
|
+
// non-fatal
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const results = [...skippedResults, ...poolResults]
|
|
611
|
+
const uploaded = results.filter(r => r.status === "uploaded").length
|
|
612
|
+
const failed = results.filter(r => r.status === "failed").length
|
|
613
|
+
const skipped = results.filter(r => r.status === "skipped").length
|
|
614
|
+
const storage_paths = results.filter(r => r.status === "uploaded").map(r => r.name)
|
|
615
|
+
|
|
616
|
+
const parts = []
|
|
617
|
+
if (uploaded > 0) parts.push(`${uploaded} uploaded`)
|
|
618
|
+
if (skipped > 0) parts.push(`${skipped} skipped`)
|
|
619
|
+
if (failed > 0) parts.push(`${failed} failed`)
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
success: failed === 0 && (uploaded + skipped) > 0,
|
|
623
|
+
bucket,
|
|
624
|
+
total: results.length,
|
|
625
|
+
uploaded,
|
|
626
|
+
skipped,
|
|
627
|
+
failed,
|
|
628
|
+
results,
|
|
629
|
+
storage_paths,
|
|
630
|
+
message: failed === 0
|
|
631
|
+
? `✓ Batch complete for bucket '${bucket}': ${parts.join(", ")}.`
|
|
632
|
+
: `Batch for bucket '${bucket}': ${parts.join(", ")} — see results[].error.`,
|
|
268
633
|
}
|
|
269
634
|
}
|
|
270
635
|
|
|
@@ -48,6 +48,10 @@ export async function runGenerateEndpointTypes(rootDir, projectId = null) {
|
|
|
48
48
|
typesPath,
|
|
49
49
|
contractPath,
|
|
50
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) : [],
|
|
51
55
|
}
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -57,6 +61,10 @@ export async function runGenerateEndpointTypes(rootDir, projectId = null) {
|
|
|
57
61
|
typesPath: payload.typesPath,
|
|
58
62
|
contractPath: payload.contractPath,
|
|
59
63
|
endpoints: payload.endpoints || [],
|
|
64
|
+
diagnosticCount: payload.diagnosticCount ?? 0,
|
|
65
|
+
errorCount: payload.errorCount ?? 0,
|
|
66
|
+
warningCount: payload.warningCount ?? 0,
|
|
67
|
+
diagnostics: Array.isArray(payload.diagnostics) ? payload.diagnostics.slice(0, 10) : [],
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
70
|
|
|
@@ -112,8 +120,16 @@ export const dypaiGenerateTypesTool = {
|
|
|
112
120
|
types_path: "dypai/types/endpoints.gen.ts",
|
|
113
121
|
contract_path: "dypai/types/endpoints.contract.json",
|
|
114
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,
|
|
115
131
|
hint: outcome.endpointCount
|
|
116
|
-
? `Types refreshed for ${outcome.endpointCount} endpoint(s). Frontend can import from dypai/types/endpoints.gen.ts
|
|
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." : ""}`
|
|
117
133
|
: "No effective endpoints found — types file is empty.",
|
|
118
134
|
}
|
|
119
135
|
},
|