pixelkiln 0.12.1 → 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"),
@@ -2392,7 +2394,17 @@ ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
2392
2394
  }
2393
2395
  async function resolveSpecs(loaded, filter) {
2394
2396
  const { manifest, root } = loaded;
2395
- 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
+ };
2396
2408
  const specs = [];
2397
2409
  const styleIds = Object.keys(manifest.styles).filter(
2398
2410
  (id) => !filter?.styles?.length || filter.styles.includes(id)
@@ -2428,6 +2440,7 @@ async function resolveSpecs(loaded, filter) {
2428
2440
  }
2429
2441
  for (const styleId of styleIds) {
2430
2442
  const style = manifest.styles[styleId];
2443
+ const activeProvider = providerFor(style.provider ?? manifest.provider);
2431
2444
  const rawProviderOptions = style.providerOptions[activeProvider.id] ?? {};
2432
2445
  const optionResolution = activeProvider.resolveOptions ? await activeProvider.resolveOptions(rawProviderOptions, { root, styleId }) : { options: rawProviderOptions };
2433
2446
  const providerOptions = optionResolution.options;
@@ -3171,13 +3184,39 @@ async function buildPlan(specs, lock, opts = {}) {
3171
3184
  items.push({ spec, key, state, reason });
3172
3185
  }
3173
3186
  const actionable = items.filter((i) => i.state === "missing" || i.state === "stale" || i.state === "failed");
3174
- const units = new Set(actionable.map((item) => item.spec.costUnit ?? "generations"));
3175
- 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;
3176
3214
  return {
3177
3215
  items,
3178
3216
  actionable,
3179
- cost: actionable.reduce((sum, i) => sum + i.spec.cost, 0),
3180
- 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")),
3181
3220
  candidates: actionable.reduce((sum, i) => sum + i.spec.candidates, 0)
3182
3221
  };
3183
3222
  }
@@ -3284,6 +3323,10 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
3284
3323
  submittedAt: (/* @__PURE__ */ new Date()).toISOString(),
3285
3324
  cost: estimate.amount,
3286
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,
3287
3330
  downloadedAt: null
3288
3331
  });
3289
3332
  await saveLock(lockPath, lock);
@@ -3296,8 +3339,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
3296
3339
  // A multi-candidate generator routes through review; record the parent
3297
3340
  // so `pick` knows where to look.
3298
3341
  reviewObjectId: estimate.candidates > 1 && !spec.tileFeature ? jobId : null,
3299
- status: "processing",
3300
- provider: provider.id
3342
+ status: "processing"
3301
3343
  });
3302
3344
  inFlight.set(jobId, spec);
3303
3345
  submitted++;
@@ -3325,9 +3367,10 @@ async function poll(provider, lock, lockPath, opts = {}) {
3325
3367
  });
3326
3368
  const started = Date.now();
3327
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;
3328
3371
  const result = { review: 0, completed: 0, failed: 0, stillRunning: 0 };
3329
3372
  const pending = () => Object.entries(lock.entries).filter(
3330
- ([, 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")
3331
3374
  );
3332
3375
  while (pending().length > 0) {
3333
3376
  if (Date.now() - started > timeout) {
@@ -3429,6 +3472,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3429
3472
  normalizeLockOutputPaths(lock, specs);
3430
3473
  const cacheDir = opts.cacheDir === false ? null : path9.resolve(opts.cacheDir ?? path9.join(path9.dirname(lockPath), ".pixelkiln", "cache"));
3431
3474
  const pending = Object.entries(lock.entries).filter(([key, e]) => {
3475
+ if (e.provider !== provider.id || !specByKey.has(key)) return false;
3432
3476
  if (e.status === "selected" || e.status === "download-failed") return true;
3433
3477
  if (!opts.repair || e.status !== "downloaded") return false;
3434
3478
  const spec = specByKey.get(key);
@@ -3595,7 +3639,7 @@ async function pushTags(provider, specs, lock, opts = {}) {
3595
3639
  let tagged = 0;
3596
3640
  for (const [key, entry] of Object.entries(lock.entries)) {
3597
3641
  const spec = specByKey.get(key);
3598
- if (!spec || !entry.objectId) continue;
3642
+ if (!spec || entry.provider !== provider.id || !entry.objectId) continue;
3599
3643
  if (entry.generator === "pixflux") continue;
3600
3644
  if (!provider.setTags) return tagged;
3601
3645
  try {
@@ -3701,30 +3745,51 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
3701
3745
  unsettled ? "warning" : "ok",
3702
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"
3703
3747
  );
3704
- if (opts.offline) {
3705
- add("provider", "warning", "provider connectivity skipped (--dry-run)");
3706
- } else if (!opts.provider) {
3707
- add(
3708
- "provider",
3709
- "error",
3710
- opts.apiKeyPresent === false ? `${opts.credentialEnv ?? "PIXELLAB_API_KEY"} is not configured` : "provider is not configured"
3711
- );
3712
- } else if (!opts.provider.balance && !opts.provider.checkConnection) {
3713
- add("provider", "ok", `${opts.provider.id} configured; connectivity check unavailable`);
3714
- } else if (!opts.provider.balance) {
3715
- try {
3716
- await opts.provider.checkConnection();
3717
- add("provider", "ok", `${opts.provider.id} reachable; balance reporting unavailable`);
3718
- } catch (err) {
3719
- add("provider", "error", `provider connectivity failed: ${err instanceof Error ? err.message : String(err)}`);
3720
- }
3721
- } else {
3722
- const balanceFn = opts.provider.balance.bind(opts.provider);
3723
- try {
3724
- const balance = await balanceFn();
3725
- add("provider", "ok", `${opts.provider.id} reachable; ${balance.remaining} ${balance.unit} remaining`);
3726
- } catch (err) {
3727
- 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
+ }
3728
3793
  }
3729
3794
  }
3730
3795
  return { ok: !checks.some((c) => c.level === "error"), checks };
@@ -3876,6 +3941,7 @@ async function adopt(provider, specs, lock, lockPath, opts = {}) {
3876
3941
  ambiguous.push(`${key}: already mapped to ${existing.objectId}, also matches ${obj.id}`);
3877
3942
  continue;
3878
3943
  }
3944
+ const durableSource = shouldPersistSourceUrl(url) ? url : null;
3879
3945
  upsert(lock, key, {
3880
3946
  styleId: spec.styleId,
3881
3947
  assetId: spec.assetId,
@@ -3892,7 +3958,8 @@ async function adopt(provider, specs, lock, lockPath, opts = {}) {
3892
3958
  candidateIndex: null,
3893
3959
  status: "downloaded",
3894
3960
  error: null,
3895
- sourceUrl: url,
3961
+ sourceUrl: durableSource,
3962
+ sourceUrls: durableSource ? [{ url: durableSource }] : [],
3896
3963
  outputs: [{ path: portableOutputPath(spec.outFile, spec.root), sha256: hash }],
3897
3964
  submittedAt: obj.createdAt,
3898
3965
  downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -3942,9 +4009,12 @@ async function tagAdopted(provider, specs, lock, opts = {}) {
3942
4009
  async function writePromptsBack(manifestPath, lock, opts = {}) {
3943
4010
  const log2 = opts.onProgress ?? (() => {
3944
4011
  });
4012
+ const selectedAssets = opts.assetIds ? new Set(opts.assetIds) : null;
3945
4013
  const raw = JSON.parse(await readFile6(manifestPath, "utf8"));
3946
4014
  const best = /* @__PURE__ */ new Map();
3947
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;
3948
4018
  if (!entry.objectId || !entry.prompt) continue;
3949
4019
  const current = best.get(entry.assetId);
3950
4020
  if (!current || entry.prompt.length > current.length) best.set(entry.assetId, entry.prompt);
@@ -3952,6 +4022,7 @@ async function writePromptsBack(manifestPath, lock, opts = {}) {
3952
4022
  let filled = 0;
3953
4023
  const stillEmpty = [];
3954
4024
  for (const [id, asset] of Object.entries(raw.assets)) {
4025
+ if (selectedAssets && !selectedAssets.has(id)) continue;
3955
4026
  if (asset.prompt && asset.prompt.trim()) continue;
3956
4027
  const recovered = best.get(id);
3957
4028
  if (recovered) {
@@ -4267,8 +4338,10 @@ function serveReviewPage(opts) {
4267
4338
  async function runPicker(provider, lock, lockPath, opts = {}) {
4268
4339
  const log2 = opts.onProgress ?? (() => {
4269
4340
  });
4341
+ const selectedKeys = opts.keys ? new Set(opts.keys) : null;
4270
4342
  const groups = [];
4271
4343
  for (const [key, entry] of Object.entries(lock.entries)) {
4344
+ if (entry.provider !== provider.id || selectedKeys && !selectedKeys.has(key)) continue;
4272
4345
  if (entry.status !== "review" || !entry.reviewObjectId) continue;
4273
4346
  try {
4274
4347
  const state = await provider.poll(entry.reviewObjectId, entry.generator);
@@ -4434,7 +4507,10 @@ async function writeManifestFile(target, manifest) {
4434
4507
  import { readFile as readFile8 } from "fs/promises";
4435
4508
  import { existsSync as existsSync11 } from "fs";
4436
4509
  import path13 from "path";
4437
- async function loadClaims(lockPaths) {
4510
+ function providerClaimId(provider, objectId) {
4511
+ return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
4512
+ }
4513
+ async function loadClaims(lockPaths, opts = {}) {
4438
4514
  const claimed = /* @__PURE__ */ new Set();
4439
4515
  for (const p of lockPaths) {
4440
4516
  if (!existsSync11(p)) throw new Error(`Claim lockfile not found: ${p}`);
@@ -4445,9 +4521,13 @@ async function loadClaims(lockPaths) {
4445
4521
  throw new Error(`Claim lockfile is malformed: ${p}`);
4446
4522
  }
4447
4523
  for (const entry of Object.values(parsed.entries)) {
4448
- if (entry.objectId) claimed.add(entry.objectId);
4449
- if (entry.reviewObjectId) claimed.add(entry.reviewObjectId);
4450
- 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);
4451
4531
  }
4452
4532
  }
4453
4533
  return claimed;
@@ -4458,7 +4538,9 @@ async function findOrphans(provider, claimed, opts = {}) {
4458
4538
  const all = [];
4459
4539
  for await (const obj of requireList(provider)()) all.push(obj);
4460
4540
  log2(` scanned ${all.length} object(s) on the account`);
4461
- 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) => ({
4462
4544
  id: o.id,
4463
4545
  prompt: o.prompt || "(no prompt recorded)",
4464
4546
  width: o.width,
@@ -4753,7 +4835,7 @@ async function workspaceClaims(ws, dir) {
4753
4835
  lockPaths.push(lockPath);
4754
4836
  let projectClaims;
4755
4837
  try {
4756
- projectClaims = await loadClaims([lockPath]);
4838
+ projectClaims = await loadClaims([lockPath], { qualify: true });
4757
4839
  } catch (err) {
4758
4840
  throw new Error(
4759
4841
  `Project "${project.id}" lockfile is unreadable: ${err instanceof Error ? err.message : String(err)}`
@@ -4791,9 +4873,14 @@ async function workspaceStatus(ws, dir) {
4791
4873
  lock: lockPath
4792
4874
  };
4793
4875
  try {
4794
- const provider = createProvider(project.provider, "offline");
4795
4876
  const loaded = await loadManifest(manifestPath);
4796
- 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);
4797
4884
  const lock = await loadLock(lockPath);
4798
4885
  normalizeLockOutputPaths(lock, specs);
4799
4886
  const plan = await buildPlan(specs, lock);
@@ -4807,6 +4894,8 @@ async function workspaceStatus(ws, dir) {
4807
4894
  }
4808
4895
  projects.push({
4809
4896
  ...base,
4897
+ provider: providers.length === 1 ? providers[0] : project.provider,
4898
+ providers,
4810
4899
  entries: Object.keys(lock.entries).length,
4811
4900
  byState,
4812
4901
  spendByUnit: spend,
@@ -4815,6 +4904,7 @@ async function workspaceStatus(ws, dir) {
4815
4904
  } catch (err) {
4816
4905
  projects.push({
4817
4906
  ...base,
4907
+ providers: [project.provider],
4818
4908
  entries: 0,
4819
4909
  byState: emptyStateCounts(),
4820
4910
  spendByUnit: { generations: 0, usd: 0, free: 0 },
@@ -6411,6 +6501,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
6411
6501
  styles: [ctx.styleId],
6412
6502
  ...orphan.width === orphan.height ? { size: orphan.width } : { width: orphan.width, height: orphan.height }
6413
6503
  };
6504
+ const durableSource = shouldPersistSourceUrl(orphan.previewUrl) ? orphan.previewUrl : null;
6414
6505
  upsert(ctx.lock, lockKey(ctx.styleId, assetId), {
6415
6506
  styleId: ctx.styleId,
6416
6507
  assetId,
@@ -6425,7 +6516,10 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
6425
6516
  candidateIndex: null,
6426
6517
  status: "downloaded",
6427
6518
  error: null,
6428
- 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 }] : [],
6429
6523
  outputs: [{
6430
6524
  path: portableOutputPath(outFile, path19.dirname(ctx.manifestPath)),
6431
6525
  sha256: sha256(buf)
@@ -6542,6 +6636,13 @@ var RecipeSchema = z5.object({
6542
6636
  digest: z5.string().regex(SHA256_RE)
6543
6637
  }).strict()
6544
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
+ }
6545
6646
  const filePaths = recipe.files.map((file) => file.path);
6546
6647
  if (new Set(filePaths).size !== filePaths.length) {
6547
6648
  context.addIssue({ code: z5.ZodIssueCode.custom, path: ["files"], message: "duplicate file path" });
@@ -6797,13 +6898,16 @@ async function installRecipe(target, options = {}) {
6797
6898
  }
6798
6899
  }
6799
6900
  const written = await writeArtifactBundle(files);
6901
+ const renderedStyle = renderStyle(loaded.recipe.style, destination, cwd);
6800
6902
  return {
6801
6903
  recipe: loaded.recipe,
6802
6904
  destination,
6803
6905
  changed: written.changed,
6804
6906
  unchanged: written.unchanged,
6805
6907
  styleId: loaded.recipe.styleId,
6806
- 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 }
6807
6911
  };
6808
6912
  }
6809
6913
 
@@ -7399,12 +7503,34 @@ function parseArgs(argv) {
7399
7503
  }
7400
7504
  }
7401
7505
  let budget;
7402
- const rawBudget = get("--budget");
7403
- if (rawBudget !== void 0) {
7404
- budget = Number(rawBudget);
7405
- 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) {
7406
7527
  throw new Error(`--budget must be a non-negative number, got "${rawBudget}".`);
7407
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.");
7408
7534
  }
7409
7535
  const manifest = get("--manifest") ?? "pixelkiln.manifest.json";
7410
7536
  const rawFormat = get("--format");
@@ -7437,6 +7563,7 @@ function parseArgs(argv) {
7437
7563
  force: rest.includes("--force"),
7438
7564
  yes: rest.includes("--yes") || rest.includes("-y"),
7439
7565
  budget,
7566
+ providerBudgets,
7440
7567
  dryRun: rest.includes("--dry-run"),
7441
7568
  all: rest.includes("--all"),
7442
7569
  json: rest.includes("--json"),
@@ -7535,7 +7662,9 @@ Options
7535
7662
  --lock <path> Default: pixelkiln.lock.json beside the manifest
7536
7663
  --style a,b Restrict to these styles
7537
7664
  --only id1,id2 Restrict to these asset ids
7538
- --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
7539
7668
  --force Regenerate; also replace changed recipe files or quality baselines
7540
7669
  --dry-run Never spend; doctor also skips provider connectivity
7541
7670
  --all salvage --dry-run: list every unclaimed object, not just the first 30
@@ -7589,13 +7718,73 @@ function printPlan(plan) {
7589
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`
7590
7719
  );
7591
7720
  if (plan.actionable.length) {
7592
- log(
7593
- ` would generate ${plan.actionable.length} asset(s) \u2014 ${formatCost(plan.costUnit, plan.cost)}, yielding ${plan.candidates} candidate/output image(s)`
7594
- );
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
+ }
7595
7726
  } else {
7596
7727
  log(` nothing to generate`);
7597
7728
  }
7598
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
+ }
7599
7788
  async function confirm(question, auto) {
7600
7789
  if (auto) return true;
7601
7790
  if (!process.stdin.isTTY) {
@@ -7845,7 +8034,8 @@ async function main() {
7845
8034
  loadEnvFiles(path22.dirname(path22.resolve(args.manifest)));
7846
8035
  loadEnvFiles(process.cwd());
7847
8036
  const loaded2 = await loadManifest(args.manifest);
7848
- const p = createProvider(loaded2.manifest.provider, "online");
8037
+ const selectedProvider = accountProviderId(loaded2.manifest, args.provider, "balance");
8038
+ const p = createProvider(selectedProvider, "online");
7849
8039
  const b = await requireBalance(p)();
7850
8040
  log(` provider: ${p.id}`);
7851
8041
  log(` plan: ${b.plan ?? "n/a"}`);
@@ -8034,8 +8224,10 @@ async function main() {
8034
8224
  ${p.id} \u2014 ERROR: ${p.error}`);
8035
8225
  continue;
8036
8226
  }
8037
- log(`
8038
- ${p.id} (${p.provider}${p.account ? `, ${p.account}` : ""})`);
8227
+ log(
8228
+ `
8229
+ ${p.id} (${p.providers.join(" + ")}${p.account ? `, ${p.account}` : ""})`
8230
+ );
8039
8231
  log(` ${p.entries} lock entries`);
8040
8232
  for (const [state, n] of Object.entries(p.byState)) if (n) log(` ${state.padEnd(12)} ${n}`);
8041
8233
  for (const [unit, amount] of Object.entries(p.spendByUnit).sort()) {
@@ -8080,12 +8272,34 @@ async function main() {
8080
8272
  const envFiles = [...loadEnvFiles(manifestDir)];
8081
8273
  if (path22.resolve(process.cwd()) !== manifestDir) envFiles.push(...loadEnvFiles(process.cwd()));
8082
8274
  const loaded = await loadManifest(args.manifest);
8083
- const estimator = createProvider(loaded.manifest.provider, "offline");
8084
- const specs = await resolveSpecs(loaded, {
8275
+ let specs = await resolveSpecs(loaded, {
8085
8276
  styles: args.styles,
8086
- assets: args.assets,
8087
- provider: estimator
8277
+ assets: args.assets
8088
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
+ }
8089
8303
  const lock = await loadLock(args.lock);
8090
8304
  normalizeLockOutputPaths(lock, specs);
8091
8305
  if (args.command === "status") {
@@ -8110,14 +8324,23 @@ async function main() {
8110
8324
  }
8111
8325
  const plan = await buildPlan(specs, lock, { force: args.force });
8112
8326
  if (args.command === "doctor") {
8113
- const factory = providerFactory(loaded.manifest.provider);
8114
- const apiKeyPresent = !factory.credentialEnv || Boolean(process.env[factory.credentialEnv]);
8115
- 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
+ });
8116
8341
  const report = await doctor(loaded, specs, lock, args.lock, {
8117
- provider: provider2,
8118
- offline: args.dryRun,
8119
- apiKeyPresent,
8120
- credentialEnv: factory.credentialEnv
8342
+ providers: providers2,
8343
+ offline: args.dryRun
8121
8344
  });
8122
8345
  if (args.json) {
8123
8346
  log(JSON.stringify(report, null, 2));
@@ -8137,6 +8360,13 @@ async function main() {
8137
8360
  cost: plan.cost,
8138
8361
  costUnit: plan.costUnit,
8139
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
+ })),
8140
8370
  actionable: plan.actionable.map((i) => i.key),
8141
8371
  items: plan.items.map(({ key, state, reason }) => ({ key, state, reason }))
8142
8372
  }, null, 2));
@@ -8151,7 +8381,7 @@ async function main() {
8151
8381
  for (const item of plan.items) {
8152
8382
  if (item.state !== "stale") continue;
8153
8383
  const entry = lock.entries[item.key];
8154
- if (!entry || entry.outputs.length === 0) continue;
8384
+ if (!entry || entry.provider !== item.spec.provider || entry.outputs.length === 0) continue;
8155
8385
  let intact = true;
8156
8386
  for (const output of entry.outputs) {
8157
8387
  const file = resolveOutputPath(output.path, item.spec.root);
@@ -8417,8 +8647,18 @@ async function main() {
8417
8647
  }
8418
8648
  return;
8419
8649
  }
8420
- 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
+ };
8421
8660
  if (args.command === "adopt") {
8661
+ const provider = providerFor(selectedAccountProvider);
8422
8662
  log(`
8423
8663
  Reconciling account objects against files already on disk\u2026`);
8424
8664
  const res = await adopt(provider, specs, lock, args.lock, { onProgress: log });
@@ -8453,15 +8693,16 @@ async function main() {
8453
8693
  }
8454
8694
  if (args.writePrompts) {
8455
8695
  const { filled, stillEmpty } = await writePromptsBack(path22.resolve(args.manifest), lock, {
8456
- onProgress: log
8696
+ onProgress: log,
8697
+ provider: provider.id,
8698
+ assetIds: specs.map((spec) => spec.assetId)
8457
8699
  });
8458
8700
  log(` recovered ${filled} prompt(s) into ${path22.relative(process.cwd(), args.manifest)}`);
8459
8701
  const reloaded = await loadManifest(args.manifest);
8460
- const rebased = await resolveSpecs(reloaded, {
8702
+ const rebased = (await resolveSpecs(reloaded, {
8461
8703
  styles: args.styles,
8462
- assets: args.assets,
8463
- provider
8464
- });
8704
+ assets: args.assets
8705
+ })).filter((spec) => spec.provider === provider.id);
8465
8706
  let n = 0;
8466
8707
  for (const spec of rebased) {
8467
8708
  const key = lockKey(spec.styleId, spec.assetId);
@@ -8482,6 +8723,7 @@ async function main() {
8482
8723
  return;
8483
8724
  }
8484
8725
  if (args.command === "salvage") {
8726
+ const provider = providerFor(selectedAccountProvider);
8485
8727
  const jsonMode = args.dryRun && args.json;
8486
8728
  const diag = jsonMode ? (msg = "") => console.error(msg) : log;
8487
8729
  const ownLock = path22.resolve(args.lock);
@@ -8519,7 +8761,7 @@ async function main() {
8519
8761
  \`pixelkiln workspace add ${args.manifest}\` would keep it aggregated too.`
8520
8762
  );
8521
8763
  }
8522
- const claimed = await loadClaims(lockPaths);
8764
+ const claimed = await loadClaims(lockPaths, { provider: provider.id });
8523
8765
  const { orphans, total } = await findOrphans(provider, claimed, { onProgress: diag });
8524
8766
  diag(` ${claimed.size} claimed \xB7 ${orphans.length} unclaimed of ${total}`);
8525
8767
  if (!orphans.length) {
@@ -8533,8 +8775,8 @@ async function main() {
8533
8775
  workspaceProjects.map((p) => resolveProject(workspaceDir, p).manifestPath),
8534
8776
  args.claims
8535
8777
  );
8536
- const { matched, unmatched, elsewhere } = groupOrphansByStyle(orphans, loaded.manifest, siblings);
8537
- 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;
8538
8780
  if (multiStyle) {
8539
8781
  diag(`
8540
8782
  by style (matched against each style's prompt prefix/suffix):`);
@@ -8574,7 +8816,7 @@ async function main() {
8574
8816
  }
8575
8817
  const rebaseline = async () => {
8576
8818
  const reloaded = await loadManifest(args.manifest);
8577
- const rebased = await resolveSpecs(reloaded, { provider });
8819
+ const rebased = (await resolveSpecs(reloaded)).filter((spec) => spec.provider === provider.id);
8578
8820
  let n = 0;
8579
8821
  for (const spec of rebased) {
8580
8822
  const key = lockKey(spec.styleId, spec.assetId);
@@ -8587,13 +8829,13 @@ async function main() {
8587
8829
  if (n) log(` baselined ${n} imported asset(s) against the manifest`);
8588
8830
  };
8589
8831
  const runOne = async (styleId, list) => {
8590
- const style = loaded.manifest.styles[styleId];
8832
+ const style = accountManifest.styles[styleId];
8591
8833
  const res = await runSalvage(
8592
8834
  provider,
8593
8835
  list,
8594
8836
  {
8595
8837
  manifestPath: loaded.path,
8596
- manifest: loaded.manifest,
8838
+ manifest: accountManifest,
8597
8839
  styleId,
8598
8840
  importDir: path22.resolve(loaded.root, style.outDir),
8599
8841
  lock,
@@ -8640,6 +8882,7 @@ async function main() {
8640
8882
  return;
8641
8883
  }
8642
8884
  if (args.command === "purge") {
8885
+ const provider = providerFor(selectedAccountProvider);
8643
8886
  const doomed = [];
8644
8887
  for await (const obj of requireList(provider)()) {
8645
8888
  if (obj.tags.includes("pixelkiln:discard")) {
@@ -8683,8 +8926,13 @@ async function main() {
8683
8926
  return;
8684
8927
  }
8685
8928
  if (args.command === "tag") {
8686
- const n = await pushTags(provider, specs, lock, { onProgress: log });
8687
- 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)`);
8688
8936
  return;
8689
8937
  }
8690
8938
  if (args.command === "submit" || args.command === "gen") {
@@ -8695,26 +8943,43 @@ async function main() {
8695
8943
  return;
8696
8944
  }
8697
8945
  if (plan.actionable.length) {
8698
- const balance = provider.balance ? await provider.balance() : null;
8699
- if (balance) {
8700
- log(`
8701
- balance: ${formatCost(balance.unit, balance.remaining)} remaining (${provider.id})`);
8702
- if (balance.unit !== plan.costUnit) {
8703
- throw new Error(
8704
- `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`
8705
8971
  );
8706
8972
  }
8707
- if (balance.unit !== "free" && plan.cost > balance.remaining) {
8973
+ const ceiling = budgets.get(group.provider);
8974
+ if (ceiling !== void 0 && group.cost > ceiling) {
8708
8975
  throw new Error(
8709
- `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)}.`
8710
8977
  );
8711
8978
  }
8712
- } else {
8713
- log(`
8714
- ${provider.id} does not expose an account balance; enforcing the explicit run budget`);
8715
8979
  }
8980
+ const spendSummary = plan.groups.map((group) => `${group.provider} ${formatCost(group.costUnit, group.cost)}`).join("; ");
8716
8981
  const ok = await confirm(
8717
- ` Spend ${formatCost(plan.costUnit, plan.cost)} on ${plan.actionable.length} asset(s)?`,
8982
+ ` Spend ${spendSummary} on ${plan.actionable.length} asset(s)?`,
8718
8983
  args.yes
8719
8984
  );
8720
8985
  if (!ok) {
@@ -8723,69 +8988,104 @@ async function main() {
8723
8988
  }
8724
8989
  log(`
8725
8990
  submitting\u2026`);
8726
- const res = await submit(provider, loaded, plan.actionable, lock, args.lock, {
8727
- budget: args.budget,
8728
- onProgress: log
8729
- });
8730
- log(
8731
- `
8732
- submitted ${res.submitted}, failed ${res.failed}, estimated ${formatCost(res.unit, res.spent)}`
8733
- );
8734
- try {
8735
- const after = balance && provider.balance ? await provider.balance() : null;
8736
- if (!balance || !after) throw new Error("balance reporting is unsupported");
8737
- const measured = measureBalanceChange(balance, after);
8738
- if (measured) {
8739
- const movement = measured.credited ? `${formatCost(measured.unit, measured.credited)} credited` : `${formatCost(measured.unit, measured.spent)} consumed`;
8740
- log(` provider-reported balance change: ${movement}`);
8741
- } else {
8742
- 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
+ );
8743
9016
  }
8744
- } catch (err) {
8745
- log(` provider balance recheck unavailable: ${err instanceof Error ? err.message : String(err)}`);
9017
+ if (res.failed) process.exitCode = 1;
8746
9018
  }
8747
- if (res.failed) process.exitCode = 1;
8748
9019
  }
8749
9020
  if (args.command === "submit") return;
8750
9021
  }
8751
9022
  if (args.command === "poll" || args.command === "gen") {
8752
9023
  log(`
8753
9024
  polling\u2026`);
8754
- const res = await poll(provider, lock, args.lock, { onProgress: log, specs });
8755
- log(`
8756
- ${res.completed} ready \xB7 ${res.review} awaiting selection \xB7 ${res.failed} failed`);
8757
- 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;
8758
9041
  if (args.command === "poll") return;
8759
9042
  }
8760
9043
  if (args.command === "pick" || args.command === "gen") {
8761
- const res = await runPicker(provider, lock, args.lock, {
8762
- port: args.port,
8763
- open: !args.noOpen,
8764
- onProgress: log
8765
- });
8766
- 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) {
8767
9056
  log(` nothing awaiting selection`);
8768
9057
  } else {
8769
9058
  log(`
8770
- selected ${res.selected}, left in review ${res.skipped}`);
9059
+ selected ${total.selected}, left in review ${total.skipped}`);
8771
9060
  }
8772
- if (args.command === "gen" && res.skipped) process.exitCode = 1;
9061
+ if (args.command === "gen" && total.skipped) process.exitCode = 1;
8773
9062
  if (args.command === "pick") return;
8774
9063
  }
8775
9064
  if (args.command === "fetch" || args.command === "restore" || args.command === "gen") {
8776
9065
  log(`
8777
9066
  downloading\u2026`);
8778
- const res = await fetchAssets(provider, specs, lock, args.lock, {
8779
- onProgress: log,
8780
- repair: args.command === "restore"
8781
- });
8782
- log(`
8783
- downloaded ${res.downloaded}, skipped ${res.skipped}, failed ${res.failed}`);
8784
- if (res.failed) process.exitCode = 1;
8785
- if (args.tag) {
8786
- const n = await pushTags(provider, specs, lock, { onProgress: log });
8787
- 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
+ }
8788
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`);
8789
9089
  await saveLock(args.lock, lock);
8790
9090
  log(` lockfile written: ${args.lock}`);
8791
9091
  return;