pixelkiln 0.24.0 → 0.26.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
@@ -4310,14 +4310,6 @@ declare class PixelLabClient {
4310
4310
  }
4311
4311
  declare function clientFromEnv(): PixelLabClient;
4312
4312
 
4313
- /**
4314
- * PixelLab, the reference implementation.
4315
- *
4316
- * All PixelLab-specific knowledge lives here: the three generators and their
4317
- * very different pricing, the review/candidate flow, the fact that a map
4318
- * object's job record expires while its image does not, and that pixflux
4319
- * returns bytes inline instead of a job id.
4320
- */
4321
4313
  declare class PixelLabProvider implements Provider {
4322
4314
  private readonly client;
4323
4315
  readonly id = "pixellab";
@@ -7225,6 +7217,8 @@ interface FetchResult {
7225
7217
  downloaded: number;
7226
7218
  skipped: number;
7227
7219
  failed: number;
7220
+ /** `refresh` only: outputs re-downloaded and found byte-identical upstream. */
7221
+ unchanged?: number;
7228
7222
  }
7229
7223
  /**
7230
7224
  * Downloads every selected object to its manifest-defined path and records the
@@ -7239,6 +7233,13 @@ declare function fetchAssets(provider: Provider, specs: ResolvedSpec[], lock: Lo
7239
7233
  repair?: boolean;
7240
7234
  /** Replace an existing untracked or modified destination. Explicit ownership override. */
7241
7235
  force?: boolean;
7236
+ /**
7237
+ * Re-download `downloaded` outputs from their durable provider reference
7238
+ * and replace the local file when the upstream bytes changed — an object
7239
+ * edited in the provider's own editor, say. Unchanged objects are left
7240
+ * alone; a locally modified file is still refused without `force`.
7241
+ */
7242
+ refresh?: boolean;
7242
7243
  /** Content-addressed media cache. Defaults beside the lockfile; false disables it. */
7243
7244
  cacheDir?: string | false;
7244
7245
  }): Promise<FetchResult>;
@@ -7670,6 +7671,10 @@ interface GalleryItem {
7670
7671
  */
7671
7672
  edit: GalleryOutput | null;
7672
7673
  editStatus: HandEditStatus | null;
7674
+ /** Where the provider's own app shows this object, when it has one. */
7675
+ upstreamUrl: string | null;
7676
+ /** Downloaded work with a durable provider reference; `fetch --refresh` can re-pull it. */
7677
+ refreshable: boolean;
7673
7678
  tags: string[];
7674
7679
  category: string | null;
7675
7680
  }
@@ -7849,8 +7854,8 @@ interface GenerateJob {
7849
7854
  id: string;
7850
7855
  project: string | null;
7851
7856
  keys: string[];
7852
- /** `resume` skips submission: poll, review, and fetch existing work at no cost. */
7853
- mode: "generate" | "resume";
7857
+ /** `resume` skips submission: poll, review, and fetch existing work at no cost; `refresh` re-pulls changed objects. */
7858
+ mode: "generate" | "resume" | "refresh";
7854
7859
  phase: GeneratePhase;
7855
7860
  startedAt: string;
7856
7861
  finishedAt: string | null;
@@ -7911,23 +7916,39 @@ interface GalleryGenerateHandlers {
7911
7916
  }): Promise<ReviewSession | null>;
7912
7917
  applyReview(jobId: string, body: unknown): Promise<PickResult>;
7913
7918
  }
7914
- declare const GenerateRequestSchema: z.ZodObject<{
7919
+ declare const GenerateRequestSchema: z.ZodEffects<z.ZodObject<{
7915
7920
  project: z.ZodOptional<z.ZodString>;
7916
7921
  keys: z.ZodArray<z.ZodString, "many">;
7917
7922
  /** Regenerate work that is up to date, as `gen --force` does. */
7918
7923
  force: z.ZodOptional<z.ZodBoolean>;
7919
7924
  /** Advance in-flight, review, or selected work without submitting anything. */
7920
7925
  resume: z.ZodOptional<z.ZodBoolean>;
7926
+ /** Re-download downloaded outputs and replace files whose object changed upstream. */
7927
+ refresh: z.ZodOptional<z.ZodBoolean>;
7921
7928
  }, "strict", z.ZodTypeAny, {
7922
7929
  keys: string[];
7923
7930
  resume?: boolean | undefined;
7924
7931
  force?: boolean | undefined;
7925
7932
  project?: string | undefined;
7933
+ refresh?: boolean | undefined;
7934
+ }, {
7935
+ keys: string[];
7936
+ resume?: boolean | undefined;
7937
+ force?: boolean | undefined;
7938
+ project?: string | undefined;
7939
+ refresh?: boolean | undefined;
7940
+ }>, {
7941
+ keys: string[];
7942
+ resume?: boolean | undefined;
7943
+ force?: boolean | undefined;
7944
+ project?: string | undefined;
7945
+ refresh?: boolean | undefined;
7926
7946
  }, {
7927
7947
  keys: string[];
7928
7948
  resume?: boolean | undefined;
7929
7949
  force?: boolean | undefined;
7930
7950
  project?: string | undefined;
7951
+ refresh?: boolean | undefined;
7931
7952
  }>;
7932
7953
  declare class GenerateRequestError extends Error {
7933
7954
  readonly status: number;
package/dist/index.d.ts CHANGED
@@ -4310,14 +4310,6 @@ declare class PixelLabClient {
4310
4310
  }
4311
4311
  declare function clientFromEnv(): PixelLabClient;
4312
4312
 
4313
- /**
4314
- * PixelLab, the reference implementation.
4315
- *
4316
- * All PixelLab-specific knowledge lives here: the three generators and their
4317
- * very different pricing, the review/candidate flow, the fact that a map
4318
- * object's job record expires while its image does not, and that pixflux
4319
- * returns bytes inline instead of a job id.
4320
- */
4321
4313
  declare class PixelLabProvider implements Provider {
4322
4314
  private readonly client;
4323
4315
  readonly id = "pixellab";
@@ -7225,6 +7217,8 @@ interface FetchResult {
7225
7217
  downloaded: number;
7226
7218
  skipped: number;
7227
7219
  failed: number;
7220
+ /** `refresh` only: outputs re-downloaded and found byte-identical upstream. */
7221
+ unchanged?: number;
7228
7222
  }
7229
7223
  /**
7230
7224
  * Downloads every selected object to its manifest-defined path and records the
@@ -7239,6 +7233,13 @@ declare function fetchAssets(provider: Provider, specs: ResolvedSpec[], lock: Lo
7239
7233
  repair?: boolean;
7240
7234
  /** Replace an existing untracked or modified destination. Explicit ownership override. */
7241
7235
  force?: boolean;
7236
+ /**
7237
+ * Re-download `downloaded` outputs from their durable provider reference
7238
+ * and replace the local file when the upstream bytes changed — an object
7239
+ * edited in the provider's own editor, say. Unchanged objects are left
7240
+ * alone; a locally modified file is still refused without `force`.
7241
+ */
7242
+ refresh?: boolean;
7242
7243
  /** Content-addressed media cache. Defaults beside the lockfile; false disables it. */
7243
7244
  cacheDir?: string | false;
7244
7245
  }): Promise<FetchResult>;
@@ -7670,6 +7671,10 @@ interface GalleryItem {
7670
7671
  */
7671
7672
  edit: GalleryOutput | null;
7672
7673
  editStatus: HandEditStatus | null;
7674
+ /** Where the provider's own app shows this object, when it has one. */
7675
+ upstreamUrl: string | null;
7676
+ /** Downloaded work with a durable provider reference; `fetch --refresh` can re-pull it. */
7677
+ refreshable: boolean;
7673
7678
  tags: string[];
7674
7679
  category: string | null;
7675
7680
  }
@@ -7849,8 +7854,8 @@ interface GenerateJob {
7849
7854
  id: string;
7850
7855
  project: string | null;
7851
7856
  keys: string[];
7852
- /** `resume` skips submission: poll, review, and fetch existing work at no cost. */
7853
- mode: "generate" | "resume";
7857
+ /** `resume` skips submission: poll, review, and fetch existing work at no cost; `refresh` re-pulls changed objects. */
7858
+ mode: "generate" | "resume" | "refresh";
7854
7859
  phase: GeneratePhase;
7855
7860
  startedAt: string;
7856
7861
  finishedAt: string | null;
@@ -7911,23 +7916,39 @@ interface GalleryGenerateHandlers {
7911
7916
  }): Promise<ReviewSession | null>;
7912
7917
  applyReview(jobId: string, body: unknown): Promise<PickResult>;
7913
7918
  }
7914
- declare const GenerateRequestSchema: z.ZodObject<{
7919
+ declare const GenerateRequestSchema: z.ZodEffects<z.ZodObject<{
7915
7920
  project: z.ZodOptional<z.ZodString>;
7916
7921
  keys: z.ZodArray<z.ZodString, "many">;
7917
7922
  /** Regenerate work that is up to date, as `gen --force` does. */
7918
7923
  force: z.ZodOptional<z.ZodBoolean>;
7919
7924
  /** Advance in-flight, review, or selected work without submitting anything. */
7920
7925
  resume: z.ZodOptional<z.ZodBoolean>;
7926
+ /** Re-download downloaded outputs and replace files whose object changed upstream. */
7927
+ refresh: z.ZodOptional<z.ZodBoolean>;
7921
7928
  }, "strict", z.ZodTypeAny, {
7922
7929
  keys: string[];
7923
7930
  resume?: boolean | undefined;
7924
7931
  force?: boolean | undefined;
7925
7932
  project?: string | undefined;
7933
+ refresh?: boolean | undefined;
7934
+ }, {
7935
+ keys: string[];
7936
+ resume?: boolean | undefined;
7937
+ force?: boolean | undefined;
7938
+ project?: string | undefined;
7939
+ refresh?: boolean | undefined;
7940
+ }>, {
7941
+ keys: string[];
7942
+ resume?: boolean | undefined;
7943
+ force?: boolean | undefined;
7944
+ project?: string | undefined;
7945
+ refresh?: boolean | undefined;
7926
7946
  }, {
7927
7947
  keys: string[];
7928
7948
  resume?: boolean | undefined;
7929
7949
  force?: boolean | undefined;
7930
7950
  project?: string | undefined;
7951
+ refresh?: boolean | undefined;
7931
7952
  }>;
7932
7953
  declare class GenerateRequestError extends Error {
7933
7954
  readonly status: number;
package/dist/index.js CHANGED
@@ -1291,6 +1291,10 @@ import { mkdirSync, existsSync, readFileSync, writeFileSync } from "fs";
1291
1291
  import { randomUUID } from "crypto";
1292
1292
  import os from "os";
1293
1293
  import path from "path";
1294
+ function pixelLabObjectUrl(generator, objectId) {
1295
+ if (!objectId || generator !== "map" && generator !== "1dir") return null;
1296
+ return `https://www.pixellab.ai/objects/${encodeURIComponent(objectId)}`;
1297
+ }
1294
1298
  var PixelLabProvider = class _PixelLabProvider {
1295
1299
  constructor(client) {
1296
1300
  this.client = client;
@@ -7742,14 +7746,16 @@ import path16 from "path";
7742
7746
  async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7743
7747
  const log = opts.onProgress ?? (() => {
7744
7748
  });
7745
- const result = { downloaded: 0, skipped: 0, failed: 0 };
7749
+ const result = { downloaded: 0, skipped: 0, failed: 0, ...opts.refresh ? { unchanged: 0 } : {} };
7746
7750
  const specByKey = new Map(specs.map((s) => [lockKey(s.styleId, s.assetId), s]));
7747
7751
  normalizeLockOutputPaths(lock, specs);
7748
7752
  const cacheDir = opts.cacheDir === false ? null : path16.resolve(opts.cacheDir ?? path16.join(path16.dirname(lockPath), ".pixelkiln", "cache"));
7749
7753
  const pending = Object.entries(lock.entries).filter(([key, e]) => {
7750
7754
  if (e.provider !== provider.id || !specByKey.has(key)) return false;
7751
7755
  if (e.status === "selected" || e.status === "download-failed") return true;
7752
- if (!opts.repair || e.status !== "downloaded") return false;
7756
+ if (e.status !== "downloaded") return false;
7757
+ if (opts.refresh) return e.outputs.length > 0 && Boolean(e.sourceUrls?.length || e.sourceUrl);
7758
+ if (!opts.repair) return false;
7753
7759
  const spec = specByKey.get(key);
7754
7760
  return e.outputs.length === 0 || !spec || e.outputs.some((output) => !existsSync12(resolveOutputPath(output.path, spec.root)));
7755
7761
  });
@@ -7781,6 +7787,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7781
7787
  continue;
7782
7788
  }
7783
7789
  const outputs = [];
7790
+ let wrote = false;
7784
7791
  try {
7785
7792
  for (let index = 0; index < sources.length; index++) {
7786
7793
  const source = sources[index];
@@ -7788,41 +7795,55 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7788
7795
  (o) => source.role ? o.role === source.role : !o.role && sources.length === 1
7789
7796
  );
7790
7797
  const target = recorded ? resolveOutputPath(recorded.path, spec.root) : expectedOutputPath(spec, source.role, index, sources.length, source.mediaType);
7798
+ let fresh = null;
7799
+ if (opts.refresh) {
7800
+ if (!source.url) throw new Error(`no durable source URL to refresh ${source.role ?? "asset"} from`);
7801
+ fresh = await provider.download(source.url);
7802
+ if (recorded && sha256(fresh) === recorded.sha256) {
7803
+ log(` current ${path16.relative(process.cwd(), target)}`);
7804
+ outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
7805
+ continue;
7806
+ }
7807
+ }
7791
7808
  if (existsSync12(target)) {
7792
7809
  const currentHash = await sha256File(target);
7793
7810
  if (recorded && currentHash === recorded.sha256) {
7794
- if (cacheDir) {
7795
- await cacheMedia(
7796
- cacheDir,
7797
- await readFile11(target),
7798
- recorded.mediaType ?? MediaType.PNG,
7799
- recorded.sha256
7800
- );
7811
+ if (!fresh) {
7812
+ if (cacheDir) {
7813
+ await cacheMedia(
7814
+ cacheDir,
7815
+ await readFile11(target),
7816
+ recorded.mediaType ?? MediaType.PNG,
7817
+ recorded.sha256
7818
+ );
7819
+ }
7820
+ outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
7821
+ continue;
7801
7822
  }
7802
- outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
7803
- continue;
7804
- }
7805
- const superseded = (entry.supersededOutputs ?? []).find(
7806
- (output, oldIndex, all) => currentOutputPath(output, spec, oldIndex, all.length) === target
7807
- );
7808
- if (!opts.force) {
7809
- if (!recorded && superseded && currentHash === superseded.sha256) {
7810
- } else if (recorded || superseded) {
7811
- throw new Error(
7812
- `refusing to overwrite modified output ${target}; pass --force to replace it`
7813
- );
7814
- } else {
7815
- throw new Error(
7816
- `refusing to overwrite untracked output ${target}; pass --force to replace it`
7817
- );
7823
+ log(` changed ${path16.relative(process.cwd(), target)} (upstream)`);
7824
+ } else {
7825
+ const superseded = (entry.supersededOutputs ?? []).find(
7826
+ (output, oldIndex, all) => currentOutputPath(output, spec, oldIndex, all.length) === target
7827
+ );
7828
+ if (!opts.force) {
7829
+ if (!recorded && superseded && currentHash === superseded.sha256) {
7830
+ } else if (recorded || superseded) {
7831
+ throw new Error(
7832
+ `refusing to overwrite modified output ${target}; pass --force to replace it`
7833
+ );
7834
+ } else {
7835
+ throw new Error(
7836
+ `refusing to overwrite untracked output ${target}; pass --force to replace it`
7837
+ );
7838
+ }
7818
7839
  }
7840
+ log(
7841
+ ` replace ${path16.relative(process.cwd(), target)}` + (opts.force ? " (--force)" : " (previous tracked generation)")
7842
+ );
7819
7843
  }
7820
- log(
7821
- ` replace ${path16.relative(process.cwd(), target)}` + (opts.force ? " (--force)" : " (previous tracked generation)")
7822
- );
7823
7844
  }
7824
7845
  const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
7825
- let buf = recorded && cacheDir ? await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType) : null;
7846
+ let buf = fresh ?? (recorded && cacheDir ? await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType) : null);
7826
7847
  if (buf) {
7827
7848
  log(` cached ${path16.relative(process.cwd(), target)}`);
7828
7849
  } else {
@@ -7854,8 +7875,13 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7854
7875
  upsert(lock, key, { outputs: mergeOutputs(entry.outputs, outputs) });
7855
7876
  await saveLock(lockPath, lock);
7856
7877
  await rename4(tmp, target);
7878
+ wrote = true;
7857
7879
  log(` wrote ${path16.relative(process.cwd(), target)}`);
7858
7880
  }
7881
+ if (opts.refresh && !wrote) {
7882
+ result.unchanged = (result.unchanged ?? 0) + 1;
7883
+ continue;
7884
+ }
7859
7885
  const persistentSources = sources.filter((source) => shouldPersistSourceUrl(source.url));
7860
7886
  upsert(lock, key, {
7861
7887
  status: "downloaded",
@@ -9739,6 +9765,8 @@ async function buildGallerySnapshot(opts) {
9739
9765
  source: spec.source ?? null,
9740
9766
  edit,
9741
9767
  editStatus,
9768
+ upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
9769
+ refreshable: Boolean(entry && entry.status === "downloaded" && entry.outputs.length && (entry.sourceUrls?.length || entry.sourceUrl)),
9742
9770
  tags: spec.tags,
9743
9771
  category: asset?.category ?? null
9744
9772
  });
@@ -9793,6 +9821,8 @@ async function buildGallerySnapshot(opts) {
9793
9821
  source: null,
9794
9822
  edit: null,
9795
9823
  editStatus: null,
9824
+ upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
9825
+ refreshable: false,
9796
9826
  tags: [],
9797
9827
  category: null
9798
9828
  });
@@ -10464,7 +10494,7 @@ function renderJobs() {
10464
10494
  const row = el('div', 'job');
10465
10495
  const ph = el('span', 'ph');
10466
10496
  ph.append(el('i', 'dot ' + PHASE_TONE[job.phase]), document.createTextNode(job.phase));
10467
- const what = (job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
10497
+ const what = (job.mode === 'refresh' ? 'pull upstream for ' : job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
10468
10498
  (job.project ? ' in ' + job.project : '');
10469
10499
  const last = el('span', 'last', what + (job.messages.length ? ' \u2014 ' + job.messages[job.messages.length - 1].trim() : ''));
10470
10500
  last.title = job.keys.join('\\n');
@@ -10503,13 +10533,15 @@ function budgetLine() {
10503
10533
 
10504
10534
  // The confirm step: what will be sent, what it is estimated to cost, and what
10505
10535
  // this session may still spend. Mirrors gen's "Spend \u2026 on N asset(s)?" prompt.
10506
- function generateDialog(items, { project = null, force = false, resume = false } = {}) {
10536
+ function generateDialog(items, { project = null, force = false, resume = false, refresh = false } = {}) {
10507
10537
  const host = $('dialog-host');
10508
10538
  host.textContent = '';
10509
10539
  const wrap = el('div', 'dialog');
10510
10540
  const form = el('form');
10511
- form.append(el('h3', null, resume ? 'Resume ' + items.length + (items.length === 1 ? ' asset' : ' assets')
10512
- : (force ? 'Regenerate ' : 'Generate ') + items.length + (items.length === 1 ? ' asset' : ' assets')));
10541
+ const noun = items.length + (items.length === 1 ? ' asset' : ' assets');
10542
+ form.append(el('h3', null, refresh ? 'Pull upstream changes for ' + noun
10543
+ : resume ? 'Resume ' + noun
10544
+ : (force ? 'Regenerate ' : 'Generate ') + noun));
10513
10545
  const table = el('table');
10514
10546
  const thead = el('tr'); thead.append(el('th', null, 'asset'), el('th', null, 'state'), el('th', 'n', 'candidates'), el('th', 'n', 'estimate'));
10515
10547
  table.append(thead);
@@ -10517,16 +10549,18 @@ function generateDialog(items, { project = null, force = false, resume = false }
10517
10549
  for (const item of items) {
10518
10550
  const tr = el('tr');
10519
10551
  tr.append(el('td', 'mono', item.key), el('td', null, item.state), el('td', 'n', item.candidates ?? '\u2014'),
10520
- el('td', 'n', resume ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
10552
+ el('td', 'n', resume || refresh ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
10521
10553
  table.append(tr);
10522
- if (!resume) {
10554
+ if (!resume && !refresh) {
10523
10555
  const g = byProvider.get(item.provider) || { cost: 0, unit: item.costUnit };
10524
10556
  g.cost += item.estimatedCost ?? 0; byProvider.set(item.provider, g);
10525
10557
  }
10526
10558
  }
10527
10559
  form.append(table);
10528
10560
  const sum = el('div', 'sum');
10529
- if (resume) {
10561
+ if (refresh) {
10562
+ 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.'));
10563
+ } else if (resume) {
10530
10564
  sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
10531
10565
  } else {
10532
10566
  for (const [provider, g] of byProvider) {
@@ -10543,7 +10577,7 @@ function generateDialog(items, { project = null, force = false, resume = false }
10543
10577
  }
10544
10578
  form.append(sum);
10545
10579
  const actions = el('div', 'actions');
10546
- const go = el('button', 'primary', resume ? 'Resume' : 'Generate');
10580
+ const go = el('button', 'primary', refresh ? 'Pull changes' : resume ? 'Resume' : 'Generate');
10547
10581
  go.type = 'submit';
10548
10582
  const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
10549
10583
  const msg = el('span', 'msg');
@@ -10557,6 +10591,7 @@ function generateDialog(items, { project = null, force = false, resume = false }
10557
10591
  if (project) body.project = project;
10558
10592
  if (force) body.force = true;
10559
10593
  if (resume) body.resume = true;
10594
+ if (refresh) body.refresh = true;
10560
10595
  const job = await postGenerate(body);
10561
10596
  host.textContent = '';
10562
10597
  GEN.jobs.unshift(job);
@@ -10846,6 +10881,13 @@ function renderMain(items) {
10846
10881
  g.onclick = () => generateDialog(snap.items.filter((i) => i.project === s.project && s.actionable.keys.includes(i.key)), { project: s.project });
10847
10882
  tools.append(g);
10848
10883
  }
10884
+ const refreshable = snap.items.filter((i) => i.project === s.project && i.styleId === s.id && i.refreshable && i.upstreamUrl);
10885
+ if (GENERATION && refreshable.length) {
10886
+ const r = el('button', null, 'Pull upstream ' + refreshable.length); r.type = 'button';
10887
+ r.title = 'Re-download objects edited in the provider\u2019s own editor; no cost';
10888
+ r.onclick = () => generateDialog(refreshable, { project: s.project, refresh: true });
10889
+ tools.append(r);
10890
+ }
10849
10891
  const styleKey = 'style:' + (s.project || '') + ':' + s.id;
10850
10892
  if (EDITABLE && s.outDir) {
10851
10893
  const es = el('button', null, ui.editing === styleKey ? 'Cancel' : 'Edit style');
@@ -10939,6 +10981,30 @@ function candidatesControl(style) {
10939
10981
  return wrap;
10940
10982
  }
10941
10983
 
10984
+ function upstreamSection(item) {
10985
+ if (!item.upstreamUrl && !(GENERATION && item.refreshable)) return null;
10986
+ const s = el('section', 'meta');
10987
+ s.append(el('h3', null, 'Upstream'));
10988
+ const dl = el('dl');
10989
+ if (item.upstreamUrl) {
10990
+ const a = el('a', null, 'Open in ' + item.provider + ' \u2197');
10991
+ a.href = item.upstreamUrl; a.target = '_blank'; a.rel = 'noopener';
10992
+ row(dl, 'object', a);
10993
+ row(dl, 'note', item.provider === 'pixellab'
10994
+ ? 'The account object this generation came from. Edit it there with PixelLab\u2019s editor, then pull the changes back here; the lockfile records the new bytes as this generation.'
10995
+ : 'The provider\u2019s page for this object.');
10996
+ }
10997
+ s.append(dl);
10998
+ if (GENERATION && item.refreshable) {
10999
+ const acts = el('div', 'hand-actions');
11000
+ const b = el('button', null, 'Pull upstream changes \xB7 no cost'); b.type = 'button';
11001
+ b.onclick = () => generateDialog([item], { project: item.project, refresh: true });
11002
+ acts.append(b);
11003
+ s.append(acts);
11004
+ }
11005
+ return s;
11006
+ }
11007
+
10942
11008
  function generateActions(item) {
10943
11009
  const row = el('div', 'gen');
10944
11010
  if (!GENERATION || !item.declared || item.currentSpecHash === null) return row;
@@ -11657,6 +11723,8 @@ function renderDrawer() {
11657
11723
 
11658
11724
  const hand = handEditSection(item);
11659
11725
  if (hand) body.append(hand);
11726
+ const upstream = upstreamSection(item);
11727
+ if (upstream) body.append(upstream);
11660
11728
 
11661
11729
  if (item.revision || item.revisionParentKey) {
11662
11730
  const { s, dl } = section('Lineage');
@@ -12150,8 +12218,10 @@ var GenerateRequestSchema = z9.object({
12150
12218
  /** Regenerate work that is up to date, as `gen --force` does. */
12151
12219
  force: z9.boolean().optional(),
12152
12220
  /** Advance in-flight, review, or selected work without submitting anything. */
12153
- resume: z9.boolean().optional()
12154
- }).strict();
12221
+ resume: z9.boolean().optional(),
12222
+ /** Re-download downloaded outputs and replace files whose object changed upstream. */
12223
+ refresh: z9.boolean().optional()
12224
+ }).strict().refine((request) => !(request.resume && request.refresh), { message: "resume and refresh are exclusive" });
12155
12225
  var GenerateRequestError = class extends Error {
12156
12226
  constructor(message6, status = 400) {
12157
12227
  super(message6);
@@ -12209,6 +12279,20 @@ function createGenerateHandlers(opts) {
12209
12279
  }
12210
12280
  return groups;
12211
12281
  };
12282
+ async function refresh(job, ctx, specs) {
12283
+ job.phase = "fetching";
12284
+ for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
12285
+ const provider = opts.providerFor(job.project ?? void 0, providerId);
12286
+ const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
12287
+ onProgress: (line) => say(job, line),
12288
+ refresh: true
12289
+ });
12290
+ job.counts.downloaded += res.downloaded;
12291
+ say(job, `${providerId}: ${res.downloaded} changed upstream and replaced, ${res.unchanged ?? 0} unchanged, ${res.skipped} skipped, ${res.failed} failed`);
12292
+ }
12293
+ job.phase = "done";
12294
+ job.finishedAt = now().toISOString();
12295
+ }
12212
12296
  async function settle(job, ctx, specs) {
12213
12297
  job.phase = "polling";
12214
12298
  for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
@@ -12242,6 +12326,10 @@ function createGenerateHandlers(opts) {
12242
12326
  }
12243
12327
  async function run(job, ctx, specs, groups) {
12244
12328
  try {
12329
+ if (job.mode === "refresh") {
12330
+ await refresh(job, ctx, specs);
12331
+ return;
12332
+ }
12245
12333
  if (job.mode === "generate") {
12246
12334
  job.phase = "submitting";
12247
12335
  for (const group of groups) {
@@ -12305,7 +12393,7 @@ function createGenerateHandlers(opts) {
12305
12393
  if (holder) throw new GenerateRequestError(`"${key}" is already being worked on by job ${holder}`, 409);
12306
12394
  }
12307
12395
  let groups = [];
12308
- if (!request.resume) {
12396
+ if (!request.resume && !request.refresh) {
12309
12397
  const plan = await buildPlan(specs, ctx.lock, { force: request.force });
12310
12398
  if (!plan.actionable.length) {
12311
12399
  const reasons = plan.items.map((item) => `${item.key}: ${item.state} \u2014 ${item.reason}`).slice(0, 5);
@@ -12325,7 +12413,7 @@ function createGenerateHandlers(opts) {
12325
12413
  id: randomBytes2(8).toString("hex"),
12326
12414
  project: request.project ?? null,
12327
12415
  keys: request.keys,
12328
- mode: request.resume ? "resume" : "generate",
12416
+ mode: request.refresh ? "refresh" : request.resume ? "resume" : "generate",
12329
12417
  phase: "queued",
12330
12418
  startedAt: now().toISOString(),
12331
12419
  finishedAt: null,
@@ -12337,7 +12425,7 @@ function createGenerateHandlers(opts) {
12337
12425
  };
12338
12426
  jobs.set(job.id, job);
12339
12427
  contexts.set(job.id, ctx);
12340
- say(job, 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("; "));
12428
+ 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("; "));
12341
12429
  void run(job, ctx, specs, groups);
12342
12430
  return job;
12343
12431
  },