pixelkiln 0.12.0 → 0.13.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.
package/dist/cli.js CHANGED
@@ -1403,6 +1403,8 @@ var StyleImageSchema = z2.object({
1403
1403
  path: z2.string()
1404
1404
  });
1405
1405
  var StyleSchema = z2.object({
1406
+ /** Generation backend for this style. Omit to inherit the manifest default. */
1407
+ provider: z2.string().min(1).optional(),
1406
1408
  // `map` is the default because it is 20-40x cheaper and correct for any
1407
1409
  // asset that is not going to be rotated or animated.
1408
1410
  generator: GeneratorSchema.default("map"),
@@ -1963,6 +1965,7 @@ function firstUrl(urls) {
1963
1965
 
1964
1966
  // src/providers/retrodiffusion.ts
1965
1967
  var DEFAULT_BASE_URL2 = "https://api.retrodiffusion.ai/v1";
1968
+ var SOURCE_PROTOCOL = "retrodiffusion:";
1966
1969
  var RetroDiffusionClient = class {
1967
1970
  constructor(token, baseUrl = DEFAULT_BASE_URL2, request = fetch) {
1968
1971
  this.token = token;
@@ -2042,7 +2045,7 @@ var RetroDiffusionProvider = class _RetroDiffusionProvider {
2042
2045
  return new _RetroDiffusionProvider(new RetroDiffusionClient(void 0));
2043
2046
  }
2044
2047
  static forDownloads() {
2045
- return _RetroDiffusionProvider.forOffline();
2048
+ return new _RetroDiffusionProvider(new RetroDiffusionClient(process.env.RD_API_KEY));
2046
2049
  }
2047
2050
  supports(generator) {
2048
2051
  return generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "animation";
@@ -2148,11 +2151,15 @@ var RetroDiffusionProvider = class _RetroDiffusionProvider {
2148
2151
  const urls = sources.map((source) => source.url);
2149
2152
  if (!urls.length) return { status: "failed", error: "Retro Diffusion task returned no images" };
2150
2153
  if (urls.length > 1) return { status: "review", candidateUrls: urls };
2154
+ const durableSources = sources.map((source, index) => ({
2155
+ ...source,
2156
+ url: sourceRef2(jobId, index, mediaType)
2157
+ }));
2151
2158
  return {
2152
2159
  status: "ready",
2153
2160
  objectId: `${jobId}#0`,
2154
- sourceUrl: urls[0],
2155
- sources,
2161
+ sourceUrl: durableSources[0].url,
2162
+ sources: durableSources,
2156
2163
  metadata: {
2157
2164
  balanceCost: task.result?.balance_cost ?? null,
2158
2165
  remainingBalance: task.result?.remaining_balance ?? null,
@@ -2171,9 +2178,26 @@ var RetroDiffusionProvider = class _RetroDiffusionProvider {
2171
2178
  if (task.status !== "succeeded") throw new Error(`Retro Diffusion task ${jobId} is not ready`);
2172
2179
  const url = resultSources(task.result, MediaType.PNG)[index]?.url;
2173
2180
  if (!url) throw new Error(`Retro Diffusion task ${jobId} has no candidate at index ${index}`);
2174
- return { objectId: `${jobId}#${index}`, sourceUrl: url };
2181
+ return { objectId: `${jobId}#${index}`, sourceUrl: sourceRef2(jobId, index, MediaType.PNG) };
2175
2182
  }
2176
2183
  async download(url) {
2184
+ if (url.startsWith(`${SOURCE_PROTOCOL}//`)) {
2185
+ const reference = parseSourceRef(url);
2186
+ const task = await this.client.task(reference.jobId);
2187
+ if (task.status !== "succeeded") {
2188
+ throw new Error(`Retro Diffusion task ${reference.jobId} is not ready`);
2189
+ }
2190
+ const source = resultSources(task.result, reference.mediaType)[reference.index]?.url;
2191
+ if (!source) {
2192
+ throw new Error(
2193
+ `Retro Diffusion task ${reference.jobId} has no output at index ${reference.index}`
2194
+ );
2195
+ }
2196
+ return this.downloadResolved(source);
2197
+ }
2198
+ return this.downloadResolved(url);
2199
+ }
2200
+ async downloadResolved(url) {
2177
2201
  const data = /^data:[^;]+;base64,(.+)$/.exec(url)?.[1];
2178
2202
  if (data) return Buffer.from(data, "base64");
2179
2203
  const response = await fetch(url);
@@ -2184,6 +2208,23 @@ var RetroDiffusionProvider = class _RetroDiffusionProvider {
2184
2208
  return { unit: "usd", remaining: await this.client.balance() };
2185
2209
  }
2186
2210
  };
2211
+ function sourceRef2(jobId, index, mediaType) {
2212
+ const url = new URL("retrodiffusion://output");
2213
+ url.searchParams.set("job", jobId);
2214
+ url.searchParams.set("index", String(index));
2215
+ url.searchParams.set("mediaType", mediaType);
2216
+ return url.href;
2217
+ }
2218
+ function parseSourceRef(value) {
2219
+ const url = new URL(value);
2220
+ const jobId = url.searchParams.get("job");
2221
+ const index = Number(url.searchParams.get("index"));
2222
+ const rawMediaType = url.searchParams.get("mediaType");
2223
+ if (url.protocol !== SOURCE_PROTOCOL || url.hostname !== "output" || !jobId || !Number.isInteger(index) || index < 0 || rawMediaType !== MediaType.PNG && rawMediaType !== MediaType.GIF) {
2224
+ throw new Error("Invalid durable Retro Diffusion source reference");
2225
+ }
2226
+ return { jobId, index, mediaType: rawMediaType };
2227
+ }
2187
2228
  function retroOptions(spec) {
2188
2229
  return spec.providerOptions;
2189
2230
  }
@@ -2353,7 +2394,17 @@ ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
2353
2394
  }
2354
2395
  async function resolveSpecs(loaded, filter) {
2355
2396
  const { manifest, root } = loaded;
2356
- const activeProvider = filter?.provider ?? createProvider(manifest.provider, "offline");
2397
+ const providerOverride = filter?.provider;
2398
+ const providers = /* @__PURE__ */ new Map();
2399
+ const providerFor = (id) => {
2400
+ if (providerOverride) return providerOverride;
2401
+ let provider = providers.get(id);
2402
+ if (!provider) {
2403
+ provider = createProvider(id, "offline");
2404
+ providers.set(id, provider);
2405
+ }
2406
+ return provider;
2407
+ };
2357
2408
  const specs = [];
2358
2409
  const styleIds = Object.keys(manifest.styles).filter(
2359
2410
  (id) => !filter?.styles?.length || filter.styles.includes(id)
@@ -2389,6 +2440,7 @@ async function resolveSpecs(loaded, filter) {
2389
2440
  }
2390
2441
  for (const styleId of styleIds) {
2391
2442
  const style = manifest.styles[styleId];
2443
+ const activeProvider = providerFor(style.provider ?? manifest.provider);
2392
2444
  const rawProviderOptions = style.providerOptions[activeProvider.id] ?? {};
2393
2445
  const optionResolution = activeProvider.resolveOptions ? await activeProvider.resolveOptions(rawProviderOptions, { root, styleId }) : { options: rawProviderOptions };
2394
2446
  const providerOptions = optionResolution.options;
@@ -3132,13 +3184,39 @@ async function buildPlan(specs, lock, opts = {}) {
3132
3184
  items.push({ spec, key, state, reason });
3133
3185
  }
3134
3186
  const actionable = items.filter((i) => i.state === "missing" || i.state === "stale" || i.state === "failed");
3135
- const units = new Set(actionable.map((item) => item.spec.costUnit ?? "generations"));
3136
- if (units.size > 1) throw new Error("A single plan cannot combine incompatible provider cost units");
3187
+ const groupsByProvider = /* @__PURE__ */ new Map();
3188
+ for (const item of actionable) {
3189
+ const provider = item.spec.provider;
3190
+ const costUnit = item.spec.costUnit ?? "generations";
3191
+ const group = groupsByProvider.get(provider);
3192
+ if (group && group.costUnit !== costUnit) {
3193
+ throw new Error(
3194
+ `Provider "${provider}" returned mixed cost units (${group.costUnit}, ${costUnit})`
3195
+ );
3196
+ }
3197
+ if (group) {
3198
+ group.actionable.push(item);
3199
+ group.cost += item.spec.cost;
3200
+ group.candidates += item.spec.candidates;
3201
+ } else {
3202
+ groupsByProvider.set(provider, {
3203
+ provider,
3204
+ costUnit,
3205
+ cost: item.spec.cost,
3206
+ candidates: item.spec.candidates,
3207
+ actionable: [item]
3208
+ });
3209
+ }
3210
+ }
3211
+ const groups = [...groupsByProvider.values()].sort((a, b) => a.provider.localeCompare(b.provider));
3212
+ const fallbackUnits = new Set(specs.map((spec) => spec.costUnit));
3213
+ const single = groups.length === 1 ? groups[0] : null;
3137
3214
  return {
3138
3215
  items,
3139
3216
  actionable,
3140
- cost: actionable.reduce((sum, i) => sum + i.spec.cost, 0),
3141
- costUnit: actionable[0]?.spec.costUnit ?? specs[0]?.costUnit ?? (specs.length ? "generations" : "free"),
3217
+ groups,
3218
+ cost: single?.cost ?? (groups.length ? null : 0),
3219
+ costUnit: single?.costUnit ?? (groups.length || fallbackUnits.size > 1 ? null : fallbackUnits.values().next().value ?? (specs.length ? "generations" : "free")),
3142
3220
  candidates: actionable.reduce((sum, i) => sum + i.spec.candidates, 0)
3143
3221
  };
3144
3222
  }
@@ -3245,6 +3323,10 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
3245
3323
  submittedAt: (/* @__PURE__ */ new Date()).toISOString(),
3246
3324
  cost: estimate.amount,
3247
3325
  costUnit: estimate.unit,
3326
+ // Persist routing before the request starts. If the process stops while
3327
+ // the provider is accepting work, a resumed run must not fall back to
3328
+ // PixelLab (the legacy lock default) for another provider's entry.
3329
+ provider: provider.id,
3248
3330
  downloadedAt: null
3249
3331
  });
3250
3332
  await saveLock(lockPath, lock);
@@ -3257,8 +3339,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
3257
3339
  // A multi-candidate generator routes through review; record the parent
3258
3340
  // so `pick` knows where to look.
3259
3341
  reviewObjectId: estimate.candidates > 1 && !spec.tileFeature ? jobId : null,
3260
- status: "processing",
3261
- provider: provider.id
3342
+ status: "processing"
3262
3343
  });
3263
3344
  inFlight.set(jobId, spec);
3264
3345
  submitted++;
@@ -3286,9 +3367,10 @@ async function poll(provider, lock, lockPath, opts = {}) {
3286
3367
  });
3287
3368
  const started = Date.now();
3288
3369
  const specByKey = new Map((opts.specs ?? []).map((s) => [lockKey(s.styleId, s.assetId), s]));
3370
+ const selectedKeys = opts.specs ? new Set(specByKey.keys()) : null;
3289
3371
  const result = { review: 0, completed: 0, failed: 0, stillRunning: 0 };
3290
3372
  const pending = () => Object.entries(lock.entries).filter(
3291
- ([, e]) => e.jobId && (e.status === "pending" || e.status === "processing")
3373
+ ([key, e]) => e.provider === provider.id && (!selectedKeys || selectedKeys.has(key)) && e.jobId && (e.status === "pending" || e.status === "processing")
3292
3374
  );
3293
3375
  while (pending().length > 0) {
3294
3376
  if (Date.now() - started > timeout) {
@@ -3343,6 +3425,45 @@ async function poll(provider, lock, lockPath, opts = {}) {
3343
3425
  import { existsSync as existsSync6 } from "fs";
3344
3426
  import { mkdir as mkdir2, readFile as readFile4, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
3345
3427
  import path9 from "path";
3428
+
3429
+ // src/source-url.ts
3430
+ var SENSITIVE_QUERY_KEYS = /* @__PURE__ */ new Set([
3431
+ "access_token",
3432
+ "apikey",
3433
+ "api_key",
3434
+ "auth",
3435
+ "authorization",
3436
+ "awsaccesskeyid",
3437
+ "googleaccessid",
3438
+ "key",
3439
+ "sig"
3440
+ ]);
3441
+ function isSensitiveSourceUrl(value) {
3442
+ let parsed;
3443
+ try {
3444
+ parsed = new URL(value);
3445
+ } catch {
3446
+ return false;
3447
+ }
3448
+ if (parsed.username || parsed.password) return true;
3449
+ for (const key of parsed.searchParams.keys()) {
3450
+ const normalized = key.toLowerCase();
3451
+ if (SENSITIVE_QUERY_KEYS.has(normalized) || normalized.includes("credential") || normalized.includes("signature") || normalized.includes("security-token") || normalized.includes("token")) return true;
3452
+ }
3453
+ return false;
3454
+ }
3455
+ function shouldPersistSourceUrl(value) {
3456
+ let parsed;
3457
+ try {
3458
+ parsed = new URL(value);
3459
+ } catch {
3460
+ return true;
3461
+ }
3462
+ if (parsed.protocol === "data:" || parsed.protocol === "file:") return false;
3463
+ return !isSensitiveSourceUrl(value);
3464
+ }
3465
+
3466
+ // src/pipeline/fetch.ts
3346
3467
  async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3347
3468
  const log2 = opts.onProgress ?? (() => {
3348
3469
  });
@@ -3351,6 +3472,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3351
3472
  normalizeLockOutputPaths(lock, specs);
3352
3473
  const cacheDir = opts.cacheDir === false ? null : path9.resolve(opts.cacheDir ?? path9.join(path9.dirname(lockPath), ".pixelkiln", "cache"));
3353
3474
  const pending = Object.entries(lock.entries).filter(([key, e]) => {
3475
+ if (e.provider !== provider.id || !specByKey.has(key)) return false;
3354
3476
  if (e.status === "selected" || e.status === "download-failed") return true;
3355
3477
  if (!opts.repair || e.status !== "downloaded") return false;
3356
3478
  const spec = specByKey.get(key);
@@ -3444,15 +3566,16 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3444
3566
  await rename2(tmp, target);
3445
3567
  log2(` wrote ${path9.relative(process.cwd(), target)}`);
3446
3568
  }
3569
+ const persistentSources = sources.filter((source) => shouldPersistSourceUrl(source.url));
3447
3570
  upsert(lock, key, {
3448
3571
  status: "downloaded",
3449
3572
  provider: provider.id,
3450
3573
  outputs,
3451
- // Inline providers can hand the pipeline a machine-local temp file.
3452
- // Once its bytes are in the durable content cache, retaining that
3453
- // path makes a committed lockfile non-portable and falsely suggests
3454
- // the source still exists on another checkout.
3455
- ...cacheDir && sources.every((source) => source.url.startsWith("file://")) ? { sourceUrl: null, sourceUrls: [] } : {},
3574
+ // A finished lock may be committed. Inline bytes, machine-local
3575
+ // paths, and credential-bearing signed URLs are useful while a
3576
+ // download is retryable but must not survive successful ingestion.
3577
+ sourceUrl: persistentSources[0]?.url ?? null,
3578
+ sourceUrls: persistentSources,
3456
3579
  downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
3457
3580
  error: null
3458
3581
  });
@@ -3516,7 +3639,7 @@ async function pushTags(provider, specs, lock, opts = {}) {
3516
3639
  let tagged = 0;
3517
3640
  for (const [key, entry] of Object.entries(lock.entries)) {
3518
3641
  const spec = specByKey.get(key);
3519
- if (!spec || !entry.objectId) continue;
3642
+ if (!spec || entry.provider !== provider.id || !entry.objectId) continue;
3520
3643
  if (entry.generator === "pixflux") continue;
3521
3644
  if (!provider.setTags) return tagged;
3522
3645
  try {
@@ -3622,30 +3745,51 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
3622
3745
  unsettled ? "warning" : "ok",
3623
3746
  unsettled ? `${unsettled} spec(s) are not current: ` + Object.entries(counts).filter(([, n]) => n > 0).map(([state, n]) => `${n} ${state}`).join(", ") : "every resolved spec is current"
3624
3747
  );
3625
- if (opts.offline) {
3626
- add("provider", "warning", "provider connectivity skipped (--dry-run)");
3627
- } else if (!opts.provider) {
3628
- add(
3629
- "provider",
3630
- "error",
3631
- opts.apiKeyPresent === false ? `${opts.credentialEnv ?? "PIXELLAB_API_KEY"} is not configured` : "provider is not configured"
3632
- );
3633
- } else if (!opts.provider.balance && !opts.provider.checkConnection) {
3634
- add("provider", "ok", `${opts.provider.id} configured; connectivity check unavailable`);
3635
- } else if (!opts.provider.balance) {
3636
- try {
3637
- await opts.provider.checkConnection();
3638
- add("provider", "ok", `${opts.provider.id} reachable; balance reporting unavailable`);
3639
- } catch (err) {
3640
- add("provider", "error", `provider connectivity failed: ${err instanceof Error ? err.message : String(err)}`);
3641
- }
3642
- } else {
3643
- const balanceFn = opts.provider.balance.bind(opts.provider);
3644
- try {
3645
- const balance = await balanceFn();
3646
- add("provider", "ok", `${opts.provider.id} reachable; ${balance.remaining} ${balance.unit} remaining`);
3647
- } catch (err) {
3648
- add("provider", "error", `provider connectivity failed: ${err instanceof Error ? err.message : String(err)}`);
3748
+ const legacyTarget = !opts.providers;
3749
+ const targets = opts.providers ?? [{
3750
+ id: opts.provider?.id ?? "provider",
3751
+ provider: opts.provider,
3752
+ apiKeyPresent: opts.apiKeyPresent ?? Boolean(opts.provider),
3753
+ credentialEnv: opts.credentialEnv
3754
+ }];
3755
+ for (const target of targets) {
3756
+ const checkId = targets.length === 1 ? "provider" : `provider:${target.id}`;
3757
+ if (opts.offline) {
3758
+ add(checkId, "warning", `${target.id} connectivity skipped (--dry-run)`);
3759
+ } else if (!target.provider) {
3760
+ add(
3761
+ checkId,
3762
+ "error",
3763
+ !target.apiKeyPresent ? `${target.credentialEnv ?? (legacyTarget ? "PIXELLAB_API_KEY" : `${target.id} credential`)} is not configured` : `${target.id} is not configured`
3764
+ );
3765
+ } else if (!target.provider.balance && !target.provider.checkConnection) {
3766
+ add(checkId, "ok", `${target.id} configured; connectivity check unavailable`);
3767
+ } else if (!target.provider.balance) {
3768
+ try {
3769
+ await target.provider.checkConnection();
3770
+ add(checkId, "ok", `${target.id} reachable; balance reporting unavailable`);
3771
+ } catch (err) {
3772
+ add(
3773
+ checkId,
3774
+ "error",
3775
+ `${legacyTarget ? "provider" : target.id} connectivity failed: ${err instanceof Error ? err.message : String(err)}`
3776
+ );
3777
+ }
3778
+ } else {
3779
+ try {
3780
+ const balance = await target.provider.balance();
3781
+ add(
3782
+ checkId,
3783
+ "ok",
3784
+ `${target.id} reachable; ${balance.remaining} ${balance.unit} remaining`
3785
+ );
3786
+ } catch (err) {
3787
+ add(
3788
+ checkId,
3789
+ "error",
3790
+ `${legacyTarget ? "provider" : target.id} connectivity failed: ${err instanceof Error ? err.message : String(err)}`
3791
+ );
3792
+ }
3649
3793
  }
3650
3794
  }
3651
3795
  return { ok: !checks.some((c) => c.level === "error"), checks };
@@ -3797,6 +3941,7 @@ async function adopt(provider, specs, lock, lockPath, opts = {}) {
3797
3941
  ambiguous.push(`${key}: already mapped to ${existing.objectId}, also matches ${obj.id}`);
3798
3942
  continue;
3799
3943
  }
3944
+ const durableSource = shouldPersistSourceUrl(url) ? url : null;
3800
3945
  upsert(lock, key, {
3801
3946
  styleId: spec.styleId,
3802
3947
  assetId: spec.assetId,
@@ -3813,7 +3958,8 @@ async function adopt(provider, specs, lock, lockPath, opts = {}) {
3813
3958
  candidateIndex: null,
3814
3959
  status: "downloaded",
3815
3960
  error: null,
3816
- sourceUrl: url,
3961
+ sourceUrl: durableSource,
3962
+ sourceUrls: durableSource ? [{ url: durableSource }] : [],
3817
3963
  outputs: [{ path: portableOutputPath(spec.outFile, spec.root), sha256: hash }],
3818
3964
  submittedAt: obj.createdAt,
3819
3965
  downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -3863,9 +4009,12 @@ async function tagAdopted(provider, specs, lock, opts = {}) {
3863
4009
  async function writePromptsBack(manifestPath, lock, opts = {}) {
3864
4010
  const log2 = opts.onProgress ?? (() => {
3865
4011
  });
4012
+ const selectedAssets = opts.assetIds ? new Set(opts.assetIds) : null;
3866
4013
  const raw = JSON.parse(await readFile6(manifestPath, "utf8"));
3867
4014
  const best = /* @__PURE__ */ new Map();
3868
4015
  for (const entry of Object.values(lock.entries)) {
4016
+ if (opts.provider && entry.provider !== opts.provider) continue;
4017
+ if (selectedAssets && !selectedAssets.has(entry.assetId)) continue;
3869
4018
  if (!entry.objectId || !entry.prompt) continue;
3870
4019
  const current = best.get(entry.assetId);
3871
4020
  if (!current || entry.prompt.length > current.length) best.set(entry.assetId, entry.prompt);
@@ -3873,6 +4022,7 @@ async function writePromptsBack(manifestPath, lock, opts = {}) {
3873
4022
  let filled = 0;
3874
4023
  const stillEmpty = [];
3875
4024
  for (const [id, asset] of Object.entries(raw.assets)) {
4025
+ if (selectedAssets && !selectedAssets.has(id)) continue;
3876
4026
  if (asset.prompt && asset.prompt.trim()) continue;
3877
4027
  const recovered = best.get(id);
3878
4028
  if (recovered) {
@@ -4188,8 +4338,10 @@ function serveReviewPage(opts) {
4188
4338
  async function runPicker(provider, lock, lockPath, opts = {}) {
4189
4339
  const log2 = opts.onProgress ?? (() => {
4190
4340
  });
4341
+ const selectedKeys = opts.keys ? new Set(opts.keys) : null;
4191
4342
  const groups = [];
4192
4343
  for (const [key, entry] of Object.entries(lock.entries)) {
4344
+ if (entry.provider !== provider.id || selectedKeys && !selectedKeys.has(key)) continue;
4193
4345
  if (entry.status !== "review" || !entry.reviewObjectId) continue;
4194
4346
  try {
4195
4347
  const state = await provider.poll(entry.reviewObjectId, entry.generator);
@@ -4355,7 +4507,10 @@ async function writeManifestFile(target, manifest) {
4355
4507
  import { readFile as readFile8 } from "fs/promises";
4356
4508
  import { existsSync as existsSync11 } from "fs";
4357
4509
  import path13 from "path";
4358
- async function loadClaims(lockPaths) {
4510
+ function providerClaimId(provider, objectId) {
4511
+ return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
4512
+ }
4513
+ async function loadClaims(lockPaths, opts = {}) {
4359
4514
  const claimed = /* @__PURE__ */ new Set();
4360
4515
  for (const p of lockPaths) {
4361
4516
  if (!existsSync11(p)) throw new Error(`Claim lockfile not found: ${p}`);
@@ -4366,9 +4521,13 @@ async function loadClaims(lockPaths) {
4366
4521
  throw new Error(`Claim lockfile is malformed: ${p}`);
4367
4522
  }
4368
4523
  for (const entry of Object.values(parsed.entries)) {
4369
- if (entry.objectId) claimed.add(entry.objectId);
4370
- if (entry.reviewObjectId) claimed.add(entry.reviewObjectId);
4371
- if (entry.jobId) claimed.add(entry.jobId);
4524
+ if (opts.provider && entry.provider !== opts.provider) continue;
4525
+ const add = (id) => {
4526
+ if (id) claimed.add(opts.qualify ? providerClaimId(entry.provider, id) : id);
4527
+ };
4528
+ add(entry.objectId);
4529
+ add(entry.reviewObjectId);
4530
+ add(entry.jobId);
4372
4531
  }
4373
4532
  }
4374
4533
  return claimed;
@@ -4379,7 +4538,9 @@ async function findOrphans(provider, claimed, opts = {}) {
4379
4538
  const all = [];
4380
4539
  for await (const obj of requireList(provider)()) all.push(obj);
4381
4540
  log2(` scanned ${all.length} object(s) on the account`);
4382
- const orphans = all.filter((o) => !claimed.has(o.id) && o.status === "completed" && o.previewUrl).map((o) => ({
4541
+ const orphans = all.filter(
4542
+ (o) => !claimed.has(o.id) && !claimed.has(providerClaimId(provider.id, o.id)) && o.status === "completed" && o.previewUrl
4543
+ ).map((o) => ({
4383
4544
  id: o.id,
4384
4545
  prompt: o.prompt || "(no prompt recorded)",
4385
4546
  width: o.width,
@@ -4674,7 +4835,7 @@ async function workspaceClaims(ws, dir) {
4674
4835
  lockPaths.push(lockPath);
4675
4836
  let projectClaims;
4676
4837
  try {
4677
- projectClaims = await loadClaims([lockPath]);
4838
+ projectClaims = await loadClaims([lockPath], { qualify: true });
4678
4839
  } catch (err) {
4679
4840
  throw new Error(
4680
4841
  `Project "${project.id}" lockfile is unreadable: ${err instanceof Error ? err.message : String(err)}`
@@ -4712,9 +4873,14 @@ async function workspaceStatus(ws, dir) {
4712
4873
  lock: lockPath
4713
4874
  };
4714
4875
  try {
4715
- const provider = createProvider(project.provider, "offline");
4716
4876
  const loaded = await loadManifest(manifestPath);
4717
- const specs = await resolveSpecs(loaded, { provider });
4877
+ const specs = await resolveSpecs(loaded);
4878
+ const providers = [...new Set(
4879
+ Object.values(loaded.manifest.styles).map(
4880
+ (style) => style.provider ?? loaded.manifest.provider
4881
+ )
4882
+ )].sort();
4883
+ if (!providers.length) providers.push(loaded.manifest.provider);
4718
4884
  const lock = await loadLock(lockPath);
4719
4885
  normalizeLockOutputPaths(lock, specs);
4720
4886
  const plan = await buildPlan(specs, lock);
@@ -4728,6 +4894,8 @@ async function workspaceStatus(ws, dir) {
4728
4894
  }
4729
4895
  projects.push({
4730
4896
  ...base,
4897
+ provider: providers.length === 1 ? providers[0] : project.provider,
4898
+ providers,
4731
4899
  entries: Object.keys(lock.entries).length,
4732
4900
  byState,
4733
4901
  spendByUnit: spend,
@@ -4736,6 +4904,7 @@ async function workspaceStatus(ws, dir) {
4736
4904
  } catch (err) {
4737
4905
  projects.push({
4738
4906
  ...base,
4907
+ providers: [project.provider],
4739
4908
  entries: 0,
4740
4909
  byState: emptyStateCounts(),
4741
4910
  spendByUnit: { generations: 0, usd: 0, free: 0 },
@@ -6332,6 +6501,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
6332
6501
  styles: [ctx.styleId],
6333
6502
  ...orphan.width === orphan.height ? { size: orphan.width } : { width: orphan.width, height: orphan.height }
6334
6503
  };
6504
+ const durableSource = shouldPersistSourceUrl(orphan.previewUrl) ? orphan.previewUrl : null;
6335
6505
  upsert(ctx.lock, lockKey(ctx.styleId, assetId), {
6336
6506
  styleId: ctx.styleId,
6337
6507
  assetId,
@@ -6346,7 +6516,10 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
6346
6516
  candidateIndex: null,
6347
6517
  status: "downloaded",
6348
6518
  error: null,
6349
- sourceUrl: orphan.previewUrl,
6519
+ // The bytes are already local. Do not turn a temporary signed
6520
+ // review URL into a credential-bearing committed lock entry.
6521
+ sourceUrl: durableSource,
6522
+ sourceUrls: durableSource ? [{ url: durableSource }] : [],
6350
6523
  outputs: [{
6351
6524
  path: portableOutputPath(outFile, path19.dirname(ctx.manifestPath)),
6352
6525
  sha256: sha256(buf)
@@ -6463,6 +6636,13 @@ var RecipeSchema = z5.object({
6463
6636
  digest: z5.string().regex(SHA256_RE)
6464
6637
  }).strict()
6465
6638
  }).strict().superRefine((recipe, context) => {
6639
+ if (recipe.style.provider && recipe.style.provider !== recipe.provider) {
6640
+ context.addIssue({
6641
+ code: z5.ZodIssueCode.custom,
6642
+ path: ["style", "provider"],
6643
+ message: "must match the recipe provider"
6644
+ });
6645
+ }
6466
6646
  const filePaths = recipe.files.map((file) => file.path);
6467
6647
  if (new Set(filePaths).size !== filePaths.length) {
6468
6648
  context.addIssue({ code: z5.ZodIssueCode.custom, path: ["files"], message: "duplicate file path" });
@@ -6718,13 +6898,16 @@ async function installRecipe(target, options = {}) {
6718
6898
  }
6719
6899
  }
6720
6900
  const written = await writeArtifactBundle(files);
6901
+ const renderedStyle = renderStyle(loaded.recipe.style, destination, cwd);
6721
6902
  return {
6722
6903
  recipe: loaded.recipe,
6723
6904
  destination,
6724
6905
  changed: written.changed,
6725
6906
  unchanged: written.unchanged,
6726
6907
  styleId: loaded.recipe.styleId,
6727
- style: renderStyle(loaded.recipe.style, destination, cwd)
6908
+ // A recipe snippet should remain correct when pasted into a manifest whose
6909
+ // top-level default is another provider.
6910
+ style: { ...renderedStyle, provider: renderedStyle.provider ?? loaded.recipe.provider }
6728
6911
  };
6729
6912
  }
6730
6913
 
@@ -7320,12 +7503,34 @@ function parseArgs(argv) {
7320
7503
  }
7321
7504
  }
7322
7505
  let budget;
7323
- const rawBudget = get("--budget");
7324
- if (rawBudget !== void 0) {
7325
- budget = Number(rawBudget);
7326
- if (!Number.isFinite(budget) || budget < 0) {
7506
+ const providerBudgets = /* @__PURE__ */ Object.create(null);
7507
+ const rawBudgets = rest.flatMap((token, index) => token === "--budget" ? [rest[index + 1]] : []);
7508
+ for (const rawBudget of rawBudgets) {
7509
+ const separator = rawBudget.indexOf("=");
7510
+ if (separator >= 0) {
7511
+ const providerId = rawBudget.slice(0, separator).trim();
7512
+ const rawAmount = rawBudget.slice(separator + 1).trim();
7513
+ const amount2 = Number(rawAmount);
7514
+ if (!providerId || /[=\s]/.test(providerId) || !Number.isFinite(amount2) || amount2 < 0) {
7515
+ throw new Error(
7516
+ `--budget must be a non-negative number or provider=number, got "${rawBudget}".`
7517
+ );
7518
+ }
7519
+ if (Object.hasOwn(providerBudgets, providerId)) {
7520
+ throw new Error(`--budget repeats provider "${providerId}".`);
7521
+ }
7522
+ providerBudgets[providerId] = amount2;
7523
+ continue;
7524
+ }
7525
+ const amount = Number(rawBudget);
7526
+ if (!Number.isFinite(amount) || amount < 0) {
7327
7527
  throw new Error(`--budget must be a non-negative number, got "${rawBudget}".`);
7328
7528
  }
7529
+ if (budget !== void 0) throw new Error("Only one unkeyed --budget may be passed.");
7530
+ budget = amount;
7531
+ }
7532
+ if (budget !== void 0 && Object.keys(providerBudgets).length) {
7533
+ throw new Error("Do not mix an unkeyed --budget with provider-keyed budgets.");
7329
7534
  }
7330
7535
  const manifest = get("--manifest") ?? "pixelkiln.manifest.json";
7331
7536
  const rawFormat = get("--format");
@@ -7358,6 +7563,7 @@ function parseArgs(argv) {
7358
7563
  force: rest.includes("--force"),
7359
7564
  yes: rest.includes("--yes") || rest.includes("-y"),
7360
7565
  budget,
7566
+ providerBudgets,
7361
7567
  dryRun: rest.includes("--dry-run"),
7362
7568
  all: rest.includes("--all"),
7363
7569
  json: rest.includes("--json"),
@@ -7456,7 +7662,9 @@ Options
7456
7662
  --lock <path> Default: pixelkiln.lock.json beside the manifest
7457
7663
  --style a,b Restrict to these styles
7458
7664
  --only id1,id2 Restrict to these asset ids
7459
- --budget <n> Refuse to spend more than n provider cost units
7665
+ --budget <n|provider=n> Refuse to exceed one provider ceiling; repeat keyed budgets
7666
+ for a mixed-provider run
7667
+ --provider <id> Choose the account for balance/adopt/salvage/purge in a mixed manifest
7460
7668
  --force Regenerate; also replace changed recipe files or quality baselines
7461
7669
  --dry-run Never spend; doctor also skips provider connectivity
7462
7670
  --all salvage --dry-run: list every unclaimed object, not just the first 30
@@ -7510,13 +7718,73 @@ function printPlan(plan) {
7510
7718
  totals: ${counts.ok} ok \xB7 ${counts.missing} missing \xB7 ${counts.untracked} untracked \xB7 ${counts.stale} stale \xB7 ${counts["in-flight"]} in-flight \xB7 ${counts.orphaned} orphaned \xB7 ${counts.recoverable} recoverable \xB7 ${counts.failed} failed`
7511
7719
  );
7512
7720
  if (plan.actionable.length) {
7513
- log(
7514
- ` would generate ${plan.actionable.length} asset(s) \u2014 ${formatCost(plan.costUnit, plan.cost)}, yielding ${plan.candidates} candidate/output image(s)`
7515
- );
7721
+ for (const group of plan.groups) {
7722
+ log(
7723
+ ` ${group.provider}: ${group.actionable.length} asset(s) \u2014 ${formatCost(group.costUnit, group.cost)}, ${group.candidates} candidate/output image(s)`
7724
+ );
7725
+ }
7516
7726
  } else {
7517
7727
  log(` nothing to generate`);
7518
7728
  }
7519
7729
  }
7730
+ function manifestProviderIds(manifest) {
7731
+ return [...new Set(
7732
+ Object.values(manifest.styles).map((style) => style.provider ?? manifest.provider)
7733
+ )].sort();
7734
+ }
7735
+ function specsByRecordedProvider(specs, lock) {
7736
+ const grouped = /* @__PURE__ */ new Map();
7737
+ for (const spec of specs) {
7738
+ const key = lockKey(spec.styleId, spec.assetId);
7739
+ const provider = lock.entries[key]?.provider ?? spec.provider;
7740
+ grouped.set(provider, [...grouped.get(provider) ?? [], spec]);
7741
+ }
7742
+ return grouped;
7743
+ }
7744
+ function accountProviderId(manifest, requested, command) {
7745
+ const providers = manifestProviderIds(manifest);
7746
+ if (requested) {
7747
+ if (!providers.includes(requested)) {
7748
+ throw new Error(
7749
+ `Provider "${requested}" is not used by this manifest. Used: ${providers.join(", ")}.`
7750
+ );
7751
+ }
7752
+ return requested;
7753
+ }
7754
+ if (providers.length > 1) {
7755
+ throw new Error(
7756
+ `${command} is account-scoped and this manifest uses ${providers.join(", ")}. Pass --provider <id>.`
7757
+ );
7758
+ }
7759
+ return providers[0] ?? manifest.provider;
7760
+ }
7761
+ function budgetsForPlan(plan, args) {
7762
+ const providerIds = new Set(plan.groups.map((group) => group.provider));
7763
+ const keyed = Object.entries(args.providerBudgets);
7764
+ for (const [provider] of keyed) {
7765
+ if (!providerIds.has(provider)) {
7766
+ throw new Error(
7767
+ `Budget names provider "${provider}", but this run has work only for ${[...providerIds].join(", ") || "no providers"}.`
7768
+ );
7769
+ }
7770
+ }
7771
+ if (plan.groups.length > 1 && args.budget !== void 0) {
7772
+ throw new Error(
7773
+ "A mixed-provider run needs provider-keyed budgets, for example --budget pixellab=40 --budget retrodiffusion=1.25."
7774
+ );
7775
+ }
7776
+ const out = /* @__PURE__ */ new Map();
7777
+ for (const group of plan.groups) {
7778
+ const ceiling = args.providerBudgets[group.provider];
7779
+ if (plan.groups.length > 1 && group.costUnit !== "free" && group.cost > 0 && ceiling === void 0) {
7780
+ throw new Error(
7781
+ `Mixed-provider run is missing --budget ${group.provider}=<amount> for ${formatCost(group.costUnit, group.cost)} of planned work.`
7782
+ );
7783
+ }
7784
+ out.set(group.provider, ceiling ?? (plan.groups.length === 1 ? args.budget : void 0));
7785
+ }
7786
+ return out;
7787
+ }
7520
7788
  async function confirm(question, auto) {
7521
7789
  if (auto) return true;
7522
7790
  if (!process.stdin.isTTY) {
@@ -7766,7 +8034,8 @@ async function main() {
7766
8034
  loadEnvFiles(path22.dirname(path22.resolve(args.manifest)));
7767
8035
  loadEnvFiles(process.cwd());
7768
8036
  const loaded2 = await loadManifest(args.manifest);
7769
- const p = createProvider(loaded2.manifest.provider, "online");
8037
+ const selectedProvider = accountProviderId(loaded2.manifest, args.provider, "balance");
8038
+ const p = createProvider(selectedProvider, "online");
7770
8039
  const b = await requireBalance(p)();
7771
8040
  log(` provider: ${p.id}`);
7772
8041
  log(` plan: ${b.plan ?? "n/a"}`);
@@ -7955,8 +8224,10 @@ async function main() {
7955
8224
  ${p.id} \u2014 ERROR: ${p.error}`);
7956
8225
  continue;
7957
8226
  }
7958
- log(`
7959
- ${p.id} (${p.provider}${p.account ? `, ${p.account}` : ""})`);
8227
+ log(
8228
+ `
8229
+ ${p.id} (${p.providers.join(" + ")}${p.account ? `, ${p.account}` : ""})`
8230
+ );
7960
8231
  log(` ${p.entries} lock entries`);
7961
8232
  for (const [state, n] of Object.entries(p.byState)) if (n) log(` ${state.padEnd(12)} ${n}`);
7962
8233
  for (const [unit, amount] of Object.entries(p.spendByUnit).sort()) {
@@ -8001,12 +8272,34 @@ async function main() {
8001
8272
  const envFiles = [...loadEnvFiles(manifestDir)];
8002
8273
  if (path22.resolve(process.cwd()) !== manifestDir) envFiles.push(...loadEnvFiles(process.cwd()));
8003
8274
  const loaded = await loadManifest(args.manifest);
8004
- const estimator = createProvider(loaded.manifest.provider, "offline");
8005
- const specs = await resolveSpecs(loaded, {
8275
+ let specs = await resolveSpecs(loaded, {
8006
8276
  styles: args.styles,
8007
- assets: args.assets,
8008
- provider: estimator
8277
+ assets: args.assets
8009
8278
  });
8279
+ const accountCommands = /* @__PURE__ */ new Set(["adopt", "salvage", "purge"]);
8280
+ const selectedAccountProvider = accountCommands.has(args.command) ? accountProviderId(loaded.manifest, args.provider, args.command) : void 0;
8281
+ if (args.provider && !selectedAccountProvider) {
8282
+ throw new Error(
8283
+ "--provider is only used by balance, adopt, salvage, purge, or workspace add."
8284
+ );
8285
+ }
8286
+ if (selectedAccountProvider) {
8287
+ specs = specs.filter((spec) => spec.provider === selectedAccountProvider);
8288
+ }
8289
+ const accountManifest = selectedAccountProvider ? {
8290
+ ...loaded.manifest,
8291
+ provider: selectedAccountProvider,
8292
+ styles: Object.fromEntries(
8293
+ Object.entries(loaded.manifest.styles).filter(
8294
+ ([, style]) => (style.provider ?? loaded.manifest.provider) === selectedAccountProvider
8295
+ )
8296
+ )
8297
+ } : loaded.manifest;
8298
+ if (selectedAccountProvider && args.styles.some((styleId) => !accountManifest.styles[styleId])) {
8299
+ throw new Error(
8300
+ `Selected style is not assigned to provider "${selectedAccountProvider}".`
8301
+ );
8302
+ }
8010
8303
  const lock = await loadLock(args.lock);
8011
8304
  normalizeLockOutputPaths(lock, specs);
8012
8305
  if (args.command === "status") {
@@ -8031,14 +8324,23 @@ async function main() {
8031
8324
  }
8032
8325
  const plan = await buildPlan(specs, lock, { force: args.force });
8033
8326
  if (args.command === "doctor") {
8034
- const factory = providerFactory(loaded.manifest.provider);
8035
- const apiKeyPresent = !factory.credentialEnv || Boolean(process.env[factory.credentialEnv]);
8036
- const provider2 = !args.dryRun && apiKeyPresent ? createProvider(loaded.manifest.provider, "online") : void 0;
8327
+ const selectedStyleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
8328
+ const providerIds = [...new Set(
8329
+ selectedStyleIds.map((styleId) => loaded.manifest.styles[styleId]?.provider ?? loaded.manifest.provider)
8330
+ )].sort();
8331
+ const providers2 = providerIds.map((id) => {
8332
+ const factory = providerFactory(id);
8333
+ const apiKeyPresent = !factory.credentialEnv || Boolean(process.env[factory.credentialEnv]);
8334
+ return {
8335
+ id,
8336
+ apiKeyPresent,
8337
+ credentialEnv: factory.credentialEnv,
8338
+ provider: !args.dryRun && apiKeyPresent ? createProvider(id, "online") : void 0
8339
+ };
8340
+ });
8037
8341
  const report = await doctor(loaded, specs, lock, args.lock, {
8038
- provider: provider2,
8039
- offline: args.dryRun,
8040
- apiKeyPresent,
8041
- credentialEnv: factory.credentialEnv
8342
+ providers: providers2,
8343
+ offline: args.dryRun
8042
8344
  });
8043
8345
  if (args.json) {
8044
8346
  log(JSON.stringify(report, null, 2));
@@ -8058,6 +8360,13 @@ async function main() {
8058
8360
  cost: plan.cost,
8059
8361
  costUnit: plan.costUnit,
8060
8362
  candidates: plan.candidates,
8363
+ groups: plan.groups.map((group) => ({
8364
+ provider: group.provider,
8365
+ cost: group.cost,
8366
+ costUnit: group.costUnit,
8367
+ candidates: group.candidates,
8368
+ actionable: group.actionable.map((item) => item.key)
8369
+ })),
8061
8370
  actionable: plan.actionable.map((i) => i.key),
8062
8371
  items: plan.items.map(({ key, state, reason }) => ({ key, state, reason }))
8063
8372
  }, null, 2));
@@ -8072,7 +8381,7 @@ async function main() {
8072
8381
  for (const item of plan.items) {
8073
8382
  if (item.state !== "stale") continue;
8074
8383
  const entry = lock.entries[item.key];
8075
- if (!entry || entry.outputs.length === 0) continue;
8384
+ if (!entry || entry.provider !== item.spec.provider || entry.outputs.length === 0) continue;
8076
8385
  let intact = true;
8077
8386
  for (const output of entry.outputs) {
8078
8387
  const file = resolveOutputPath(output.path, item.spec.root);
@@ -8338,8 +8647,18 @@ async function main() {
8338
8647
  }
8339
8648
  return;
8340
8649
  }
8341
- const provider = args.command === "restore" || args.command === "fetch" && !args.tag ? createProvider(loaded.manifest.provider, "downloads") : createProvider(loaded.manifest.provider, "online");
8650
+ const providerMode = args.command === "restore" || args.command === "fetch" && !args.tag ? "downloads" : "online";
8651
+ const providers = /* @__PURE__ */ new Map();
8652
+ const providerFor = (id) => {
8653
+ let resolved = providers.get(id);
8654
+ if (!resolved) {
8655
+ resolved = createProvider(id, providerMode);
8656
+ providers.set(id, resolved);
8657
+ }
8658
+ return resolved;
8659
+ };
8342
8660
  if (args.command === "adopt") {
8661
+ const provider = providerFor(selectedAccountProvider);
8343
8662
  log(`
8344
8663
  Reconciling account objects against files already on disk\u2026`);
8345
8664
  const res = await adopt(provider, specs, lock, args.lock, { onProgress: log });
@@ -8374,15 +8693,16 @@ async function main() {
8374
8693
  }
8375
8694
  if (args.writePrompts) {
8376
8695
  const { filled, stillEmpty } = await writePromptsBack(path22.resolve(args.manifest), lock, {
8377
- onProgress: log
8696
+ onProgress: log,
8697
+ provider: provider.id,
8698
+ assetIds: specs.map((spec) => spec.assetId)
8378
8699
  });
8379
8700
  log(` recovered ${filled} prompt(s) into ${path22.relative(process.cwd(), args.manifest)}`);
8380
8701
  const reloaded = await loadManifest(args.manifest);
8381
- const rebased = await resolveSpecs(reloaded, {
8702
+ const rebased = (await resolveSpecs(reloaded, {
8382
8703
  styles: args.styles,
8383
- assets: args.assets,
8384
- provider
8385
- });
8704
+ assets: args.assets
8705
+ })).filter((spec) => spec.provider === provider.id);
8386
8706
  let n = 0;
8387
8707
  for (const spec of rebased) {
8388
8708
  const key = lockKey(spec.styleId, spec.assetId);
@@ -8403,6 +8723,7 @@ async function main() {
8403
8723
  return;
8404
8724
  }
8405
8725
  if (args.command === "salvage") {
8726
+ const provider = providerFor(selectedAccountProvider);
8406
8727
  const jsonMode = args.dryRun && args.json;
8407
8728
  const diag = jsonMode ? (msg = "") => console.error(msg) : log;
8408
8729
  const ownLock = path22.resolve(args.lock);
@@ -8440,7 +8761,7 @@ async function main() {
8440
8761
  \`pixelkiln workspace add ${args.manifest}\` would keep it aggregated too.`
8441
8762
  );
8442
8763
  }
8443
- const claimed = await loadClaims(lockPaths);
8764
+ const claimed = await loadClaims(lockPaths, { provider: provider.id });
8444
8765
  const { orphans, total } = await findOrphans(provider, claimed, { onProgress: diag });
8445
8766
  diag(` ${claimed.size} claimed \xB7 ${orphans.length} unclaimed of ${total}`);
8446
8767
  if (!orphans.length) {
@@ -8454,8 +8775,8 @@ async function main() {
8454
8775
  workspaceProjects.map((p) => resolveProject(workspaceDir, p).manifestPath),
8455
8776
  args.claims
8456
8777
  );
8457
- const { matched, unmatched, elsewhere } = groupOrphansByStyle(orphans, loaded.manifest, siblings);
8458
- const multiStyle = Object.keys(loaded.manifest.styles).length > 1;
8778
+ const { matched, unmatched, elsewhere } = groupOrphansByStyle(orphans, accountManifest, siblings);
8779
+ const multiStyle = Object.keys(accountManifest.styles).length > 1;
8459
8780
  if (multiStyle) {
8460
8781
  diag(`
8461
8782
  by style (matched against each style's prompt prefix/suffix):`);
@@ -8495,7 +8816,7 @@ async function main() {
8495
8816
  }
8496
8817
  const rebaseline = async () => {
8497
8818
  const reloaded = await loadManifest(args.manifest);
8498
- const rebased = await resolveSpecs(reloaded, { provider });
8819
+ const rebased = (await resolveSpecs(reloaded)).filter((spec) => spec.provider === provider.id);
8499
8820
  let n = 0;
8500
8821
  for (const spec of rebased) {
8501
8822
  const key = lockKey(spec.styleId, spec.assetId);
@@ -8508,13 +8829,13 @@ async function main() {
8508
8829
  if (n) log(` baselined ${n} imported asset(s) against the manifest`);
8509
8830
  };
8510
8831
  const runOne = async (styleId, list) => {
8511
- const style = loaded.manifest.styles[styleId];
8832
+ const style = accountManifest.styles[styleId];
8512
8833
  const res = await runSalvage(
8513
8834
  provider,
8514
8835
  list,
8515
8836
  {
8516
8837
  manifestPath: loaded.path,
8517
- manifest: loaded.manifest,
8838
+ manifest: accountManifest,
8518
8839
  styleId,
8519
8840
  importDir: path22.resolve(loaded.root, style.outDir),
8520
8841
  lock,
@@ -8561,6 +8882,7 @@ async function main() {
8561
8882
  return;
8562
8883
  }
8563
8884
  if (args.command === "purge") {
8885
+ const provider = providerFor(selectedAccountProvider);
8564
8886
  const doomed = [];
8565
8887
  for await (const obj of requireList(provider)()) {
8566
8888
  if (obj.tags.includes("pixelkiln:discard")) {
@@ -8604,8 +8926,13 @@ async function main() {
8604
8926
  return;
8605
8927
  }
8606
8928
  if (args.command === "tag") {
8607
- const n = await pushTags(provider, specs, lock, { onProgress: log });
8608
- log(` tagged ${n} object(s)`);
8929
+ let tagged = 0;
8930
+ for (const [providerId, providerSpecsForRun] of specsByRecordedProvider(specs, lock)) {
8931
+ tagged += await pushTags(providerFor(providerId), providerSpecsForRun, lock, {
8932
+ onProgress: log
8933
+ });
8934
+ }
8935
+ log(` tagged ${tagged} object(s)`);
8609
8936
  return;
8610
8937
  }
8611
8938
  if (args.command === "submit" || args.command === "gen") {
@@ -8616,26 +8943,43 @@ async function main() {
8616
8943
  return;
8617
8944
  }
8618
8945
  if (plan.actionable.length) {
8619
- const balance = provider.balance ? await provider.balance() : null;
8620
- if (balance) {
8621
- log(`
8622
- balance: ${formatCost(balance.unit, balance.remaining)} remaining (${provider.id})`);
8623
- if (balance.unit !== plan.costUnit) {
8624
- throw new Error(
8625
- `Provider estimate unit ${plan.costUnit} does not match balance unit ${balance.unit}.`
8946
+ const budgets = budgetsForPlan(plan, args);
8947
+ const balances = /* @__PURE__ */ new Map();
8948
+ for (const group of plan.groups) {
8949
+ const groupProvider = providerFor(group.provider);
8950
+ const balance = groupProvider.balance ? await groupProvider.balance() : null;
8951
+ balances.set(group.provider, balance);
8952
+ if (balance) {
8953
+ log(
8954
+ `
8955
+ balance: ${formatCost(balance.unit, balance.remaining)} remaining (${group.provider})`
8956
+ );
8957
+ if (balance.unit !== group.costUnit) {
8958
+ throw new Error(
8959
+ `Provider ${group.provider} estimate unit ${group.costUnit} does not match balance unit ${balance.unit}.`
8960
+ );
8961
+ }
8962
+ if (balance.unit !== "free" && group.cost > balance.remaining) {
8963
+ throw new Error(
8964
+ `${group.provider} needs ${formatCost(balance.unit, group.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
8965
+ );
8966
+ }
8967
+ } else {
8968
+ log(
8969
+ `
8970
+ ${group.provider} does not expose an account balance; enforcing its run budget`
8626
8971
  );
8627
8972
  }
8628
- if (balance.unit !== "free" && plan.cost > balance.remaining) {
8973
+ const ceiling = budgets.get(group.provider);
8974
+ if (ceiling !== void 0 && group.cost > ceiling) {
8629
8975
  throw new Error(
8630
- `This run needs ${formatCost(balance.unit, plan.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
8976
+ `${group.provider} would spend ${formatCost(group.costUnit, group.cost)} but its budget is ${formatCost(group.costUnit, ceiling)}.`
8631
8977
  );
8632
8978
  }
8633
- } else {
8634
- log(`
8635
- ${provider.id} does not expose an account balance; enforcing the explicit run budget`);
8636
8979
  }
8980
+ const spendSummary = plan.groups.map((group) => `${group.provider} ${formatCost(group.costUnit, group.cost)}`).join("; ");
8637
8981
  const ok = await confirm(
8638
- ` Spend ${formatCost(plan.costUnit, plan.cost)} on ${plan.actionable.length} asset(s)?`,
8982
+ ` Spend ${spendSummary} on ${plan.actionable.length} asset(s)?`,
8639
8983
  args.yes
8640
8984
  );
8641
8985
  if (!ok) {
@@ -8644,69 +8988,104 @@ async function main() {
8644
8988
  }
8645
8989
  log(`
8646
8990
  submitting\u2026`);
8647
- const res = await submit(provider, loaded, plan.actionable, lock, args.lock, {
8648
- budget: args.budget,
8649
- onProgress: log
8650
- });
8651
- log(
8652
- `
8653
- submitted ${res.submitted}, failed ${res.failed}, estimated ${formatCost(res.unit, res.spent)}`
8654
- );
8655
- try {
8656
- const after = balance && provider.balance ? await provider.balance() : null;
8657
- if (!balance || !after) throw new Error("balance reporting is unsupported");
8658
- const measured = measureBalanceChange(balance, after);
8659
- if (measured) {
8660
- const movement = measured.credited ? `${formatCost(measured.unit, measured.credited)} credited` : `${formatCost(measured.unit, measured.spent)} consumed`;
8661
- log(` provider-reported balance change: ${movement}`);
8662
- } else {
8663
- log(` provider changed balance units during the run; no delta reported`);
8991
+ for (const group of plan.groups) {
8992
+ const groupProvider = providerFor(group.provider);
8993
+ const res = await submit(groupProvider, loaded, group.actionable, lock, args.lock, {
8994
+ budget: budgets.get(group.provider),
8995
+ onProgress: log
8996
+ });
8997
+ log(
8998
+ `
8999
+ ${group.provider}: submitted ${res.submitted}, failed ${res.failed}, estimated ${formatCost(res.unit, res.spent)}`
9000
+ );
9001
+ try {
9002
+ const before = balances.get(group.provider);
9003
+ const after = before && groupProvider.balance ? await groupProvider.balance() : null;
9004
+ if (!before || !after) throw new Error("balance reporting is unsupported");
9005
+ const measured = measureBalanceChange(before, after);
9006
+ if (measured) {
9007
+ const movement = measured.credited ? `${formatCost(measured.unit, measured.credited)} credited` : `${formatCost(measured.unit, measured.spent)} consumed`;
9008
+ log(` ${group.provider} balance change: ${movement}`);
9009
+ } else {
9010
+ log(` ${group.provider} changed balance units; no delta reported`);
9011
+ }
9012
+ } catch (err) {
9013
+ log(
9014
+ ` ${group.provider} balance recheck unavailable: ${err instanceof Error ? err.message : String(err)}`
9015
+ );
8664
9016
  }
8665
- } catch (err) {
8666
- log(` provider balance recheck unavailable: ${err instanceof Error ? err.message : String(err)}`);
9017
+ if (res.failed) process.exitCode = 1;
8667
9018
  }
8668
- if (res.failed) process.exitCode = 1;
8669
9019
  }
8670
9020
  if (args.command === "submit") return;
8671
9021
  }
8672
9022
  if (args.command === "poll" || args.command === "gen") {
8673
9023
  log(`
8674
9024
  polling\u2026`);
8675
- const res = await poll(provider, lock, args.lock, { onProgress: log, specs });
8676
- log(`
8677
- ${res.completed} ready \xB7 ${res.review} awaiting selection \xB7 ${res.failed} failed`);
8678
- if (res.failed || res.stillRunning) process.exitCode = 1;
9025
+ const total = { completed: 0, review: 0, failed: 0, stillRunning: 0 };
9026
+ for (const [providerId, providerSpecsForRun] of specsByRecordedProvider(specs, lock)) {
9027
+ const res = await poll(providerFor(providerId), lock, args.lock, {
9028
+ onProgress: log,
9029
+ specs: providerSpecsForRun
9030
+ });
9031
+ total.completed += res.completed;
9032
+ total.review += res.review;
9033
+ total.failed += res.failed;
9034
+ total.stillRunning += res.stillRunning;
9035
+ }
9036
+ log(
9037
+ `
9038
+ ${total.completed} ready \xB7 ${total.review} awaiting selection \xB7 ${total.failed} failed`
9039
+ );
9040
+ if (total.failed || total.stillRunning) process.exitCode = 1;
8679
9041
  if (args.command === "poll") return;
8680
9042
  }
8681
9043
  if (args.command === "pick" || args.command === "gen") {
8682
- const res = await runPicker(provider, lock, args.lock, {
8683
- port: args.port,
8684
- open: !args.noOpen,
8685
- onProgress: log
8686
- });
8687
- if (res.selected === 0 && res.skipped === 0) {
9044
+ const total = { selected: 0, skipped: 0 };
9045
+ for (const [providerId, providerSpecsForRun] of specsByRecordedProvider(specs, lock)) {
9046
+ const res = await runPicker(providerFor(providerId), lock, args.lock, {
9047
+ port: args.port,
9048
+ open: !args.noOpen,
9049
+ onProgress: log,
9050
+ keys: providerSpecsForRun.map((spec) => lockKey(spec.styleId, spec.assetId))
9051
+ });
9052
+ total.selected += res.selected;
9053
+ total.skipped += res.skipped;
9054
+ }
9055
+ if (total.selected === 0 && total.skipped === 0) {
8688
9056
  log(` nothing awaiting selection`);
8689
9057
  } else {
8690
9058
  log(`
8691
- selected ${res.selected}, left in review ${res.skipped}`);
9059
+ selected ${total.selected}, left in review ${total.skipped}`);
8692
9060
  }
8693
- if (args.command === "gen" && res.skipped) process.exitCode = 1;
9061
+ if (args.command === "gen" && total.skipped) process.exitCode = 1;
8694
9062
  if (args.command === "pick") return;
8695
9063
  }
8696
9064
  if (args.command === "fetch" || args.command === "restore" || args.command === "gen") {
8697
9065
  log(`
8698
9066
  downloading\u2026`);
8699
- const res = await fetchAssets(provider, specs, lock, args.lock, {
8700
- onProgress: log,
8701
- repair: args.command === "restore"
8702
- });
8703
- log(`
8704
- downloaded ${res.downloaded}, skipped ${res.skipped}, failed ${res.failed}`);
8705
- if (res.failed) process.exitCode = 1;
8706
- if (args.tag) {
8707
- const n = await pushTags(provider, specs, lock, { onProgress: log });
8708
- log(` tagged ${n} object(s) upstream`);
9067
+ const total = { downloaded: 0, skipped: 0, failed: 0, tagged: 0 };
9068
+ for (const [providerId, providerSpecsForRun] of specsByRecordedProvider(specs, lock)) {
9069
+ const groupProvider = providerFor(providerId);
9070
+ const res = await fetchAssets(groupProvider, providerSpecsForRun, lock, args.lock, {
9071
+ onProgress: log,
9072
+ repair: args.command === "restore"
9073
+ });
9074
+ total.downloaded += res.downloaded;
9075
+ total.skipped += res.skipped;
9076
+ total.failed += res.failed;
9077
+ if (args.tag) {
9078
+ total.tagged += await pushTags(groupProvider, providerSpecsForRun, lock, {
9079
+ onProgress: log
9080
+ });
9081
+ }
8709
9082
  }
9083
+ log(
9084
+ `
9085
+ downloaded ${total.downloaded}, skipped ${total.skipped}, failed ${total.failed}`
9086
+ );
9087
+ if (total.failed) process.exitCode = 1;
9088
+ if (args.tag) log(` tagged ${total.tagged} object(s) upstream`);
8710
9089
  await saveLock(args.lock, lock);
8711
9090
  log(` lockfile written: ${args.lock}`);
8712
9091
  return;