run402-mcp 2.1.0 → 2.3.0

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.
@@ -362,8 +362,29 @@ function requireNonEmptyStringQueryOption(value, label, context) {
362
362
  return value;
363
363
  }
364
364
  // ─── Internal pipeline ───────────────────────────────────────────────────────
365
+ /**
366
+ * Compute the sorted set of slice kinds the spec carried. Surfaces on
367
+ * `commit.phase` and `ready` events so agents can group per-phase
368
+ * telemetry by slice category. `assets` slice → `"asset"`; any of
369
+ * `database` / `functions` / `site` → `"release"`. Order is stable
370
+ * (release before asset).
371
+ */
372
+ function deriveSliceKinds(spec) {
373
+ // Guard against non-object spec — the validate phase throws below
374
+ // (INVALID_SPEC), but this is called before validation in applyOnce so
375
+ // we must not blow up first.
376
+ if (!spec || typeof spec !== "object")
377
+ return [];
378
+ const set = new Set();
379
+ if (spec.database || spec.functions || spec.site)
380
+ set.add("release");
381
+ if (spec.assets)
382
+ set.add("asset");
383
+ return [...set].sort((a, b) => (a === "release" ? -1 : 1));
384
+ }
365
385
  async function applyOnce(client, spec, opts, emit) {
366
386
  const allowWarningCodes = normalizeAllowWarningCodes(opts.allowWarningCodes);
387
+ const sliceKinds = deriveSliceKinds(spec);
367
388
  emit({ type: "plan.started" });
368
389
  const { plan, byteReaders } = await planInternal(client, spec, opts.idempotencyKey);
369
390
  emit({ type: "plan.diff", diff: plan.diff });
@@ -383,10 +404,15 @@ async function applyOnce(client, spec, opts, emit) {
383
404
  // event and resolve before we hit upload.
384
405
  }
385
406
  await uploadMissing(client, spec.project, plan.missing_content, byteReaders, emit);
386
- emit({ type: "commit.phase", phase: "validate", status: "started" });
407
+ emit({
408
+ type: "commit.phase",
409
+ phase: "validate",
410
+ status: "started",
411
+ ...(sliceKinds.length > 0 ? { slice_kinds: sliceKinds } : {}),
412
+ });
387
413
  const { planId } = requirePersistedPlan(plan, "applying deploy");
388
414
  const commit = await commitInternal(client, planId, opts.idempotencyKey);
389
- const result = await pollUntilReady(client, commit, plan.diff, plan.warnings, emit, spec.project);
415
+ const result = await pollUntilReady(client, commit, plan.diff, plan.warnings, emit, spec.project, sliceKinds);
390
416
  // v1.48 unified-apply: thread the plan response's `asset_entries[]` back
391
417
  // into DeployResult.assets so callers reading `result.assets.byKey[key]`
392
418
  // get the gateway-authoritative `AssetRef` envelope (URLs, SRI, etag).
@@ -873,6 +899,7 @@ async function uploadMissing(client, projectId, presence, byteReaders, emit) {
873
899
  label: reader?.label ?? p.sha256,
874
900
  sha256: p.sha256,
875
901
  reason: "present",
902
+ ...(reader?.slice ? { slice_kind: reader.slice } : {}),
876
903
  });
877
904
  }
878
905
  // Filter to refs the gateway reported as missing for this project.
@@ -925,6 +952,7 @@ async function uploadMissing(client, projectId, presence, byteReaders, emit) {
925
952
  sha256: session.sha256,
926
953
  done,
927
954
  total,
955
+ ...(reader.slice ? { slice_kind: reader.slice } : {}),
928
956
  });
929
957
  }
930
958
  // Plan-level finalize — marks the plan committed in the deploy_plans
@@ -1040,7 +1068,7 @@ async function putToS3(fetchFn, url, body, checksumBase64, partNumber) {
1040
1068
  }
1041
1069
  return res.headers.get("etag");
1042
1070
  }
1043
- async function pollUntilReady(client, commit, diff, warnings, emit, projectId) {
1071
+ async function pollUntilReady(client, commit, diff, warnings, emit, projectId, sliceKinds = []) {
1044
1072
  if (commit.status === "failed") {
1045
1073
  throw translateGatewayError(commit.error, "commit", null, commit.operation_id);
1046
1074
  }
@@ -1054,7 +1082,12 @@ async function pollUntilReady(client, commit, diff, warnings, emit, projectId) {
1054
1082
  context: "committing deploy",
1055
1083
  });
1056
1084
  }
1057
- emit({ type: "ready", releaseId: commit.release_id, urls: commit.urls });
1085
+ emit({
1086
+ type: "ready",
1087
+ releaseId: commit.release_id,
1088
+ urls: commit.urls,
1089
+ ...(sliceKinds.length > 0 ? { slice_kinds: sliceKinds } : {}),
1090
+ });
1058
1091
  return {
1059
1092
  release_id: commit.release_id,
1060
1093
  operation_id: commit.operation_id,
@@ -1065,9 +1098,16 @@ async function pollUntilReady(client, commit, diff, warnings, emit, projectId) {
1065
1098
  }
1066
1099
  const opHeaders = projectId ? await apikeyHeaders(client, projectId) : {};
1067
1100
  const initialSnapshot = await client.request(`/apply/v1/operations/${encodeURIComponent(commit.operation_id)}`, { headers: opHeaders, context: "fetching deploy operation" });
1068
- return await pollSnapshotUntilReady(client, initialSnapshot, diff, warnings, emit, projectId);
1069
- }
1070
- async function pollSnapshotUntilReady(client, initial, diff, warnings, emit, projectId) {
1101
+ return await pollSnapshotUntilReady(client, initialSnapshot, diff, warnings, emit, projectId, sliceKinds);
1102
+ }
1103
+ async function pollSnapshotUntilReady(client, initial, diff, warnings, emit, projectId, sliceKinds = []) {
1104
+ // Helper to spread slice_kinds onto every commit.phase / ready emit so
1105
+ // agents grouping per-slice telemetry don't need to track the apply's
1106
+ // spec separately. The low-level commit/upload helpers that pass no
1107
+ // sliceKinds get an empty array → field is omitted from events.
1108
+ const withSliceKinds = (ev) => sliceKinds.length > 0
1109
+ ? { ...ev, slice_kinds: sliceKinds }
1110
+ : ev;
1071
1111
  let snapshot = initial;
1072
1112
  const opHeaders = projectId ? await apikeyHeaders(client, projectId) : {};
1073
1113
  let lastPhaseEmitted = null;
@@ -1107,7 +1147,7 @@ async function pollSnapshotUntilReady(client, initial, diff, warnings, emit, pro
1107
1147
  return;
1108
1148
  if (nextPhase !== undefined && prev.phase === nextPhase)
1109
1149
  return;
1110
- emit({ type: "commit.phase", phase: prev.phase, status: closeStatus });
1150
+ emit(withSliceKinds({ type: "commit.phase", phase: prev.phase, status: closeStatus }));
1111
1151
  };
1112
1152
  while (true) {
1113
1153
  if (lastPhaseEmitted !== snapshot.status) {
@@ -1115,7 +1155,7 @@ async function pollSnapshotUntilReady(client, initial, diff, warnings, emit, pro
1115
1155
  if (ev) {
1116
1156
  if (ev.type === "commit.phase")
1117
1157
  closePreviousPhase(ev.phase);
1118
- emit(ev);
1158
+ emit(withSliceKinds(ev));
1119
1159
  lastPhaseEmitted = snapshot.status;
1120
1160
  }
1121
1161
  // If `ev` is null (status not in the phase map, e.g. "ready"), leave
@@ -1133,7 +1173,7 @@ async function pollSnapshotUntilReady(client, initial, diff, warnings, emit, pro
1133
1173
  });
1134
1174
  }
1135
1175
  closePreviousPhase();
1136
- emit({ type: "ready", releaseId: snapshot.release_id, urls: snapshot.urls });
1176
+ emit(withSliceKinds({ type: "ready", releaseId: snapshot.release_id, urls: snapshot.urls }));
1137
1177
  return {
1138
1178
  release_id: snapshot.release_id,
1139
1179
  operation_id: snapshot.operation_id,
@@ -1215,12 +1255,18 @@ async function startInternal(client, spec, opts) {
1215
1255
  reason: plan.payment_required.reason,
1216
1256
  });
1217
1257
  }
1258
+ const sliceKinds = deriveSliceKinds(spec);
1218
1259
  const resultPromise = (async () => {
1219
1260
  await uploadMissing(client, spec.project, plan.missing_content, byteReaders, emit);
1220
- emit({ type: "commit.phase", phase: "validate", status: "started" });
1261
+ emit({
1262
+ type: "commit.phase",
1263
+ phase: "validate",
1264
+ status: "started",
1265
+ ...(sliceKinds.length > 0 ? { slice_kinds: sliceKinds } : {}),
1266
+ });
1221
1267
  const { planId } = requirePersistedPlan(plan, "starting deploy");
1222
1268
  const commit = await commitInternal(client, planId, opts.idempotencyKey);
1223
- return await pollUntilReady(client, commit, plan.diff, plan.warnings, emit, spec.project);
1269
+ return await pollUntilReady(client, commit, plan.diff, plan.warnings, emit, spec.project, sliceKinds);
1224
1270
  })();
1225
1271
  // Avoid an unhandled-rejection at construction time. Consumers must call
1226
1272
  // .result() to actually observe the error.
@@ -2033,7 +2079,13 @@ function invalidSecretSpec(message, resource) {
2033
2079
  }
2034
2080
  async function normalizeReleaseSpec(client, spec) {
2035
2081
  const byteReaders = new Map();
2036
- const remember = (resolved) => {
2082
+ // Slice-tagged `remember`. Each slice category creates its own remember
2083
+ // closure so the registered reader carries `reader.slice = "release" |
2084
+ // "asset"`. On cross-kind dedup (same SHA from both a release-bound
2085
+ // slice and the asset slice) the value escalates to `"mixed"`. This
2086
+ // value surfaces on `content.upload.*` events so agents can group
2087
+ // upload telemetry by slice kind.
2088
+ const makeRemember = (slice) => (resolved) => {
2037
2089
  // Propagate the final content-type onto the deferred reader so the CAS
2038
2090
  // upload session can declare it correctly. Callers may set
2039
2091
  // ref.contentType *after* resolveContent returns (e.g. normalizeFileSet
@@ -2042,18 +2094,25 @@ async function normalizeReleaseSpec(client, spec) {
2042
2094
  resolved.reader.contentType = resolved.ref.contentType;
2043
2095
  }
2044
2096
  if (!byteReaders.has(resolved.ref.sha256)) {
2097
+ resolved.reader.slice = slice;
2045
2098
  byteReaders.set(resolved.ref.sha256, resolved.reader);
2046
2099
  }
2047
2100
  else {
2048
2101
  // Already remembered — but if the existing reader has no contentType
2049
- // and we just learned it, fill it in.
2102
+ // and we just learned it, fill it in. Also escalate slice tag when
2103
+ // the second registration comes from a different kind.
2050
2104
  const existing = byteReaders.get(resolved.ref.sha256);
2051
2105
  if (resolved.ref.contentType && !existing.contentType) {
2052
2106
  existing.contentType = resolved.ref.contentType;
2053
2107
  }
2108
+ if (existing.slice && existing.slice !== slice && existing.slice !== "mixed") {
2109
+ existing.slice = "mixed";
2110
+ }
2054
2111
  }
2055
2112
  return resolved.ref;
2056
2113
  };
2114
+ const rememberRelease = makeRemember("release");
2115
+ const rememberAsset = makeRemember("asset");
2057
2116
  const normalized = { project: spec.project };
2058
2117
  if (spec.base)
2059
2118
  normalized.base = spec.base;
@@ -2074,19 +2133,19 @@ async function normalizeReleaseSpec(client, spec) {
2074
2133
  db.zero_downtime = spec.database.zero_downtime;
2075
2134
  }
2076
2135
  if (spec.database.migrations && spec.database.migrations.length > 0) {
2077
- db.migrations = await Promise.all(spec.database.migrations.map(async (m) => normalizeMigration(client, spec.project, m, remember)));
2136
+ db.migrations = await Promise.all(spec.database.migrations.map(async (m) => normalizeMigration(client, spec.project, m, rememberRelease)));
2078
2137
  }
2079
2138
  normalized.database = db;
2080
2139
  }
2081
2140
  if (spec.functions) {
2082
2141
  const fns = {};
2083
2142
  if (spec.functions.replace) {
2084
- fns.replace = await normalizeFunctionMap(spec.functions.replace, remember);
2143
+ fns.replace = await normalizeFunctionMap(spec.functions.replace, rememberRelease);
2085
2144
  }
2086
2145
  if (spec.functions.patch) {
2087
2146
  fns.patch = {};
2088
2147
  if (spec.functions.patch.set) {
2089
- fns.patch.set = await normalizeFunctionMap(spec.functions.patch.set, remember);
2148
+ fns.patch.set = await normalizeFunctionMap(spec.functions.patch.set, rememberRelease);
2090
2149
  }
2091
2150
  if (spec.functions.patch.delete)
2092
2151
  fns.patch.delete = spec.functions.patch.delete;
@@ -2096,7 +2155,7 @@ async function normalizeReleaseSpec(client, spec) {
2096
2155
  if (spec.site) {
2097
2156
  const publicPaths = "public_paths" in spec.site ? spec.site.public_paths : undefined;
2098
2157
  if ("replace" in spec.site && spec.site.replace) {
2099
- const map = await normalizeFileSet(spec.site.replace, remember);
2158
+ const map = await normalizeFileSet(spec.site.replace, rememberRelease);
2100
2159
  normalized.site = {
2101
2160
  replace: map,
2102
2161
  ...(publicPaths ? { public_paths: publicPaths } : {}),
@@ -2105,7 +2164,7 @@ async function normalizeReleaseSpec(client, spec) {
2105
2164
  else if ("patch" in spec.site && spec.site.patch) {
2106
2165
  const patch = {};
2107
2166
  if (spec.site.patch.put) {
2108
- patch.put = await normalizeFileSet(spec.site.patch.put, remember);
2167
+ patch.put = await normalizeFileSet(spec.site.patch.put, rememberRelease);
2109
2168
  }
2110
2169
  if (spec.site.patch.delete)
2111
2170
  patch.delete = spec.site.patch.delete;
@@ -2123,7 +2182,7 @@ async function normalizeReleaseSpec(client, spec) {
2123
2182
  // register a byte-reader; emit the wire-shaped `AssetPutEntry[]`.
2124
2183
  // Cross-kind SHA dedup is automatic via the shared `byteReaders` map.
2125
2184
  if (spec.assets) {
2126
- normalized.assets = await normalizeAssetSlice(spec.assets, remember);
2185
+ normalized.assets = await normalizeAssetSlice(spec.assets, rememberAsset);
2127
2186
  }
2128
2187
  return { normalized, byteReaders };
2129
2188
  }
@@ -2173,39 +2232,75 @@ async function normalizeFileSet(set, remember) {
2173
2232
  * attacker-controlled or filesystem-derived keys (`__proto__`,
2174
2233
  * `constructor`, `toString`) don't collide with prototype properties.
2175
2234
  *
2176
- * Totals are placeholders here (`bytes_uploaded: 0`, `bytes_reused: 0`,
2177
- * `duration_ms: 0`); upstream callers that need realised values back-fill
2178
- * them from the realised `content.upload.*` event stream. The shape stays
2179
- * stable so consumers can rely on the keys being present.
2235
+ * Totals are derived from the plan response's per-entry `status`:
2236
+ * - `"upload_pending"` bytes_uploaded (the SDK is about to PUT these to S3)
2237
+ * - `"present"` or `"satisfied_by_plan"` bytes_reused (already in CAS,
2238
+ * dedup hit either project-locally or via a same-spec sibling)
2239
+ * `duration_ms` is filled in by `manifestFromResult` at the NodeAssets layer.
2180
2240
  */
2181
2241
  function buildAssetManifestFromPlanEntries(entries) {
2182
2242
  const list = [];
2183
2243
  const byKey = Object.create(null);
2184
2244
  const manifest = Object.create(null);
2245
+ let bytesUploaded = 0;
2246
+ let bytesReused = 0;
2185
2247
  for (const entry of entries) {
2248
+ const ref = entry.asset_ref;
2186
2249
  const e = {
2187
2250
  key: entry.key,
2188
2251
  sha256: entry.sha256,
2189
2252
  size_bytes: entry.size_bytes,
2190
2253
  content_type: entry.content_type,
2191
2254
  visibility: entry.visibility,
2192
- url: entry.asset_ref.url,
2193
- immutable_url: entry.asset_ref.immutable_url,
2194
- cdn_url: entry.asset_ref.cdn_url,
2195
- cdn_immutable_url: entry.asset_ref.cdn_immutable_url,
2196
- sri: entry.asset_ref.sri,
2197
- etag: entry.asset_ref.etag,
2198
- content_digest: entry.asset_ref.content_digest,
2255
+ url: ref.url,
2256
+ immutable_url: ref.immutable_url,
2257
+ cdn_url: ref.cdn_url,
2258
+ cdn_immutable_url: ref.cdn_immutable_url,
2259
+ sri: ref.sri,
2260
+ etag: ref.etag,
2261
+ content_digest: ref.content_digest,
2199
2262
  };
2263
+ // v1.49+ image-variant pass-through. Only emitted when the gateway
2264
+ // returned them (image MIMEs ≥320×320; HEIC/HEIF sources also include
2265
+ // `display_jpeg`). Pre-v1.49 plan responses omit these fields entirely
2266
+ // and the manifest entry stays bytewise-identical to before.
2267
+ if (ref.width_px !== undefined)
2268
+ e.width_px = ref.width_px;
2269
+ if (ref.height_px !== undefined)
2270
+ e.height_px = ref.height_px;
2271
+ if (ref.blurhash !== undefined)
2272
+ e.blurhash = ref.blurhash;
2273
+ if (ref.variant_spec_version !== undefined) {
2274
+ e.variant_spec_version = ref.variant_spec_version;
2275
+ }
2276
+ if (ref.display_url !== undefined)
2277
+ e.display_url = ref.display_url;
2278
+ if (ref.display_immutable_url !== undefined) {
2279
+ e.display_immutable_url = ref.display_immutable_url;
2280
+ }
2281
+ if (ref.variants !== undefined)
2282
+ e.variants = ref.variants;
2200
2283
  list.push(e);
2201
2284
  byKey[entry.key] = e;
2202
2285
  manifest[entry.key] = e;
2286
+ if (entry.status === "upload_pending") {
2287
+ bytesUploaded += entry.size_bytes;
2288
+ }
2289
+ else {
2290
+ // "present" or "satisfied_by_plan" — already in CAS or covered by a sibling.
2291
+ bytesReused += entry.size_bytes;
2292
+ }
2203
2293
  }
2204
2294
  return {
2205
2295
  list,
2206
2296
  byKey,
2207
2297
  manifest,
2208
- totals: { files: entries.length, bytes_uploaded: 0, bytes_reused: 0, duration_ms: 0 },
2298
+ totals: {
2299
+ files: entries.length,
2300
+ bytes_uploaded: bytesUploaded,
2301
+ bytes_reused: bytesReused,
2302
+ duration_ms: 0,
2303
+ },
2209
2304
  };
2210
2305
  }
2211
2306
  // ─── Asset slice normalization (v1.48 unified-apply) ─────────────────────────