pixelkiln 0.31.0 → 0.32.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/index.d.cts CHANGED
@@ -8312,8 +8312,14 @@ interface GenerateJob {
8312
8312
  id: string;
8313
8313
  project: string | null;
8314
8314
  keys: string[];
8315
- /** `resume` skips submission: poll, review, and fetch existing work at no cost; `refresh` re-pulls changed objects. */
8316
- mode: "generate" | "resume" | "refresh";
8315
+ /**
8316
+ * `resume` skips submission: poll, review, and fetch existing work at no
8317
+ * cost; `refresh` re-pulls changed objects; `restore` puts the recorded
8318
+ * bytes back on disk from the cache or the provider, as `pixelkiln restore`.
8319
+ */
8320
+ mode: "generate" | "resume" | "refresh" | "restore";
8321
+ /** The job may replace a locally modified or untracked file, as `--force` does. */
8322
+ force: boolean;
8317
8323
  phase: GeneratePhase;
8318
8324
  startedAt: string;
8319
8325
  finishedAt: string | null;
@@ -8377,36 +8383,46 @@ interface GalleryGenerateHandlers {
8377
8383
  declare const GenerateRequestSchema: z.ZodEffects<z.ZodObject<{
8378
8384
  project: z.ZodOptional<z.ZodString>;
8379
8385
  keys: z.ZodArray<z.ZodString, "many">;
8380
- /** Regenerate work that is up to date, as `gen --force` does. */
8386
+ /**
8387
+ * Regenerate work that is up to date, as `gen --force` does, and let the
8388
+ * fetch replace a locally modified or untracked file. With `restore`, put
8389
+ * the recorded bytes back over a modified file.
8390
+ */
8381
8391
  force: z.ZodOptional<z.ZodBoolean>;
8382
8392
  /** Advance in-flight, review, or selected work without submitting anything. */
8383
8393
  resume: z.ZodOptional<z.ZodBoolean>;
8384
8394
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
8385
8395
  refresh: z.ZodOptional<z.ZodBoolean>;
8396
+ /** Put recorded outputs back on disk from the cache or the provider, without generating. */
8397
+ restore: z.ZodOptional<z.ZodBoolean>;
8386
8398
  }, "strict", z.ZodTypeAny, {
8387
8399
  keys: string[];
8388
8400
  resume?: boolean | undefined;
8389
8401
  force?: boolean | undefined;
8390
8402
  project?: string | undefined;
8391
8403
  refresh?: boolean | undefined;
8404
+ restore?: boolean | undefined;
8392
8405
  }, {
8393
8406
  keys: string[];
8394
8407
  resume?: boolean | undefined;
8395
8408
  force?: boolean | undefined;
8396
8409
  project?: string | undefined;
8397
8410
  refresh?: boolean | undefined;
8411
+ restore?: boolean | undefined;
8398
8412
  }>, {
8399
8413
  keys: string[];
8400
8414
  resume?: boolean | undefined;
8401
8415
  force?: boolean | undefined;
8402
8416
  project?: string | undefined;
8403
8417
  refresh?: boolean | undefined;
8418
+ restore?: boolean | undefined;
8404
8419
  }, {
8405
8420
  keys: string[];
8406
8421
  resume?: boolean | undefined;
8407
8422
  force?: boolean | undefined;
8408
8423
  project?: string | undefined;
8409
8424
  refresh?: boolean | undefined;
8425
+ restore?: boolean | undefined;
8410
8426
  }>;
8411
8427
  declare class GenerateRequestError extends Error {
8412
8428
  readonly status: number;
package/dist/index.d.ts CHANGED
@@ -8312,8 +8312,14 @@ interface GenerateJob {
8312
8312
  id: string;
8313
8313
  project: string | null;
8314
8314
  keys: string[];
8315
- /** `resume` skips submission: poll, review, and fetch existing work at no cost; `refresh` re-pulls changed objects. */
8316
- mode: "generate" | "resume" | "refresh";
8315
+ /**
8316
+ * `resume` skips submission: poll, review, and fetch existing work at no
8317
+ * cost; `refresh` re-pulls changed objects; `restore` puts the recorded
8318
+ * bytes back on disk from the cache or the provider, as `pixelkiln restore`.
8319
+ */
8320
+ mode: "generate" | "resume" | "refresh" | "restore";
8321
+ /** The job may replace a locally modified or untracked file, as `--force` does. */
8322
+ force: boolean;
8317
8323
  phase: GeneratePhase;
8318
8324
  startedAt: string;
8319
8325
  finishedAt: string | null;
@@ -8377,36 +8383,46 @@ interface GalleryGenerateHandlers {
8377
8383
  declare const GenerateRequestSchema: z.ZodEffects<z.ZodObject<{
8378
8384
  project: z.ZodOptional<z.ZodString>;
8379
8385
  keys: z.ZodArray<z.ZodString, "many">;
8380
- /** Regenerate work that is up to date, as `gen --force` does. */
8386
+ /**
8387
+ * Regenerate work that is up to date, as `gen --force` does, and let the
8388
+ * fetch replace a locally modified or untracked file. With `restore`, put
8389
+ * the recorded bytes back over a modified file.
8390
+ */
8381
8391
  force: z.ZodOptional<z.ZodBoolean>;
8382
8392
  /** Advance in-flight, review, or selected work without submitting anything. */
8383
8393
  resume: z.ZodOptional<z.ZodBoolean>;
8384
8394
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
8385
8395
  refresh: z.ZodOptional<z.ZodBoolean>;
8396
+ /** Put recorded outputs back on disk from the cache or the provider, without generating. */
8397
+ restore: z.ZodOptional<z.ZodBoolean>;
8386
8398
  }, "strict", z.ZodTypeAny, {
8387
8399
  keys: string[];
8388
8400
  resume?: boolean | undefined;
8389
8401
  force?: boolean | undefined;
8390
8402
  project?: string | undefined;
8391
8403
  refresh?: boolean | undefined;
8404
+ restore?: boolean | undefined;
8392
8405
  }, {
8393
8406
  keys: string[];
8394
8407
  resume?: boolean | undefined;
8395
8408
  force?: boolean | undefined;
8396
8409
  project?: string | undefined;
8397
8410
  refresh?: boolean | undefined;
8411
+ restore?: boolean | undefined;
8398
8412
  }>, {
8399
8413
  keys: string[];
8400
8414
  resume?: boolean | undefined;
8401
8415
  force?: boolean | undefined;
8402
8416
  project?: string | undefined;
8403
8417
  refresh?: boolean | undefined;
8418
+ restore?: boolean | undefined;
8404
8419
  }, {
8405
8420
  keys: string[];
8406
8421
  resume?: boolean | undefined;
8407
8422
  force?: boolean | undefined;
8408
8423
  project?: string | undefined;
8409
8424
  refresh?: boolean | undefined;
8425
+ restore?: boolean | undefined;
8410
8426
  }>;
8411
8427
  declare class GenerateRequestError extends Error {
8412
8428
  readonly status: number;
package/dist/index.js CHANGED
@@ -7770,7 +7770,7 @@ async function poll(provider, lock, lockPath, opts = {}) {
7770
7770
  }
7771
7771
 
7772
7772
  // src/pipeline/fetch.ts
7773
- import { existsSync as existsSync13 } from "fs";
7773
+ import { existsSync as existsSync13, readFileSync as readFileSync3 } from "fs";
7774
7774
  import { mkdir as mkdir4, readFile as readFile11, rename as rename4, rm as rm6, writeFile as writeFile4 } from "fs/promises";
7775
7775
  import path16 from "path";
7776
7776
  async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
@@ -7787,7 +7787,12 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7787
7787
  if (opts.refresh) return e.outputs.length > 0 && Boolean(e.sourceUrls?.length || e.sourceUrl);
7788
7788
  if (!opts.repair) return false;
7789
7789
  const spec = specByKey.get(key);
7790
- return e.outputs.length === 0 || !spec || e.outputs.some((output) => !existsSync13(resolveOutputPath(output.path, spec.root)));
7790
+ if (e.outputs.length === 0 || !spec) return true;
7791
+ return e.outputs.some((output) => {
7792
+ const file = resolveOutputPath(output.path, spec.root);
7793
+ if (!existsSync13(file)) return true;
7794
+ return Boolean(opts.force) && sha256(readFileSync3(file)) !== output.sha256;
7795
+ });
7791
7796
  });
7792
7797
  const concurrency = Math.min(Math.max(1, opts.concurrency ?? 8), Math.max(1, pending.length));
7793
7798
  let cursor = 0;
@@ -10604,6 +10609,8 @@ function renderGallery(snapshot, opts = {}) {
10604
10609
  .tray > button { padding:5px 11px; font-size:12.5px; }
10605
10610
  @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host, .sheet.editor-host { width:100vw; } }
10606
10611
  .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
10612
+ .drawer .readonly-hint { margin-top:10px; font-size:12.5px; line-height:1.5; }
10613
+ .drawer .readonly-hint code { font:12px var(--mono); color:var(--text); }
10607
10614
  .pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
10608
10615
  .pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
10609
10616
  background-size:12px 12px; background-position:0 0,6px 6px; display:grid; place-items:center; padding:8px; min-height:96px; }
@@ -10813,7 +10820,7 @@ function renderJobs() {
10813
10820
  const row = el('div', 'job');
10814
10821
  const ph = el('span', 'ph');
10815
10822
  ph.append(el('i', 'dot ' + PHASE_TONE[job.phase]), document.createTextNode(job.phase));
10816
- const what = (job.mode === 'refresh' ? 'pull upstream for ' : job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
10823
+ const what = (job.mode === 'refresh' ? 'pull upstream for ' : job.mode === 'restore' ? 'restore ' : job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
10817
10824
  (job.project ? ' in ' + job.project : '');
10818
10825
  const last = el('span', 'last', what + (job.messages.length ? ' \u2014 ' + job.messages[job.messages.length - 1].trim() : ''));
10819
10826
  last.title = job.keys.join('\\n');
@@ -10938,13 +10945,16 @@ function budgetLine() {
10938
10945
 
10939
10946
  // The confirm step: what will be sent, what it is estimated to cost, and what
10940
10947
  // this session may still spend. Mirrors gen's "Spend \u2026 on N asset(s)?" prompt.
10941
- function generateDialog(items, { project = null, force = false, resume = false, refresh = false } = {}) {
10948
+ function generateDialog(items, { project = null, force = false, resume = false, refresh = false, restore = false } = {}) {
10942
10949
  const host = $('dialog-host');
10943
10950
  host.textContent = '';
10944
10951
  const wrap = el('div', 'dialog');
10945
10952
  const form = el('form');
10946
10953
  const noun = items.length + (items.length === 1 ? ' asset' : ' assets');
10954
+ const free = resume || refresh || restore;
10955
+ const replacing = items.filter((i) => i.state === 'orphaned' && i.outputs.every((o) => o.exists) || i.state === 'untracked');
10947
10956
  form.append(el('h3', null, refresh ? 'Pull upstream changes for ' + noun
10957
+ : restore ? 'Restore ' + noun
10948
10958
  : resume ? 'Resume ' + noun
10949
10959
  : (force ? 'Regenerate ' : 'Generate ') + noun));
10950
10960
  const table = el('table');
@@ -10954,9 +10964,9 @@ function generateDialog(items, { project = null, force = false, resume = false,
10954
10964
  for (const item of items) {
10955
10965
  const tr = el('tr');
10956
10966
  tr.append(el('td', 'mono', item.key), el('td', null, item.state), el('td', 'n', item.candidates ?? '\u2014'),
10957
- el('td', 'n', resume || refresh ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
10967
+ el('td', 'n', free ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
10958
10968
  table.append(tr);
10959
- if (!resume && !refresh) {
10969
+ if (!free) {
10960
10970
  const g = byProvider.get(item.provider) || { cost: 0, unit: item.costUnit };
10961
10971
  g.cost += item.estimatedCost ?? 0; byProvider.set(item.provider, g);
10962
10972
  }
@@ -10965,6 +10975,9 @@ function generateDialog(items, { project = null, force = false, resume = false,
10965
10975
  const sum = el('div', 'sum');
10966
10976
  if (refresh) {
10967
10977
  sum.append(el('div', null, 'Re-downloads each object from the provider and replaces the local file only when the object changed upstream \u2014 for example after editing it in the provider\u2019s own editor. Unchanged objects are left alone; a file you changed locally is refused. Nothing is submitted.'));
10978
+ } else if (restore) {
10979
+ sum.append(el('div', null, 'Puts the recorded bytes back on disk from the local cache or the provider object. Nothing is generated or submitted.'));
10980
+ if (force && replacing.length) sum.append(el('div', 'warn', 'The file on disk was changed after PixelKiln wrote it. Restoring discards that change. To keep it instead, cancel and use Edit by hand or Edit in browser first: the edit is copied beside the generated file, and the record stays intact.'));
10968
10981
  } else if (resume) {
10969
10982
  sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
10970
10983
  } else {
@@ -10977,12 +10990,13 @@ function generateDialog(items, { project = null, force = false, resume = false,
10977
10990
  if (left !== null && g.cost > left) line.className = 'warn';
10978
10991
  sum.append(line);
10979
10992
  }
10980
- if (force) sum.append(el('div', 'warn', 'Regenerating replaces the current art. The previous file is replaced when the new result is fetched; the provider objects it came from are not deleted.'));
10993
+ if (force && replacing.length) sum.append(el('div', 'warn', (replacing.length === 1 ? 'This file' : replacing.length + ' of these files') + ' changed on disk after PixelKiln wrote it (or was never recorded). Generating replaces it and that change is lost. To keep it, cancel and use Edit by hand or Edit in browser first.'));
10994
+ else if (force) sum.append(el('div', 'warn', 'Regenerating replaces the current art. The previous file is replaced when the new result is fetched; the provider objects it came from are not deleted.'));
10981
10995
  sum.append(el('div', null, 'Candidate sets land in review; you choose from them here before anything is downloaded.'));
10982
10996
  }
10983
10997
  form.append(sum);
10984
10998
  const actions = el('div', 'actions');
10985
- const go = el('button', 'primary', refresh ? 'Pull changes' : resume ? 'Resume' : 'Generate');
10999
+ const go = el('button', 'primary', refresh ? 'Pull changes' : restore ? (force && replacing.length ? 'Restore and discard my change' : 'Restore') : resume ? 'Resume' : force && replacing.length ? 'Generate and replace' : 'Generate');
10986
11000
  go.type = 'submit';
10987
11001
  const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
10988
11002
  const msg = el('span', 'msg');
@@ -10997,6 +11011,7 @@ function generateDialog(items, { project = null, force = false, resume = false,
10997
11011
  if (force) body.force = true;
10998
11012
  if (resume) body.resume = true;
10999
11013
  if (refresh) body.refresh = true;
11014
+ if (restore) body.restore = true;
11000
11015
  const job = await postGenerate(body);
11001
11016
  host.textContent = '';
11002
11017
  GEN.jobs.unshift(job);
@@ -11655,6 +11670,13 @@ function generateActions(item) {
11655
11670
  };
11656
11671
  if (item.state === 'missing' || item.state === 'stale' || item.state === 'failed') add('Generate' + cost, 'primary', {});
11657
11672
  else if (item.state === 'ok') add('Regenerate' + cost, null, { force: true });
11673
+ else if (item.state === 'orphaned' && item.outputs.every((o) => o.exists)) {
11674
+ // The file changed after download. Putting the recorded bytes back and
11675
+ // regenerating both discard that change, so both say so and ask.
11676
+ add('Restore recorded bytes \xB7 no cost', null, { restore: true, force: true });
11677
+ add('Regenerate' + cost, null, { force: true });
11678
+ } else if (item.state === 'orphaned') add('Restore \xB7 no cost', 'primary', { restore: true });
11679
+ else if (item.state === 'untracked') add('Generate' + cost + ' \xB7 replaces the file', null, { force: true });
11658
11680
  if (item.state === 'in-flight' || item.state === 'recoverable') {
11659
11681
  // A job already parked this asset in review: go straight to its sheet.
11660
11682
  const holder = GEN.jobs.find((job) => job.phase === 'review' && job.project === item.project && job.review.includes(item.key));
@@ -12345,6 +12367,15 @@ function renderDrawer() {
12345
12367
  if (ui.notice && ui.notice.id === item.id) body.append(el('div', 'notice', ui.notice.text));
12346
12368
  body.append(stateNode(item.state, item.reason));
12347
12369
  if (GENERATION) body.append(generateActions(item));
12370
+ // Writes are opt-in flags on the command line; say so where the buttons would be.
12371
+ if ((!EDITABLE || !GENERATION) && item.declared) {
12372
+ const missing = [];
12373
+ if (!EDITABLE) missing.push('--edit to change its prompt, size, and tags or open it in the browser editor');
12374
+ if (!GENERATION) missing.push('--budget <n> to generate or regenerate it from here');
12375
+ const hint = el('div', 'state-dim readonly-hint');
12376
+ hint.append(document.createTextNode('This gallery is read-only. Restart it with '), el('code', null, 'pixelkiln gallery'), document.createTextNode(' plus ' + missing.join(', and ') + '.'));
12377
+ body.append(hint);
12378
+ }
12348
12379
  if (canEdit && ui.editing === item.id) body.append(editForm(item));
12349
12380
  // Plan already quotes the error as the reason for a failed entry; only a
12350
12381
  // stale or superseded failure needs its own line.
@@ -13233,13 +13264,21 @@ import { z as z10 } from "zod";
13233
13264
  var GenerateRequestSchema = z10.object({
13234
13265
  project: z10.string().min(1).optional(),
13235
13266
  keys: z10.array(z10.string().min(1)).min(1).max(500),
13236
- /** Regenerate work that is up to date, as `gen --force` does. */
13267
+ /**
13268
+ * Regenerate work that is up to date, as `gen --force` does, and let the
13269
+ * fetch replace a locally modified or untracked file. With `restore`, put
13270
+ * the recorded bytes back over a modified file.
13271
+ */
13237
13272
  force: z10.boolean().optional(),
13238
13273
  /** Advance in-flight, review, or selected work without submitting anything. */
13239
13274
  resume: z10.boolean().optional(),
13240
13275
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
13241
- refresh: z10.boolean().optional()
13242
- }).strict().refine((request) => !(request.resume && request.refresh), { message: "resume and refresh are exclusive" });
13276
+ refresh: z10.boolean().optional(),
13277
+ /** Put recorded outputs back on disk from the cache or the provider, without generating. */
13278
+ restore: z10.boolean().optional()
13279
+ }).strict().refine((request) => [request.resume, request.refresh, request.restore].filter(Boolean).length <= 1, {
13280
+ message: "resume, refresh, and restore are exclusive"
13281
+ });
13243
13282
  var GenerateRequestError = class extends Error {
13244
13283
  constructor(message6, status = 400) {
13245
13284
  super(message6);
@@ -13327,7 +13366,9 @@ function createGenerateHandlers(opts) {
13327
13366
  for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
13328
13367
  const provider = opts.providerFor(job.project ?? void 0, providerId);
13329
13368
  const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
13330
- onProgress: (line) => say(job, line)
13369
+ onProgress: (line) => say(job, line),
13370
+ // A forced regeneration replaces whatever is on disk, as `gen --force`.
13371
+ force: job.force
13331
13372
  });
13332
13373
  job.counts.downloaded += res.downloaded;
13333
13374
  if (res.downloaded || res.failed) {
@@ -13342,12 +13383,32 @@ function createGenerateHandlers(opts) {
13342
13383
  job.finishedAt = now().toISOString();
13343
13384
  }
13344
13385
  }
13386
+ async function restore(job, ctx, specs) {
13387
+ job.phase = "fetching";
13388
+ for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
13389
+ const provider = opts.providerFor(job.project ?? void 0, providerId);
13390
+ const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
13391
+ onProgress: (line) => say(job, line),
13392
+ repair: true,
13393
+ force: job.force
13394
+ });
13395
+ job.counts.downloaded += res.downloaded;
13396
+ say(job, `${providerId}: restored ${res.downloaded}, skipped ${res.skipped}, failed ${res.failed}`);
13397
+ if (res.failed) throw new Error(`${res.failed} file(s) could not be restored`);
13398
+ }
13399
+ job.phase = "done";
13400
+ job.finishedAt = now().toISOString();
13401
+ }
13345
13402
  async function run(job, ctx, specs, groups) {
13346
13403
  try {
13347
13404
  if (job.mode === "refresh") {
13348
13405
  await refresh(job, ctx, specs);
13349
13406
  return;
13350
13407
  }
13408
+ if (job.mode === "restore") {
13409
+ await restore(job, ctx, specs);
13410
+ return;
13411
+ }
13351
13412
  if (job.mode === "generate") {
13352
13413
  job.phase = "submitting";
13353
13414
  for (const group of groups) {
@@ -13410,8 +13471,16 @@ function createGenerateHandlers(opts) {
13410
13471
  const holder = busy.get(`${request.project ?? ""}:${key}`);
13411
13472
  if (holder) throw new GenerateRequestError(`"${key}" is already being worked on by job ${holder}`, 409);
13412
13473
  }
13474
+ if (request.restore) {
13475
+ for (const key of request.keys) {
13476
+ const entry = ctx.lock.entries[key];
13477
+ if (!entry || entry.status !== "downloaded" || !entry.outputs.length) {
13478
+ throw new GenerateRequestError(`"${key}" has no downloaded output recorded to restore`);
13479
+ }
13480
+ }
13481
+ }
13413
13482
  let groups = [];
13414
- if (!request.resume && !request.refresh) {
13483
+ if (!request.resume && !request.refresh && !request.restore) {
13415
13484
  const plan = await buildPlan(specs, ctx.lock, { force: request.force });
13416
13485
  if (!plan.actionable.length) {
13417
13486
  const reasons = plan.items.map((item) => `${item.key}: ${item.state} \u2014 ${item.reason}`).slice(0, 5);
@@ -13431,7 +13500,8 @@ function createGenerateHandlers(opts) {
13431
13500
  id: randomBytes2(8).toString("hex"),
13432
13501
  project: request.project ?? null,
13433
13502
  keys: request.keys,
13434
- mode: request.refresh ? "refresh" : request.resume ? "resume" : "generate",
13503
+ mode: request.refresh ? "refresh" : request.resume ? "resume" : request.restore ? "restore" : "generate",
13504
+ force: Boolean(request.force),
13435
13505
  phase: "queued",
13436
13506
  startedAt: now().toISOString(),
13437
13507
  finishedAt: null,
@@ -13443,7 +13513,7 @@ function createGenerateHandlers(opts) {
13443
13513
  };
13444
13514
  jobs.set(job.id, job);
13445
13515
  contexts.set(job.id, ctx);
13446
- say(job, request.refresh ? `checking ${request.keys.length} asset(s) upstream at no cost` : request.resume ? `resuming ${request.keys.length} asset(s) at no cost` : `generating ${groups.reduce((n, g) => n + g.actionable.length, 0)} asset(s): ` + groups.map((g) => `${g.provider} ${formatCost(g.costUnit, g.cost)}`).join("; "));
13516
+ say(job, request.refresh ? `checking ${request.keys.length} asset(s) upstream at no cost` : request.restore ? `restoring ${request.keys.length} asset(s) at no cost${request.force ? " (replacing modified files)" : ""}` : request.resume ? `resuming ${request.keys.length} asset(s) at no cost` : `generating ${groups.reduce((n, g) => n + g.actionable.length, 0)} asset(s): ` + groups.map((g) => `${g.provider} ${formatCost(g.costUnit, g.cost)}`).join("; "));
13447
13517
  void run(job, ctx, specs, groups);
13448
13518
  return job;
13449
13519
  },