@run402/sdk 2.2.0 → 2.3.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.
@@ -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,16 +404,79 @@ 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).
393
419
  // Release-only applies leave `result.assets` undefined.
394
420
  if (plan.asset_entries && plan.asset_entries.length > 0) {
395
421
  result.assets = buildAssetManifestFromPlanEntries(plan.asset_entries);
422
+ // v1.49 image-variant follow-up: variants are generated AT COMMIT TIME
423
+ // (parent gateway change Section 5 — `prepareStagedAssetVariants` runs
424
+ // before the activation txn opens). The plan that funded `result.assets`
425
+ // was built BEFORE commit, so its `asset_entries[].asset_ref` doesn't
426
+ // include the variant fields (the gateway only threads `image_data`
427
+ // through `buildAssetRefForPlan` for SHAs that ALREADY have variants
428
+ // in `internal.blob_image_variants`).
429
+ //
430
+ // For image puts, do a dry-run re-plan with the same spec. Bytes are
431
+ // now in CAS (the commit just landed) AND variant rows exist in the
432
+ // DB. The new plan response surfaces the variants. Dry-run keeps it
433
+ // cheap: no new plan_id or operation_id rows are created.
434
+ //
435
+ // Best-effort: a re-plan failure shouldn't fail the apply. The bytes
436
+ // are committed, the release is live, and the variant fields are
437
+ // strictly additive — leaving them empty when the recheck errors is
438
+ // worse than failing the apply.
439
+ const hasImagePut = spec.assets?.put?.some((entry) => {
440
+ const ct = ("content_type" in entry && typeof entry.content_type === "string")
441
+ ? entry.content_type
442
+ : "";
443
+ return ct.startsWith("image/");
444
+ });
445
+ if (hasImagePut) {
446
+ try {
447
+ const { plan: recheck } = await planInternal(client, spec, undefined, true);
448
+ if (recheck.asset_entries && recheck.asset_entries.length > 0) {
449
+ for (const recheckEntry of recheck.asset_entries) {
450
+ const existing = result.assets.byKey[recheckEntry.key];
451
+ if (!existing)
452
+ continue;
453
+ const ref = recheckEntry.asset_ref;
454
+ if (ref.width_px !== undefined)
455
+ existing.width_px = ref.width_px;
456
+ if (ref.height_px !== undefined)
457
+ existing.height_px = ref.height_px;
458
+ if (ref.blurhash !== undefined)
459
+ existing.blurhash = ref.blurhash;
460
+ if (ref.variant_spec_version !== undefined) {
461
+ existing.variant_spec_version = ref.variant_spec_version;
462
+ }
463
+ if (ref.display_url !== undefined)
464
+ existing.display_url = ref.display_url;
465
+ if (ref.display_immutable_url !== undefined) {
466
+ existing.display_immutable_url = ref.display_immutable_url;
467
+ }
468
+ if (ref.variants !== undefined)
469
+ existing.variants = ref.variants;
470
+ }
471
+ }
472
+ }
473
+ catch {
474
+ // Best-effort: leave variant fields unpopulated rather than
475
+ // failing a successful apply. Consumers can re-call
476
+ // `r.assets.put` with the same bytes (dedup) to trigger the
477
+ // plan-time surfacing if variants are missing.
478
+ }
479
+ }
396
480
  }
397
481
  return result;
398
482
  }
@@ -873,6 +957,7 @@ async function uploadMissing(client, projectId, presence, byteReaders, emit) {
873
957
  label: reader?.label ?? p.sha256,
874
958
  sha256: p.sha256,
875
959
  reason: "present",
960
+ ...(reader?.slice ? { slice_kind: reader.slice } : {}),
876
961
  });
877
962
  }
878
963
  // Filter to refs the gateway reported as missing for this project.
@@ -925,6 +1010,7 @@ async function uploadMissing(client, projectId, presence, byteReaders, emit) {
925
1010
  sha256: session.sha256,
926
1011
  done,
927
1012
  total,
1013
+ ...(reader.slice ? { slice_kind: reader.slice } : {}),
928
1014
  });
929
1015
  }
930
1016
  // Plan-level finalize — marks the plan committed in the deploy_plans
@@ -1040,7 +1126,7 @@ async function putToS3(fetchFn, url, body, checksumBase64, partNumber) {
1040
1126
  }
1041
1127
  return res.headers.get("etag");
1042
1128
  }
1043
- async function pollUntilReady(client, commit, diff, warnings, emit, projectId) {
1129
+ async function pollUntilReady(client, commit, diff, warnings, emit, projectId, sliceKinds = []) {
1044
1130
  if (commit.status === "failed") {
1045
1131
  throw translateGatewayError(commit.error, "commit", null, commit.operation_id);
1046
1132
  }
@@ -1054,7 +1140,12 @@ async function pollUntilReady(client, commit, diff, warnings, emit, projectId) {
1054
1140
  context: "committing deploy",
1055
1141
  });
1056
1142
  }
1057
- emit({ type: "ready", releaseId: commit.release_id, urls: commit.urls });
1143
+ emit({
1144
+ type: "ready",
1145
+ releaseId: commit.release_id,
1146
+ urls: commit.urls,
1147
+ ...(sliceKinds.length > 0 ? { slice_kinds: sliceKinds } : {}),
1148
+ });
1058
1149
  return {
1059
1150
  release_id: commit.release_id,
1060
1151
  operation_id: commit.operation_id,
@@ -1065,9 +1156,16 @@ async function pollUntilReady(client, commit, diff, warnings, emit, projectId) {
1065
1156
  }
1066
1157
  const opHeaders = projectId ? await apikeyHeaders(client, projectId) : {};
1067
1158
  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) {
1159
+ return await pollSnapshotUntilReady(client, initialSnapshot, diff, warnings, emit, projectId, sliceKinds);
1160
+ }
1161
+ async function pollSnapshotUntilReady(client, initial, diff, warnings, emit, projectId, sliceKinds = []) {
1162
+ // Helper to spread slice_kinds onto every commit.phase / ready emit so
1163
+ // agents grouping per-slice telemetry don't need to track the apply's
1164
+ // spec separately. The low-level commit/upload helpers that pass no
1165
+ // sliceKinds get an empty array → field is omitted from events.
1166
+ const withSliceKinds = (ev) => sliceKinds.length > 0
1167
+ ? { ...ev, slice_kinds: sliceKinds }
1168
+ : ev;
1071
1169
  let snapshot = initial;
1072
1170
  const opHeaders = projectId ? await apikeyHeaders(client, projectId) : {};
1073
1171
  let lastPhaseEmitted = null;
@@ -1107,7 +1205,7 @@ async function pollSnapshotUntilReady(client, initial, diff, warnings, emit, pro
1107
1205
  return;
1108
1206
  if (nextPhase !== undefined && prev.phase === nextPhase)
1109
1207
  return;
1110
- emit({ type: "commit.phase", phase: prev.phase, status: closeStatus });
1208
+ emit(withSliceKinds({ type: "commit.phase", phase: prev.phase, status: closeStatus }));
1111
1209
  };
1112
1210
  while (true) {
1113
1211
  if (lastPhaseEmitted !== snapshot.status) {
@@ -1115,7 +1213,7 @@ async function pollSnapshotUntilReady(client, initial, diff, warnings, emit, pro
1115
1213
  if (ev) {
1116
1214
  if (ev.type === "commit.phase")
1117
1215
  closePreviousPhase(ev.phase);
1118
- emit(ev);
1216
+ emit(withSliceKinds(ev));
1119
1217
  lastPhaseEmitted = snapshot.status;
1120
1218
  }
1121
1219
  // If `ev` is null (status not in the phase map, e.g. "ready"), leave
@@ -1133,7 +1231,7 @@ async function pollSnapshotUntilReady(client, initial, diff, warnings, emit, pro
1133
1231
  });
1134
1232
  }
1135
1233
  closePreviousPhase();
1136
- emit({ type: "ready", releaseId: snapshot.release_id, urls: snapshot.urls });
1234
+ emit(withSliceKinds({ type: "ready", releaseId: snapshot.release_id, urls: snapshot.urls }));
1137
1235
  return {
1138
1236
  release_id: snapshot.release_id,
1139
1237
  operation_id: snapshot.operation_id,
@@ -1215,12 +1313,18 @@ async function startInternal(client, spec, opts) {
1215
1313
  reason: plan.payment_required.reason,
1216
1314
  });
1217
1315
  }
1316
+ const sliceKinds = deriveSliceKinds(spec);
1218
1317
  const resultPromise = (async () => {
1219
1318
  await uploadMissing(client, spec.project, plan.missing_content, byteReaders, emit);
1220
- emit({ type: "commit.phase", phase: "validate", status: "started" });
1319
+ emit({
1320
+ type: "commit.phase",
1321
+ phase: "validate",
1322
+ status: "started",
1323
+ ...(sliceKinds.length > 0 ? { slice_kinds: sliceKinds } : {}),
1324
+ });
1221
1325
  const { planId } = requirePersistedPlan(plan, "starting deploy");
1222
1326
  const commit = await commitInternal(client, planId, opts.idempotencyKey);
1223
- return await pollUntilReady(client, commit, plan.diff, plan.warnings, emit, spec.project);
1327
+ return await pollUntilReady(client, commit, plan.diff, plan.warnings, emit, spec.project, sliceKinds);
1224
1328
  })();
1225
1329
  // Avoid an unhandled-rejection at construction time. Consumers must call
1226
1330
  // .result() to actually observe the error.
@@ -2033,7 +2137,13 @@ function invalidSecretSpec(message, resource) {
2033
2137
  }
2034
2138
  async function normalizeReleaseSpec(client, spec) {
2035
2139
  const byteReaders = new Map();
2036
- const remember = (resolved) => {
2140
+ // Slice-tagged `remember`. Each slice category creates its own remember
2141
+ // closure so the registered reader carries `reader.slice = "release" |
2142
+ // "asset"`. On cross-kind dedup (same SHA from both a release-bound
2143
+ // slice and the asset slice) the value escalates to `"mixed"`. This
2144
+ // value surfaces on `content.upload.*` events so agents can group
2145
+ // upload telemetry by slice kind.
2146
+ const makeRemember = (slice) => (resolved) => {
2037
2147
  // Propagate the final content-type onto the deferred reader so the CAS
2038
2148
  // upload session can declare it correctly. Callers may set
2039
2149
  // ref.contentType *after* resolveContent returns (e.g. normalizeFileSet
@@ -2042,18 +2152,25 @@ async function normalizeReleaseSpec(client, spec) {
2042
2152
  resolved.reader.contentType = resolved.ref.contentType;
2043
2153
  }
2044
2154
  if (!byteReaders.has(resolved.ref.sha256)) {
2155
+ resolved.reader.slice = slice;
2045
2156
  byteReaders.set(resolved.ref.sha256, resolved.reader);
2046
2157
  }
2047
2158
  else {
2048
2159
  // Already remembered — but if the existing reader has no contentType
2049
- // and we just learned it, fill it in.
2160
+ // and we just learned it, fill it in. Also escalate slice tag when
2161
+ // the second registration comes from a different kind.
2050
2162
  const existing = byteReaders.get(resolved.ref.sha256);
2051
2163
  if (resolved.ref.contentType && !existing.contentType) {
2052
2164
  existing.contentType = resolved.ref.contentType;
2053
2165
  }
2166
+ if (existing.slice && existing.slice !== slice && existing.slice !== "mixed") {
2167
+ existing.slice = "mixed";
2168
+ }
2054
2169
  }
2055
2170
  return resolved.ref;
2056
2171
  };
2172
+ const rememberRelease = makeRemember("release");
2173
+ const rememberAsset = makeRemember("asset");
2057
2174
  const normalized = { project: spec.project };
2058
2175
  if (spec.base)
2059
2176
  normalized.base = spec.base;
@@ -2074,19 +2191,19 @@ async function normalizeReleaseSpec(client, spec) {
2074
2191
  db.zero_downtime = spec.database.zero_downtime;
2075
2192
  }
2076
2193
  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)));
2194
+ db.migrations = await Promise.all(spec.database.migrations.map(async (m) => normalizeMigration(client, spec.project, m, rememberRelease)));
2078
2195
  }
2079
2196
  normalized.database = db;
2080
2197
  }
2081
2198
  if (spec.functions) {
2082
2199
  const fns = {};
2083
2200
  if (spec.functions.replace) {
2084
- fns.replace = await normalizeFunctionMap(spec.functions.replace, remember);
2201
+ fns.replace = await normalizeFunctionMap(spec.functions.replace, rememberRelease);
2085
2202
  }
2086
2203
  if (spec.functions.patch) {
2087
2204
  fns.patch = {};
2088
2205
  if (spec.functions.patch.set) {
2089
- fns.patch.set = await normalizeFunctionMap(spec.functions.patch.set, remember);
2206
+ fns.patch.set = await normalizeFunctionMap(spec.functions.patch.set, rememberRelease);
2090
2207
  }
2091
2208
  if (spec.functions.patch.delete)
2092
2209
  fns.patch.delete = spec.functions.patch.delete;
@@ -2096,7 +2213,7 @@ async function normalizeReleaseSpec(client, spec) {
2096
2213
  if (spec.site) {
2097
2214
  const publicPaths = "public_paths" in spec.site ? spec.site.public_paths : undefined;
2098
2215
  if ("replace" in spec.site && spec.site.replace) {
2099
- const map = await normalizeFileSet(spec.site.replace, remember);
2216
+ const map = await normalizeFileSet(spec.site.replace, rememberRelease);
2100
2217
  normalized.site = {
2101
2218
  replace: map,
2102
2219
  ...(publicPaths ? { public_paths: publicPaths } : {}),
@@ -2105,7 +2222,7 @@ async function normalizeReleaseSpec(client, spec) {
2105
2222
  else if ("patch" in spec.site && spec.site.patch) {
2106
2223
  const patch = {};
2107
2224
  if (spec.site.patch.put) {
2108
- patch.put = await normalizeFileSet(spec.site.patch.put, remember);
2225
+ patch.put = await normalizeFileSet(spec.site.patch.put, rememberRelease);
2109
2226
  }
2110
2227
  if (spec.site.patch.delete)
2111
2228
  patch.delete = spec.site.patch.delete;
@@ -2123,7 +2240,7 @@ async function normalizeReleaseSpec(client, spec) {
2123
2240
  // register a byte-reader; emit the wire-shaped `AssetPutEntry[]`.
2124
2241
  // Cross-kind SHA dedup is automatic via the shared `byteReaders` map.
2125
2242
  if (spec.assets) {
2126
- normalized.assets = await normalizeAssetSlice(spec.assets, remember);
2243
+ normalized.assets = await normalizeAssetSlice(spec.assets, rememberAsset);
2127
2244
  }
2128
2245
  return { normalized, byteReaders };
2129
2246
  }
@@ -2186,20 +2303,41 @@ function buildAssetManifestFromPlanEntries(entries) {
2186
2303
  let bytesUploaded = 0;
2187
2304
  let bytesReused = 0;
2188
2305
  for (const entry of entries) {
2306
+ const ref = entry.asset_ref;
2189
2307
  const e = {
2190
2308
  key: entry.key,
2191
2309
  sha256: entry.sha256,
2192
2310
  size_bytes: entry.size_bytes,
2193
2311
  content_type: entry.content_type,
2194
2312
  visibility: entry.visibility,
2195
- url: entry.asset_ref.url,
2196
- immutable_url: entry.asset_ref.immutable_url,
2197
- cdn_url: entry.asset_ref.cdn_url,
2198
- cdn_immutable_url: entry.asset_ref.cdn_immutable_url,
2199
- sri: entry.asset_ref.sri,
2200
- etag: entry.asset_ref.etag,
2201
- content_digest: entry.asset_ref.content_digest,
2313
+ url: ref.url,
2314
+ immutable_url: ref.immutable_url,
2315
+ cdn_url: ref.cdn_url,
2316
+ cdn_immutable_url: ref.cdn_immutable_url,
2317
+ sri: ref.sri,
2318
+ etag: ref.etag,
2319
+ content_digest: ref.content_digest,
2202
2320
  };
2321
+ // v1.49+ image-variant pass-through. Only emitted when the gateway
2322
+ // returned them (image MIMEs ≥320×320; HEIC/HEIF sources also include
2323
+ // `display_jpeg`). Pre-v1.49 plan responses omit these fields entirely
2324
+ // and the manifest entry stays bytewise-identical to before.
2325
+ if (ref.width_px !== undefined)
2326
+ e.width_px = ref.width_px;
2327
+ if (ref.height_px !== undefined)
2328
+ e.height_px = ref.height_px;
2329
+ if (ref.blurhash !== undefined)
2330
+ e.blurhash = ref.blurhash;
2331
+ if (ref.variant_spec_version !== undefined) {
2332
+ e.variant_spec_version = ref.variant_spec_version;
2333
+ }
2334
+ if (ref.display_url !== undefined)
2335
+ e.display_url = ref.display_url;
2336
+ if (ref.display_immutable_url !== undefined) {
2337
+ e.display_immutable_url = ref.display_immutable_url;
2338
+ }
2339
+ if (ref.variants !== undefined)
2340
+ e.variants = ref.variants;
2203
2341
  list.push(e);
2204
2342
  byKey[entry.key] = e;
2205
2343
  manifest[entry.key] = e;