@koda-sl/baker-cli 0.114.1 → 0.115.0-dev.3bcc79f9c

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/README.md CHANGED
@@ -653,6 +653,56 @@ baker ads google library search-competitors "crm software" --location uk
653
653
 
654
654
  ---
655
655
 
656
+ ### Staged writes (`baker ads google budgets|campaigns|...`)
657
+
658
+ Write commands **never touch the Google Ads API at stage time**. Each command stages a create/update/pause/resume/remove op against the current chat's draft (`BAKER_CHAT_ID`); the dashboard shows it as a pending "Google Ads" change, and the whole draft applies as one atomic `GoogleAdsService.Mutate` when the chat is published. Feature-flagged per company (`companies.googleAdsWriteEnabled`) — off by default = a fully simulated publish with zero real API calls.
659
+
660
+ Reference not-yet-created resources with `g_temp_*` refs returned by earlier `create` commands (they map to Google's temporary negative-ID resource names inside one atomic batch).
661
+
662
+ ```bash
663
+ # Build a Search campaign bottom-up, chaining temp refs
664
+ baker ads google budgets create --customer-id 1234567890 --name "Search budget" --amount 50
665
+ # → { ref: "g_temp_ab12", ... }
666
+ baker ads google campaigns create --customer-id 1234567890 --name "Brand — Search" \
667
+ --channel-type SEARCH --budget-ref g_temp_ab12 --bidding-strategy MANUAL_CPC
668
+ baker ads google ad-groups create --customer-id 1234567890 --name "Brand terms" --campaign-ref g_temp_<campaign>
669
+ baker ads google keywords add --customer-id 1234567890 --ad-group-ref g_temp_<adgroup> --text "brand name" --match-type EXACT
670
+ # Shared negative keyword list → attach to the campaign
671
+ baker ads google keyword-lists create --customer-id 1234567890 --name "Brand exclusions"
672
+ baker ads google keyword-lists add --customer-id 1234567890 --list-ref g_temp_<list> --text "free" --match-type BROAD
673
+ baker ads google keyword-lists attach --customer-id 1234567890 --campaign-ref g_temp_<campaign> --list-ref g_temp_<list>
674
+
675
+ # Batch keywords — one command stages the whole set as ONE request (up to 500)
676
+ baker ads google keywords add --customer-id 1234567890 --ad-group-ref g_temp_<adgroup> \
677
+ --text "brand name, brand shop:PHRASE, brand store" --match-type EXACT
678
+ baker ads google keyword-lists add --customer-id 1234567890 --list-ref <list-id> --file negatives.txt
679
+ # negatives.txt: one keyword[:MATCH_TYPE] per line; blank lines and "# comments" are skipped
680
+ # Responsive search ad
681
+ baker ads google ads create --customer-id 1234567890 --ad-group-ref g_temp_<adgroup> \
682
+ --headlines "Fast Widgets,Buy Online,Free Shipping" --descriptions "Best widgets around.,Ships tomorrow." \
683
+ --final-url https://example.com
684
+ # Responsive display ad — images are fields on the ad; register each image as an asset first (returns a g_temp_* ref)
685
+ baker ads google assets create --customer-id 1234567890 --file '{"type":"image","imageId":"<baker_image_id>","name":"Hero"}'
686
+ baker ads google ads create --customer-id 1234567890 --ad-group-ref g_temp_<adgroup> --format responsiveDisplay \
687
+ --headlines "Come back and save" --long-headline "Finish signing up and get 20% off" \
688
+ --descriptions "Fast setup, no card required" --business-name "Acme" --final-url https://example.com \
689
+ --marketing-images g_temp_<img_landscape> --square-marketing-images g_temp_<img_square> --logo-images g_temp_<img_logo>
690
+
691
+ # Review / undo staged changes
692
+ baker ads google draft list # readable campaign ▸ ad group ▸ ad tree + completeness advisories
693
+ baker ads google draft list --json # raw JSON envelope for scripting
694
+ baker ads google draft remove g_temp_ab12 # cascades to dependents
695
+ baker ads google draft clear
696
+ ```
697
+
698
+ Command groups: `budgets`, `campaigns`, `ad-groups`, `keywords` (add/update/remove), `negative-keywords`, `keyword-lists`, `ads`, `assets`, `audiences`, `conversions`, `bidding-strategies`, `labels`, `campaign-criteria`, and `draft`. Amounts are in major currency units (converted to micros). Money/bids: `--amount`, `--cpc-bid`, `--target-cpa` take major units; `--target-roas` a ratio. Less-common ops accept a `--file <payload.json>` (flags override file keys). Updates target a resource name or bare id as the positional argument; a target that names an op staged earlier **amends it in place**.
699
+
700
+ **Reviewing the draft** — `baker ads google draft list` renders everything you've staged as a grouped campaign ▸ ad group ▸ ad tree (with the simulated/live mode banner and non-blocking completeness advisories), the CLI counterpart to the dashboard's Google Ads tab. Pass `--json` for the raw envelope. Aim for a fully built campaign — 2–4 ad groups, ≥5 keywords each, 2–4 RSAs with 8–12 headlines, ≥4 sitelinks / ≥3 callouts / ≥1 structured snippet, and ≥1 shared negative list; the advisories flag what's still thin.
701
+
702
+ **Batch keyword adds** — `keywords add`, `negative-keywords add`, and `keyword-lists add` take a whole batch in one command: comma-separate `--text` entries and/or pass `--file <list.txt>` (one keyword per line). A `:EXACT`/`:PHRASE`/`:BROAD` suffix per entry overrides the `--match-type` default. Batches stage all-or-nothing as one request (limit 500); each keyword still lands as its own draft op, so it stays individually removable/amendable.
703
+
704
+ ---
705
+
656
706
  ### Caching
657
707
 
658
708
  Google Ads data is cached at two levels:
@@ -2549,6 +2599,17 @@ baker canvas run my-canvas.json
2549
2599
  # single node — that old serial workaround is obsolete.
2550
2600
  baker canvas run my-canvas.json --parallel 8
2551
2601
 
2602
+ # 2c. Runs persist across sandboxes/sessions by default: node results sync to a
2603
+ # company-scoped remote cache (small JSON pointers; bytes stay in R2), so a FRESH
2604
+ # sandbox re-runs an already-computed canvas at zero credits — assets rehydrate
2605
+ # from R2, sha-verified. Every run also posts a durable history record (per-node
2606
+ # outputs, credits, cached/fresh) that powers the dashboard's Creatives
2607
+ # generations timeline. Opt out with --remote-cache off (env
2608
+ # BAKER_CANVAS_REMOTE_CACHE=off) and --no-record. With --remote-cache off,
2609
+ # assets are not uploaded, so a recorded run keeps its stats but has no
2610
+ # browsable outputs — pass --no-record too if you want nothing persisted.
2611
+ baker canvas run my-canvas.json --remote-cache off --no-record
2612
+
2552
2613
  # 3. Inspect a finished run (per-node timing, file list, optional video thumbs)
2553
2614
  baker canvas inspect <run_id>
2554
2615
 
@@ -3852,6 +3913,7 @@ baker canvas run ./reference-ad.video.canvas.json
3852
3913
  | Flag | Default | Effect |
3853
3914
  |---|---|---|
3854
3915
  | `--out <path>` | `<video-dir>/<name>.video.canvas.json` | Where to write the canvas (composition is copied alongside). |
3916
+ | `--slug <slug>` | — | Creative slug (lowercase kebab): writes the canvas to `src/creatives/<slug>/<slug>.canvas.json` — the repo convention that attaches every run to the creative's dashboard generation history. `--out` wins over `--slug`. |
3855
3917
  | `--frames <mode>` | `generate` | `generate` emits ONE recast keyframe per scene (the original frame is dropped so the dropped `el_*` assets drive identity); `reuse` wires the real extracted first+last frames straight into the clips (faithful, cheaper, no recast). |
3856
3918
  | `--ambient` | off | Give silent **b-roll** scenes native diegetic ambient (Seedance `generate_audio`), mixed deep under the music bed. Talking scenes already carry voice; check levels don't muddy the mix before keeping it. |
3857
3919
  | `--max-scenes <n>` | all source scenes | **Cost lever that reduces fidelity** — caps the deconstruct, MERGING away every scene beyond the cap (fewer cuts, lost beats). Prints a warning when set; omit it to reproduce every scene. |
@@ -3893,6 +3955,7 @@ baker canvas run ./static-ad.canvas.json
3893
3955
  |---|---|---|
3894
3956
  | `--context <text>` | — | Known provenance (advertiser, category, market) to ground the describe. |
3895
3957
  | `--out <path>` | `<image-dir>/static-ad.canvas.json` (cwd when `<image>` is a URL) | Where to write the canvas (`prompt.json` is written alongside). |
3958
+ | `--slug <slug>` | — | Creative slug (lowercase kebab): writes the canvas to `src/creatives/<slug>/<slug>.canvas.json` — the repo convention that attaches every run to the creative's dashboard generation history. `--out` wins over `--slug`. |
3896
3959
  | `--describe-model <id>` | registry default (`~google/gemini-pro-latest`) | Override the `image_describe` model. |
3897
3960
  | `--select-model <id>` | registry default (`~google/gemini-flash-latest`) | Override the element-selection `text_generate` model. |
3898
3961
  | `--layout-model <id>` | registry default (`~google/gemini-flash-latest`) | Override the global-layout `text_generate` model. |
@@ -4364,6 +4427,26 @@ import {
4364
4427
  } from "@koda-sl/baker-cli/engine";
4365
4428
  ```
4366
4429
 
4430
+ ## Creatives
4431
+
4432
+ Publish an approved canvas render as a first-class Baker creative. The image uploads to the Baker image library (tagged `creative`), a creative record is created/updated, and the command prints the creative reference JSON the dashboard renders in chat.
4433
+
4434
+ ```bash
4435
+ baker creatives publish ./canvas/<run_id>/<final>.png --title "Spring Offer 4x5" \
4436
+ --slug spring-offer-4x5 --run-id r_01JXYZ... \
4437
+ --source-reference-url "https://www.facebook.com/ads/library/?id=..."
4438
+ ```
4439
+
4440
+ | Flag | Effect |
4441
+ |---|---|
4442
+ | `--title <text>` | Required. Human title for the creative. |
4443
+ | `--slug <slug>` | Creative slug (`src/creatives/<slug>/`) — attaches the image to that creative's row, marks it `published`. |
4444
+ | `--run-id <r_…>` | Pins the approved generation from the creative's run history as the published one. |
4445
+ | `--source-reference-url <url>` | Original reference ad URL, recorded on the creative. |
4446
+ | `--context <text>` | Optional describe context for the uploaded image asset. |
4447
+
4448
+ Without `--slug` the command behaves as before (one creative record per published image). With `--slug` it upserts the repo-convention row — the same one the dashboard's Creatives tab and the `src/creatives/{slug}/` folder describe — so publish, repo sync, and run history all land on a single record regardless of order.
4449
+
4367
4450
  ## Help & Discovery
4368
4451
 
4369
4452
  Every command supports `--help` for usage info:
@@ -24,9 +24,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  mod
25
25
  ));
26
26
 
27
- // ../../node_modules/.pnpm/safe-stable-stringify@2.5.0/node_modules/safe-stable-stringify/index.js
27
+ // ../../.pnpm-store/v10/links/@/safe-stable-stringify/2.5.0/810146e81bae4e3a061fe487864f2fde80c4b03b886877dc0f1fffbc6480b67e/node_modules/safe-stable-stringify/index.js
28
28
  var require_safe_stable_stringify = __commonJS({
29
- "../../node_modules/.pnpm/safe-stable-stringify@2.5.0/node_modules/safe-stable-stringify/index.js"(exports, module) {
29
+ "../../.pnpm-store/v10/links/@/safe-stable-stringify/2.5.0/810146e81bae4e3a061fe487864f2fde80c4b03b886877dc0f1fffbc6480b67e/node_modules/safe-stable-stringify/index.js"(exports, module) {
30
30
  "use strict";
31
31
  var { hasOwnProperty } = Object.prototype;
32
32
  var stringify = configure2();
@@ -159,9 +159,9 @@ var require_safe_stable_stringify = __commonJS({
159
159
  }
160
160
  if (value) {
161
161
  return (value2) => {
162
- let message = `Object can not safely be stringified. Received type ${typeof value2}`;
163
- if (typeof value2 !== "function") message += ` (${value2.toString()})`;
164
- throw new Error(message);
162
+ let message2 = `Object can not safely be stringified. Received type ${typeof value2}`;
163
+ if (typeof value2 !== "function") message2 += ` (${value2.toString()})`;
164
+ throw new Error(message2);
165
165
  };
166
166
  }
167
167
  }
@@ -673,6 +673,9 @@ var HttpClient = class {
673
673
  async postJson(path16, body, signal) {
674
674
  return await this.requestJson("POST", path16, body, signal);
675
675
  }
676
+ async putJson(path16, body, signal) {
677
+ return await this.requestJson("PUT", path16, body, signal);
678
+ }
676
679
  async getJson(path16, signal) {
677
680
  return await this.requestJson("GET", path16, void 0, signal);
678
681
  }
@@ -700,8 +703,8 @@ var HttpClient = class {
700
703
  try {
701
704
  const res = await this.fetchFn(url, {
702
705
  method,
703
- headers: method === "POST" ? { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` } : { Authorization: `Bearer ${this.apiKey}` },
704
- body: method === "POST" ? JSON.stringify(body) : void 0,
706
+ headers: method === "GET" ? { Authorization: `Bearer ${this.apiKey}` } : { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` },
707
+ body: method === "GET" ? void 0 : JSON.stringify(body),
705
708
  signal: controller.signal
706
709
  });
707
710
  if (res.ok) return { kind: "value", value: await res.json() };
@@ -738,33 +741,33 @@ async function parseErrorBody(res) {
738
741
  const errObj = body.error ?? {};
739
742
  return classifyHttpError(res.status, errObj, errObj.message ?? `HTTP ${res.status}`);
740
743
  }
741
- function classifyHttpError(status, errObj, message) {
744
+ function classifyHttpError(status, errObj, message2) {
742
745
  if (errObj.code === CONTENT_POLICY_CODE) {
743
- return { kind: "content_policy", status, provider: errObj.provider, message };
746
+ return { kind: "content_policy", status, provider: errObj.provider, message: message2 };
744
747
  }
745
748
  if (status === 401 || status === 403) {
746
- return { kind: "unauthorized", status, message };
749
+ return { kind: "unauthorized", status, message: message2 };
747
750
  }
748
751
  if (status === 400 || status === 422) {
749
- return { kind: "validation", status, message, details: errObj.details };
752
+ return { kind: "validation", status, message: message2, details: errObj.details };
750
753
  }
751
754
  if (status === 502 || status === 504) {
752
755
  if (errObj.code === "provider_timeout" || status === 504) {
753
- return { kind: "timeout", provider: errObj.provider, message };
756
+ return { kind: "timeout", provider: errObj.provider, message: message2 };
754
757
  }
755
758
  return {
756
759
  kind: "provider",
757
760
  status,
758
761
  provider: errObj.provider,
759
762
  code: errObj.code ?? "provider_error",
760
- message,
763
+ message: message2,
761
764
  retryable: errObj.retryable ?? true
762
765
  };
763
766
  }
764
767
  if (status >= 500 || status === 429) {
765
- return { kind: "server", status, message };
768
+ return { kind: "server", status, message: message2 };
766
769
  }
767
- return { kind: "validation", status, message, details: errObj.details };
770
+ return { kind: "validation", status, message: message2, details: errObj.details };
768
771
  }
769
772
  function backoffMs(attempt) {
770
773
  return 1e3 * 2 ** attempt;
@@ -835,6 +838,27 @@ var BackendClient = class {
835
838
  signal
836
839
  );
837
840
  }
841
+ /** Remote cache lookup. A miss (404) — or an old backend without the route — returns null. */
842
+ async getCacheEntry(cacheKey, signal) {
843
+ try {
844
+ const res = await this.http.getJson(`/api/canvas/cache/${encodeURIComponent(cacheKey)}`, signal);
845
+ return res.entry;
846
+ } catch (e) {
847
+ if (e instanceof BackendHttpError && "status" in e.detail && e.detail.status === 404) return null;
848
+ throw e;
849
+ }
850
+ }
851
+ async putCacheEntry(entry, signal) {
852
+ await this.http.putJson(
853
+ `/api/canvas/cache/${encodeURIComponent(entry.cacheKey)}`,
854
+ entry,
855
+ signal
856
+ );
857
+ }
858
+ /** Durable run-history record — POST /api/canvas/runs (idempotent server-side on runId). */
859
+ async recordRun(payload, signal) {
860
+ await this.http.postJson("/api/canvas/runs", payload, signal);
861
+ }
838
862
  getArtifact(kind, name, version, signal) {
839
863
  const path16 = version ? `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}` : `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
840
864
  return this.http.getJson(path16, signal);
@@ -858,14 +882,17 @@ function requireCredentialsFromEnv(env = process.env) {
858
882
  }
859
883
  return c;
860
884
  }
885
+ function remoteCacheEnabledFromEnv(env = process.env) {
886
+ return env.BAKER_CANVAS_REMOTE_CACHE !== "off";
887
+ }
861
888
 
862
889
  // src/engine/engine/errors.ts
863
890
  function isBlocking(issue) {
864
891
  return issue.severity !== "warning";
865
892
  }
866
893
  var CanvasError = class extends Error {
867
- constructor(message) {
868
- super(message);
894
+ constructor(message2) {
895
+ super(message2);
869
896
  this.name = "CanvasError";
870
897
  }
871
898
  };
@@ -922,7 +949,7 @@ function describeCause(c) {
922
949
  }
923
950
  }
924
951
 
925
- // ../../node_modules/.pnpm/safe-stable-stringify@2.5.0/node_modules/safe-stable-stringify/esm/wrapper.js
952
+ // ../../.pnpm-store/v10/links/@/safe-stable-stringify/2.5.0/810146e81bae4e3a061fe487864f2fde80c4b03b886877dc0f1fffbc6480b67e/node_modules/safe-stable-stringify/esm/wrapper.js
926
953
  var import__ = __toESM(require_safe_stable_stringify(), 1);
927
954
  var configure = import__.default.configure;
928
955
  var wrapper_default = import__.default;
@@ -1583,6 +1610,158 @@ function encodeRandom() {
1583
1610
  return out;
1584
1611
  }
1585
1612
 
1613
+ // src/engine/storage/remote-cache-store.ts
1614
+ var CANVAS_ASSETS_URL_SEGMENT = "/canvas-assets/";
1615
+ function isPersistedAssetRef(ref) {
1616
+ return typeof ref.url === "string" && ref.url.includes(`${CANVAS_ASSETS_URL_SEGMENT}${ref.sha256}`);
1617
+ }
1618
+ function isAssetRefLike(value) {
1619
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.sha256 === "string" && typeof value.mime === "string";
1620
+ }
1621
+ function collectAssetRefLikes(value, out = []) {
1622
+ if (Array.isArray(value)) {
1623
+ for (const item of value) collectAssetRefLikes(item, out);
1624
+ return out;
1625
+ }
1626
+ if (typeof value !== "object" || value === null) return out;
1627
+ if (isAssetRefLike(value)) {
1628
+ out.push(value);
1629
+ }
1630
+ for (const item of Object.values(value)) collectAssetRefLikes(item, out);
1631
+ return out;
1632
+ }
1633
+ function entryFullyPersisted(entry) {
1634
+ return collectAssetRefLikes(entry.outputs).every((ref) => isPersistedAssetRef(ref));
1635
+ }
1636
+ function stripLocalFields(entry) {
1637
+ const clone = JSON.parse(JSON.stringify(entry));
1638
+ for (const ref of collectAssetRefLikes(clone.outputs)) {
1639
+ delete ref.path;
1640
+ delete ref.bytes;
1641
+ }
1642
+ return clone;
1643
+ }
1644
+ var RemoteCacheStore = class {
1645
+ client;
1646
+ log;
1647
+ constructor(client, log) {
1648
+ this.client = client;
1649
+ this.log = log ?? (() => void 0);
1650
+ }
1651
+ async get(cacheKey) {
1652
+ return await this.client.getCacheEntry(cacheKey);
1653
+ }
1654
+ async put(entry) {
1655
+ if (!entryFullyPersisted(entry)) {
1656
+ this.log(`[cache ] ${entry.cacheKey.slice(0, 12)}\u2026 has local-only assets, kept local`);
1657
+ return;
1658
+ }
1659
+ const stripped = stripLocalFields(entry);
1660
+ if (stripped.refs.length > MAX_REMOTE_REFS) {
1661
+ stripped.refs = stripped.refs.slice(0, MAX_REMOTE_REFS);
1662
+ }
1663
+ await this.client.putCacheEntry(stripped);
1664
+ }
1665
+ };
1666
+ var MAX_REMOTE_REFS = 512;
1667
+ var LayeredCacheStore = class {
1668
+ rootDir;
1669
+ local;
1670
+ remote;
1671
+ assets;
1672
+ log;
1673
+ constructor(opts) {
1674
+ this.local = opts.local;
1675
+ this.remote = opts.remote;
1676
+ this.assets = opts.assets;
1677
+ this.rootDir = opts.local.rootDir;
1678
+ this.log = opts.log ?? (() => void 0);
1679
+ }
1680
+ async get(cacheKey) {
1681
+ const localHit = await this.local.get(cacheKey);
1682
+ if (localHit) return localHit;
1683
+ let remoteEntry;
1684
+ try {
1685
+ remoteEntry = await this.remote.get(cacheKey);
1686
+ } catch (e) {
1687
+ this.log(`[cache ] remote lookup failed (${message(e)}) \u2014 treating as miss`);
1688
+ return null;
1689
+ }
1690
+ if (!remoteEntry) return null;
1691
+ let rehydrated;
1692
+ try {
1693
+ rehydrated = await this.rehydrate(remoteEntry);
1694
+ } catch (e) {
1695
+ this.log(`[cache ] ${cacheKey.slice(0, 12)}\u2026 rehydration failed (${message(e)}) \u2014 treating as miss`);
1696
+ return null;
1697
+ }
1698
+ await this.local.put(rehydrated);
1699
+ return rehydrated;
1700
+ }
1701
+ async put(entry) {
1702
+ await this.local.put(entry);
1703
+ try {
1704
+ await this.remote.put(entry);
1705
+ } catch (e) {
1706
+ this.log(`[cache ] remote write failed (${message(e)}) \u2014 entry kept local`);
1707
+ }
1708
+ }
1709
+ /**
1710
+ * Download every referenced asset into the local content-addressed store
1711
+ * (sha-verified) and stamp fresh local paths. Any ref that cannot be
1712
+ * rehydrated fails the WHOLE entry — a partially-hydrated cache hit would
1713
+ * crash materialization later with a far less actionable error.
1714
+ */
1715
+ async rehydrate(entry) {
1716
+ const clone = JSON.parse(JSON.stringify(entry));
1717
+ for (const ref of collectAssetRefLikes(clone.outputs)) {
1718
+ if (!isPersistedAssetRef(ref)) {
1719
+ throw new Error(`ref ${ref.sha256.slice(0, 12)}\u2026 has no persisted url`);
1720
+ }
1721
+ const ingested = await this.assets.ingestRemote({
1722
+ kind: typeof ref.kind === "string" ? ref.kind : "json",
1723
+ url: ref.url,
1724
+ sha256: ref.sha256,
1725
+ mime: ref.mime,
1726
+ metadata: ref.metadata ?? void 0
1727
+ });
1728
+ ref.path = ingested.path;
1729
+ }
1730
+ return clone;
1731
+ }
1732
+ };
1733
+ function message(e) {
1734
+ return e instanceof Error ? e.message : String(e);
1735
+ }
1736
+
1737
+ // src/engine/nodes/remote/upload.ts
1738
+ async function presignAndPut(args) {
1739
+ const { putUrl, publicUrl } = await args.ctx.client.presignAssetUpload(args.sha256, args.mime, args.ctx.signal);
1740
+ const putRes = await fetch(putUrl, {
1741
+ method: "PUT",
1742
+ body: new Uint8Array(args.bytes),
1743
+ headers: { "Content-Type": args.mime },
1744
+ signal: args.ctx.signal
1745
+ });
1746
+ if (!putRes.ok) {
1747
+ throw new Error(`upload: presigned PUT failed ${putRes.status} ${putRes.statusText}`);
1748
+ }
1749
+ return publicUrl;
1750
+ }
1751
+ async function ensureUploaded(ref, ctx) {
1752
+ if (ref.url) return ref;
1753
+ const bytes = await ctx.assets.readBytes(ref.sha256, ref.mime);
1754
+ const url = await presignAndPut({ bytes, sha256: ref.sha256, mime: ref.mime, ctx });
1755
+ return { ...ref, url };
1756
+ }
1757
+ async function persistOutputAssetUrls(outputs, ctx) {
1758
+ for (const ref of collectAssetRefLikes(outputs)) {
1759
+ if (isPersistedAssetRef(ref)) continue;
1760
+ const bytes = await ctx.assets.readBytes(ref.sha256, ref.mime);
1761
+ ref.url = await presignAndPut({ bytes, sha256: ref.sha256, mime: ref.mime, ctx });
1762
+ }
1763
+ }
1764
+
1586
1765
  // src/engine/schema/canvas.ts
1587
1766
  import { z } from "zod";
1588
1767
  var REF_PREFIX = "$ref:";
@@ -2799,6 +2978,7 @@ var Engine = class {
2799
2978
  cache;
2800
2979
  outputsDir;
2801
2980
  log;
2981
+ persistAssets;
2802
2982
  constructor(opts) {
2803
2983
  this.registry = opts.registry;
2804
2984
  this.client = opts.client;
@@ -2806,6 +2986,7 @@ var Engine = class {
2806
2986
  this.cache = opts.cache;
2807
2987
  this.outputsDir = opts.outputsDir;
2808
2988
  this.log = opts.log ?? (() => void 0);
2989
+ this.persistAssets = opts.persistAssets ?? false;
2809
2990
  }
2810
2991
  validate(canvas) {
2811
2992
  return validateCanvas(canvas, this.registry);
@@ -2852,7 +3033,7 @@ var Engine = class {
2852
3033
  `[done ] ${stats.cached_nodes}/${stats.total_nodes} cached, ${stats.total_credits} credits, ${stats.duration_ms}ms`
2853
3034
  );
2854
3035
  this.log(`outputs in: ${writer.runDir}`);
2855
- return { run_id: runId, output, outputs_by_node: outputs, stats, outputs_dir: writer.runDir };
3036
+ return { run_id: runId, output, outputs_by_node: outputs, stats, outputs_dir: writer.runDir, node_runs: nodeRuns };
2856
3037
  }
2857
3038
  async runLayers(canvas, outputs, runId, writer, opts, counters, nodeRuns) {
2858
3039
  const layers = topologicalLayers(this.pruneToOutput(canvas, buildGraph(canvas)));
@@ -2949,6 +3130,14 @@ var Engine = class {
2949
3130
  const credits = def.cost ? def.cost({ params: parsedParams }).credits : 0;
2950
3131
  const outputsObj = result;
2951
3132
  outputs[node.id] = outputsObj;
3133
+ if (this.persistAssets) {
3134
+ try {
3135
+ await persistOutputAssetUrls(outputsObj, ctx);
3136
+ } catch (e) {
3137
+ const msg = e instanceof Error ? e.message : String(e);
3138
+ this.log(`[warn ] ${node.id}: asset persistence failed (${msg}) \u2014 outputs stay local-only`);
3139
+ }
3140
+ }
2952
3141
  if (policy === "read_write") {
2953
3142
  await this.cache.put({
2954
3143
  cacheKey: prepared.cacheKey,
@@ -3267,27 +3456,6 @@ var FontRef = BaseAssetRef.extend({
3267
3456
  });
3268
3457
  var AssetRef = z4.discriminatedUnion("kind", [ImageRef, VideoRef, AudioRef, JsonRef, TextRef, FontRef]);
3269
3458
 
3270
- // src/engine/nodes/remote/upload.ts
3271
- async function presignAndPut(args) {
3272
- const { putUrl, publicUrl } = await args.ctx.client.presignAssetUpload(args.sha256, args.mime, args.ctx.signal);
3273
- const putRes = await fetch(putUrl, {
3274
- method: "PUT",
3275
- body: new Uint8Array(args.bytes),
3276
- headers: { "Content-Type": args.mime },
3277
- signal: args.ctx.signal
3278
- });
3279
- if (!putRes.ok) {
3280
- throw new Error(`upload: presigned PUT failed ${putRes.status} ${putRes.statusText}`);
3281
- }
3282
- return publicUrl;
3283
- }
3284
- async function ensureUploaded(ref, ctx) {
3285
- if (ref.url) return ref;
3286
- const bytes = await ctx.assets.readBytes(ref.sha256, ref.mime);
3287
- const url = await presignAndPut({ bytes, sha256: ref.sha256, mime: ref.mime, ctx });
3288
- return { ...ref, url };
3289
- }
3290
-
3291
3459
  // src/engine/nodes/remote/delegate.ts
3292
3460
  function delegated(spec) {
3293
3461
  return {
@@ -3682,10 +3850,10 @@ function inferKindFromMime(mime) {
3682
3850
  if (mime.startsWith("font/")) return "font";
3683
3851
  return null;
3684
3852
  }
3685
- function localExecError(ctx, message) {
3853
+ function localExecError(ctx, message2) {
3686
3854
  return new NodeExecutionError(ctx.nodeId, ctx.nodeType, {
3687
3855
  kind: "local",
3688
- cause: new Error(`ingest: ${message}`)
3856
+ cause: new Error(`ingest: ${message2}`)
3689
3857
  });
3690
3858
  }
3691
3859
  async function execLocalFile(params, ctx) {
@@ -4352,7 +4520,7 @@ async function refToUrl(ref) {
4352
4520
  return `data:${ref.mime};base64,${bytes.toString("base64")}`;
4353
4521
  }
4354
4522
  var ASSET_KINDS = /* @__PURE__ */ new Set(["image", "video", "audio", "json", "text", "font"]);
4355
- function isAssetRefLike(value) {
4523
+ function isAssetRefLike2(value) {
4356
4524
  if (!value || typeof value !== "object") return false;
4357
4525
  const v = value;
4358
4526
  return typeof v.kind === "string" && ASSET_KINDS.has(v.kind) && typeof v.mime === "string" && typeof v.sha256 === "string" && (typeof v.url === "string" || typeof v.path === "string");
@@ -4724,8 +4892,8 @@ var NEVER_BLOCK = [
4724
4892
  /text[_-]?occluded/i
4725
4893
  ];
4726
4894
  var UNAVAILABLE = /unknown command|command not found|not found|Did you mean|Unknown argument|ENOENT/i;
4727
- function isAdvisory(code, message) {
4728
- const hay = `${code} ${message}`;
4895
+ function isAdvisory(code, message2) {
4896
+ const hay = `${code} ${message2}`;
4729
4897
  return NEVER_BLOCK.some((re) => re.test(hay));
4730
4898
  }
4731
4899
  function parseCheckJson(raw) {
@@ -4753,10 +4921,10 @@ function classifyLint(json) {
4753
4921
  for (const f of findings) {
4754
4922
  const rec = f;
4755
4923
  const code = String(rec?.code ?? "");
4756
- const message = String(rec?.message ?? "");
4924
+ const message2 = String(rec?.message ?? "");
4757
4925
  const severity = String(rec?.severity ?? "info");
4758
- const blocking = severity === "error" && !isAdvisory(code, message);
4759
- out.push({ source: "lint", code, message, severity: blocking ? "blocking" : "warning" });
4926
+ const blocking = severity === "error" && !isAdvisory(code, message2);
4927
+ out.push({ source: "lint", code, message: message2, severity: blocking ? "blocking" : "warning" });
4760
4928
  }
4761
4929
  return out;
4762
4930
  }
@@ -4768,9 +4936,9 @@ function classifyInspect(json) {
4768
4936
  for (const iss of issues) {
4769
4937
  const rec = iss;
4770
4938
  const code = String(rec?.code ?? rec?.type ?? "overflow");
4771
- const message = String(rec?.message ?? rec?.detail ?? JSON.stringify(iss));
4939
+ const message2 = String(rec?.message ?? rec?.detail ?? JSON.stringify(iss));
4772
4940
  const severity = rec?.severity ? String(rec.severity) : obj?.ok === false ? "error" : "warning";
4773
- out.push({ source: "inspect", code, message, severity: severity === "error" ? "blocking" : "warning" });
4941
+ out.push({ source: "inspect", code, message: message2, severity: severity === "error" ? "blocking" : "warning" });
4774
4942
  }
4775
4943
  return out;
4776
4944
  }
@@ -5215,7 +5383,7 @@ async function buildSubstitutionValues(compositionParams, meta, duration) {
5215
5383
  }
5216
5384
  function coerceImageParam(value) {
5217
5385
  if (typeof value === "string") return Promise.resolve(value);
5218
- if (isAssetRefLike(value)) return refToUrl(value);
5386
+ if (isAssetRefLike2(value)) return refToUrl(value);
5219
5387
  throw new Error("hyperframe_render: image param must be a URL string or AssetRef");
5220
5388
  }
5221
5389
  async function substituteCompositionFiles(tmp, values) {
@@ -5445,7 +5613,7 @@ async function buildSubstitutionValues2(compositionParams, meta) {
5445
5613
  }
5446
5614
  function coerceImageParam2(value) {
5447
5615
  if (typeof value === "string") return Promise.resolve(value);
5448
- if (isAssetRefLike(value)) return refToUrl(value);
5616
+ if (isAssetRefLike2(value)) return refToUrl(value);
5449
5617
  throw new Error("hyperframe_snapshot: image param must be a URL string or AssetRef");
5450
5618
  }
5451
5619
  async function substituteCompositionFiles2(tmp, values) {
@@ -6471,17 +6639,29 @@ function createEngineFromEnv(opts = {}) {
6471
6639
  const cacheDir = opts.cacheDir ?? path15.join(cwd, "canvas", ".cache");
6472
6640
  const outputsDir = opts.outputsDir ?? path15.join(cwd, "canvas");
6473
6641
  const creds = requireCredentialsFromEnv();
6642
+ const client = new BackendClient({ baseUrl: creds.url, apiKey: creds.apiKey });
6643
+ const assets = new LocalAssetStore(path15.join(cacheDir, "assets"));
6644
+ const localCache = new LocalCacheStore(path15.join(cacheDir, "index"));
6645
+ const remoteCacheEnabled = opts.remoteCache ?? remoteCacheEnabledFromEnv();
6646
+ const cache = remoteCacheEnabled ? new LayeredCacheStore({
6647
+ local: localCache,
6648
+ remote: new RemoteCacheStore(client, opts.log),
6649
+ assets,
6650
+ log: opts.log
6651
+ }) : localCache;
6474
6652
  return new Engine({
6475
6653
  registry: defaultRegistry(),
6476
- client: new BackendClient({ baseUrl: creds.url, apiKey: creds.apiKey }),
6477
- assets: new LocalAssetStore(path15.join(cacheDir, "assets")),
6478
- cache: new LocalCacheStore(path15.join(cacheDir, "index")),
6654
+ client,
6655
+ assets,
6656
+ cache,
6479
6657
  outputsDir,
6480
- log: opts.log
6658
+ log: opts.log,
6659
+ persistAssets: remoteCacheEnabled
6481
6660
  });
6482
6661
  }
6483
6662
 
6484
6663
  export {
6664
+ requireCredentialsFromEnv,
6485
6665
  LayerExecutionError,
6486
6666
  describeFailureReason,
6487
6667
  SEEDANCE_DURATIONS,
@@ -6489,6 +6669,10 @@ export {
6489
6669
  IMAGE_GENERATE_MODELS,
6490
6670
  MODEL_REGISTRY,
6491
6671
  resolveConcurrency,
6672
+ ulid,
6673
+ isPersistedAssetRef,
6674
+ collectAssetRefLikes,
6675
+ sha256Hex,
6492
6676
  BackendClient2 as BackendClient,
6493
6677
  Engine2 as Engine,
6494
6678
  LocalAssetStore2 as LocalAssetStore,
@@ -6499,4 +6683,4 @@ export {
6499
6683
  defaultRegistry,
6500
6684
  createEngineFromEnv
6501
6685
  };
6502
- //# sourceMappingURL=chunk-ZWMMCLJ4.js.map
6686
+ //# sourceMappingURL=chunk-OCMOQOIJ.js.map