@vxil/cli 0.16.1 → 0.18.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/vxil.js CHANGED
@@ -2656,6 +2656,24 @@ __export(type_exports3, {
2656
2656
  var Type = type_exports3;
2657
2657
 
2658
2658
  // ../feature-configs/src/hooks.ts
2659
+ var HOOK_LIMITS = {
2660
+ maxSourceLen: 2e3,
2661
+ maxTokens: 600,
2662
+ maxNodes: 250,
2663
+ maxDepth: 40,
2664
+ maxEvalSteps: 5e3,
2665
+ maxStringLen: 4096,
2666
+ maxNumberMagnitude: 1e15,
2667
+ // READ-path bounds (runReadHooks). The list path evaluates hooks PER ROW over
2668
+ // a query page (≤500 rows), so the per-request cost is bounded by ONE shared
2669
+ // eval-step budget across the whole page — not per expression. Typical hooks
2670
+ // cost tens of steps/row, so 250k covers a max page with the full 10-hook
2671
+ // complement many times over; a pathological page (maximal expressions ×
2672
+ // maximal rows × maximal hooks) exhausts it and the request fails a clean
2673
+ // 422 hook_error rather than pinning the isolate's CPU.
2674
+ maxReadEvalStepsPerRequest: 25e4,
2675
+ maxReadHooksPerCollection: 10
2676
+ };
2659
2677
  var HOOK_ROOT_VARS = ["item", "before", "now", "caller"];
2660
2678
  var HOOK_FUNCTIONS = [
2661
2679
  // math
@@ -2693,6 +2711,451 @@ var HOOK_FUNCTIONS = [
2693
2711
  ];
2694
2712
  var FN_SET = new Set(HOOK_FUNCTIONS);
2695
2713
  var ROOT_SET = new Set(HOOK_ROOT_VARS);
2714
+ var FORBIDDEN_PROPS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
2715
+ var PROP_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
2716
+ var HookParseError = class extends Error {
2717
+ };
2718
+ var PUNCT = ["||", "&&", "==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%", "!", "(", ")", ",", ".", "?", ":"];
2719
+ function tokenize(src) {
2720
+ if (src.length > HOOK_LIMITS.maxSourceLen) {
2721
+ throw new HookParseError(`expression exceeds ${HOOK_LIMITS.maxSourceLen} chars`);
2722
+ }
2723
+ const toks = [];
2724
+ let i = 0;
2725
+ const n = src.length;
2726
+ while (i < n) {
2727
+ const c = src[i];
2728
+ if (c === " " || c === " " || c === "\n" || c === "\r") {
2729
+ i++;
2730
+ continue;
2731
+ }
2732
+ if (c === '"' || c === "'") {
2733
+ const quote = c;
2734
+ let s = "";
2735
+ i++;
2736
+ while (i < n && src[i] !== quote) {
2737
+ let ch = src[i];
2738
+ if (ch === "\\") {
2739
+ const e = src[i + 1];
2740
+ if (e === "n") ch = "\n";
2741
+ else if (e === "t") ch = " ";
2742
+ else if (e === "\\" || e === '"' || e === "'") ch = e;
2743
+ else throw new HookParseError(`bad string escape \\${e ?? ""}`);
2744
+ i += 2;
2745
+ } else {
2746
+ i++;
2747
+ }
2748
+ s += ch;
2749
+ if (s.length > HOOK_LIMITS.maxStringLen) throw new HookParseError("string literal too long");
2750
+ }
2751
+ if (i >= n) throw new HookParseError("unterminated string");
2752
+ i++;
2753
+ toks.push({ k: "str", v: s });
2754
+ } else if (c >= "0" && c <= "9") {
2755
+ let j = i;
2756
+ while (j < n && /[0-9]/.test(src[j])) j++;
2757
+ if (src[j] === ".") {
2758
+ j++;
2759
+ while (j < n && /[0-9]/.test(src[j])) j++;
2760
+ }
2761
+ const num2 = Number(src.slice(i, j));
2762
+ if (!Number.isFinite(num2)) throw new HookParseError(`bad number near ${src.slice(i, j)}`);
2763
+ toks.push({ k: "num", v: num2 });
2764
+ i = j;
2765
+ } else if (/[A-Za-z_]/.test(c)) {
2766
+ let j = i;
2767
+ while (j < n && /[A-Za-z0-9_]/.test(src[j])) j++;
2768
+ toks.push({ k: "name", v: src.slice(i, j) });
2769
+ i = j;
2770
+ } else {
2771
+ const two = src.slice(i, i + 2);
2772
+ const one = c;
2773
+ const m = PUNCT.includes(two) ? two : PUNCT.includes(one) ? one : null;
2774
+ if (!m) throw new HookParseError(`unexpected character '${c}'`);
2775
+ toks.push({ k: "op", v: m });
2776
+ i += m.length;
2777
+ }
2778
+ if (toks.length > HOOK_LIMITS.maxTokens) throw new HookParseError("expression too long (token cap)");
2779
+ }
2780
+ toks.push({ k: "eof" });
2781
+ return toks;
2782
+ }
2783
+ var BIN_PREC = {
2784
+ "||": 1,
2785
+ "&&": 2,
2786
+ "==": 3,
2787
+ "!=": 3,
2788
+ "<": 4,
2789
+ "<=": 4,
2790
+ ">": 4,
2791
+ ">=": 4,
2792
+ "+": 5,
2793
+ "-": 5,
2794
+ "*": 6,
2795
+ "/": 6,
2796
+ "%": 6
2797
+ };
2798
+ var Parser = class {
2799
+ constructor(toks) {
2800
+ this.toks = toks;
2801
+ }
2802
+ toks;
2803
+ p = 0;
2804
+ nodes = 0;
2805
+ peek() {
2806
+ return this.toks[this.p];
2807
+ }
2808
+ next() {
2809
+ return this.toks[this.p++];
2810
+ }
2811
+ isOp(v) {
2812
+ const t = this.peek();
2813
+ return t.k === "op" && t.v === v;
2814
+ }
2815
+ eatOp(v) {
2816
+ if (!this.isOp(v)) throw new HookParseError(`expected '${v}'`);
2817
+ this.p++;
2818
+ }
2819
+ node(nd) {
2820
+ if (++this.nodes > HOOK_LIMITS.maxNodes) throw new HookParseError("expression too complex (node cap)");
2821
+ return nd;
2822
+ }
2823
+ parse() {
2824
+ const e = this.ternary();
2825
+ if (this.peek().k !== "eof") throw new HookParseError("trailing tokens after expression");
2826
+ return e;
2827
+ }
2828
+ ternary() {
2829
+ const c = this.binary(0);
2830
+ if (this.isOp("?")) {
2831
+ this.next();
2832
+ const a = this.ternary();
2833
+ this.eatOp(":");
2834
+ const b2 = this.ternary();
2835
+ return this.node({ t: "tern", c, a, b: b2 });
2836
+ }
2837
+ return c;
2838
+ }
2839
+ binary(minPrec) {
2840
+ let left = this.unary();
2841
+ for (; ; ) {
2842
+ const t = this.peek();
2843
+ if (t.k !== "op") break;
2844
+ const prec = BIN_PREC[t.v];
2845
+ if (prec === void 0 || prec < minPrec) break;
2846
+ this.next();
2847
+ const right = this.binary(prec + 1);
2848
+ left = this.node({ t: "bin", op: t.v, l: left, r: right });
2849
+ }
2850
+ return left;
2851
+ }
2852
+ unary() {
2853
+ if (this.isOp("!")) {
2854
+ this.next();
2855
+ return this.node({ t: "unary", op: "!", arg: this.unary() });
2856
+ }
2857
+ if (this.isOp("-")) {
2858
+ this.next();
2859
+ return this.node({ t: "unary", op: "-", arg: this.unary() });
2860
+ }
2861
+ return this.postfix();
2862
+ }
2863
+ postfix() {
2864
+ let e = this.primary();
2865
+ for (; ; ) {
2866
+ if (this.isOp(".")) {
2867
+ this.next();
2868
+ const t = this.next();
2869
+ if (t.k !== "name") throw new HookParseError("expected property name after .");
2870
+ if (!PROP_RE.test(t.v) || FORBIDDEN_PROPS.has(t.v)) {
2871
+ throw new HookParseError(`forbidden property '${t.v}'`);
2872
+ }
2873
+ e = this.node({ t: "member", obj: e, prop: t.v });
2874
+ } else break;
2875
+ }
2876
+ return e;
2877
+ }
2878
+ primary() {
2879
+ const t = this.next();
2880
+ if (t.k === "num") return this.node({ t: "num", v: t.v });
2881
+ if (t.k === "str") return this.node({ t: "str", v: t.v });
2882
+ if (t.k === "op" && t.v === "(") {
2883
+ const e = this.ternary();
2884
+ this.eatOp(")");
2885
+ return e;
2886
+ }
2887
+ if (t.k === "name") {
2888
+ if (t.v === "true") return this.node({ t: "bool", v: true });
2889
+ if (t.v === "false") return this.node({ t: "bool", v: false });
2890
+ if (t.v === "null") return this.node({ t: "null" });
2891
+ if (this.isOp("(")) {
2892
+ this.next();
2893
+ const args = [];
2894
+ if (!this.isOp(")")) {
2895
+ args.push(this.ternary());
2896
+ while (this.isOp(",")) {
2897
+ this.next();
2898
+ args.push(this.ternary());
2899
+ }
2900
+ }
2901
+ this.eatOp(")");
2902
+ return this.node({ t: "call", fn: t.v, args });
2903
+ }
2904
+ return this.node({ t: "var", name: t.v });
2905
+ }
2906
+ throw new HookParseError("unexpected token");
2907
+ }
2908
+ };
2909
+ function parseExpr(src) {
2910
+ return new Parser(tokenize(src)).parse();
2911
+ }
2912
+ function validateAst(root) {
2913
+ const errors = [];
2914
+ const walk = (nd, depth) => {
2915
+ if (depth > HOOK_LIMITS.maxDepth) {
2916
+ errors.push("expression nests too deeply");
2917
+ return;
2918
+ }
2919
+ switch (nd.t) {
2920
+ case "num":
2921
+ if (!Number.isFinite(nd.v) || Math.abs(nd.v) > HOOK_LIMITS.maxNumberMagnitude) errors.push("numeric literal out of range");
2922
+ return;
2923
+ case "str":
2924
+ if (nd.v.length > HOOK_LIMITS.maxStringLen) errors.push("string literal too long");
2925
+ return;
2926
+ case "bool":
2927
+ case "null":
2928
+ return;
2929
+ case "var":
2930
+ if (!ROOT_SET.has(nd.name)) errors.push(`unknown variable '${nd.name}' (allowed: ${HOOK_ROOT_VARS.join(", ")})`);
2931
+ return;
2932
+ case "member":
2933
+ if (FORBIDDEN_PROPS.has(nd.prop) || !PROP_RE.test(nd.prop)) errors.push(`forbidden property '${nd.prop}'`);
2934
+ walk(nd.obj, depth + 1);
2935
+ return;
2936
+ case "unary":
2937
+ walk(nd.arg, depth + 1);
2938
+ return;
2939
+ case "bin":
2940
+ walk(nd.l, depth + 1);
2941
+ walk(nd.r, depth + 1);
2942
+ return;
2943
+ case "tern":
2944
+ walk(nd.c, depth + 1);
2945
+ walk(nd.a, depth + 1);
2946
+ walk(nd.b, depth + 1);
2947
+ return;
2948
+ case "call":
2949
+ if (!FN_SET.has(nd.fn)) errors.push(`unknown function '${nd.fn}'`);
2950
+ if (nd.args.length > 16) errors.push(`function '${nd.fn}' has too many arguments`);
2951
+ for (const a of nd.args) walk(a, depth + 1);
2952
+ return;
2953
+ default: {
2954
+ errors.push("unsupported expression node");
2955
+ }
2956
+ }
2957
+ };
2958
+ walk(root, 0);
2959
+ return errors;
2960
+ }
2961
+ var NUM_FNS = /* @__PURE__ */ new Set(["min", "max", "abs", "round", "floor", "ceil", "sqrt", "pow", "sign", "len", "number", "daysBetween", "yearsBetween"]);
2962
+ var STR_FNS = /* @__PURE__ */ new Set(["lower", "upper", "trim", "substr", "concat", "string"]);
2963
+ var BOOL_FNS = /* @__PURE__ */ new Set(["contains", "startsWith", "endsWith", "not", "isNull", "bool"]);
2964
+ function inferType(nd) {
2965
+ switch (nd.t) {
2966
+ case "num":
2967
+ return "number";
2968
+ case "str":
2969
+ return "string";
2970
+ case "bool":
2971
+ return "boolean";
2972
+ case "null":
2973
+ return "null";
2974
+ case "var":
2975
+ return nd.name === "now" ? "string" : "unknown";
2976
+ case "member":
2977
+ return "unknown";
2978
+ case "unary":
2979
+ return nd.op === "!" ? "boolean" : "number";
2980
+ case "bin":
2981
+ return ["==", "!=", "<", "<=", ">", ">=", "&&", "||"].includes(nd.op) ? "boolean" : "number";
2982
+ case "tern": {
2983
+ const a = inferType(nd.a);
2984
+ const b2 = inferType(nd.b);
2985
+ return a === b2 ? a : "unknown";
2986
+ }
2987
+ case "call":
2988
+ if (NUM_FNS.has(nd.fn)) return "number";
2989
+ if (STR_FNS.has(nd.fn)) return "string";
2990
+ if (BOOL_FNS.has(nd.fn)) return "boolean";
2991
+ return "unknown";
2992
+ // coalesce / ifNull
2993
+ default:
2994
+ return "unknown";
2995
+ }
2996
+ }
2997
+ var FIELD_STATIC = {
2998
+ string: "string",
2999
+ text: "string",
3000
+ datetime: "string",
3001
+ relation: "string",
3002
+ file: "string",
3003
+ int: "number",
3004
+ float: "number",
3005
+ bool: "boolean",
3006
+ json: "json"
3007
+ };
3008
+ var WRITE_EVENTS = /* @__PURE__ */ new Set(["beforeCreate", "beforeUpdate", "beforeWrite"]);
3009
+ function fieldRef(nd) {
3010
+ if (nd.t !== "member" || nd.obj.t !== "var") return null;
3011
+ if (nd.obj.name !== "item" && nd.obj.name !== "before") return null;
3012
+ return { root: nd.obj.name, field: nd.prop };
3013
+ }
3014
+ function staticTypeOf(nd, fields) {
3015
+ if (nd.t === "var" && (nd.name === "item" || nd.name === "before" || nd.name === "caller")) return "object";
3016
+ const ref = fieldRef(nd);
3017
+ if (ref) {
3018
+ const f = fields && Object.prototype.hasOwnProperty.call(fields, ref.field) ? fields[ref.field] : void 0;
3019
+ return f ? FIELD_STATIC[f.type] ?? "unknown" : "unknown";
3020
+ }
3021
+ return inferType(nd);
3022
+ }
3023
+ function describeOperand(nd) {
3024
+ const ref = fieldRef(nd);
3025
+ if (ref) return `${ref.root}.${ref.field}`;
3026
+ if (nd.t === "var") return nd.name;
3027
+ if (nd.t === "str") return JSON.stringify(nd.v);
3028
+ return "a text value";
3029
+ }
3030
+ function nanOperand(nd, fields) {
3031
+ if (nd.t === "var" && nd.name === "now") return nd;
3032
+ if (nd.t === "str") return Number.isFinite(Number(nd.v)) ? null : nd;
3033
+ const ref = fieldRef(nd);
3034
+ if (ref && fields && Object.prototype.hasOwnProperty.call(fields, ref.field) && fields[ref.field].type === "datetime") return nd;
3035
+ return null;
3036
+ }
3037
+ function checkHookExprTypes(root, fields, opts) {
3038
+ const errors = [];
3039
+ const warnings = [];
3040
+ const warned = /* @__PURE__ */ new Set();
3041
+ const writePath = WRITE_EVENTS.has(opts.event);
3042
+ const optionalOperand = (nd) => {
3043
+ if (!writePath || !fields) return;
3044
+ const ref = fieldRef(nd);
3045
+ if (!ref) return;
3046
+ const f = Object.prototype.hasOwnProperty.call(fields, ref.field) ? fields[ref.field] : void 0;
3047
+ if (!f || f.required === true || ref.root === "item" && opts.present?.has(ref.field)) return;
3048
+ const name = `${ref.root}.${ref.field}`;
3049
+ if (warned.has(name)) return;
3050
+ warned.add(name);
3051
+ warnings.push(`arithmetic on '${name}', which is optional, faults the hook whenever it is missing \u2014 write coalesce(${name}, 0)`);
3052
+ };
3053
+ const walk = (nd) => {
3054
+ switch (nd.t) {
3055
+ case "bin": {
3056
+ let refused = false;
3057
+ if (nd.op === "==" || nd.op === "!=") {
3058
+ const lt = staticTypeOf(nd.l, fields);
3059
+ const rt = staticTypeOf(nd.r, fields);
3060
+ if (nd.l.t !== "null" && nd.r.t !== "null") {
3061
+ const always = nd.op === "==" ? "always false" : "always true";
3062
+ const objSide = lt === "object" ? nd.l : rt === "object" ? nd.r : lt === "json" && rt === "json" ? nd.l : null;
3063
+ const jsonSide = lt === "json" ? nd.l : rt === "json" ? nd.r : null;
3064
+ if (objSide) {
3065
+ errors.push(`'${nd.op}' cannot compare '${describeOperand(objSide)}' (an object or json value: the comparison is ${always}) \u2014 compare a scalar inside it (e.g. ${describeOperand(objSide)}.status) or test isNull()`);
3066
+ } else if (jsonSide) {
3067
+ warnings.push(`'${nd.op}' on json field '${describeOperand(jsonSide)}' compares only when it holds a scalar \u2014 when it holds an object or array the comparison is ${always}; compare a scalar inside it (e.g. ${describeOperand(jsonSide)}.status)`);
3068
+ }
3069
+ }
3070
+ } else if (nd.op === "+") {
3071
+ const nan = nanOperand(nd.l, fields) ?? nanOperand(nd.r, fields);
3072
+ if (nan) {
3073
+ refused = true;
3074
+ errors.push(`'+' adds numbers only, and '${describeOperand(nan)}' is never a number \u2014 use concat() to join text`);
3075
+ } else {
3076
+ const strSide = staticTypeOf(nd.l, fields) === "string" ? nd.l : staticTypeOf(nd.r, fields) === "string" ? nd.r : null;
3077
+ if (strSide) {
3078
+ warnings.push(`'+' adds numbers only: '${describeOperand(strSide)}' is text, so the hook faults unless it holds a numeric string \u2014 use concat() to join text, or number() to add`);
3079
+ }
3080
+ }
3081
+ }
3082
+ if (!refused && (nd.op === "+" || nd.op === "-" || nd.op === "*" || nd.op === "/" || nd.op === "%")) {
3083
+ optionalOperand(nd.l);
3084
+ optionalOperand(nd.r);
3085
+ }
3086
+ walk(nd.l);
3087
+ walk(nd.r);
3088
+ return;
3089
+ }
3090
+ case "unary":
3091
+ if (nd.op === "-") optionalOperand(nd.arg);
3092
+ walk(nd.arg);
3093
+ return;
3094
+ case "tern":
3095
+ walk(nd.c);
3096
+ walk(nd.a);
3097
+ walk(nd.b);
3098
+ return;
3099
+ case "call":
3100
+ for (const a of nd.args) walk(a);
3101
+ return;
3102
+ case "member":
3103
+ walk(nd.obj);
3104
+ return;
3105
+ default:
3106
+ return;
3107
+ }
3108
+ };
3109
+ walk(root);
3110
+ return { errors, warnings };
3111
+ }
3112
+ function hookFieldTypesOf(collections) {
3113
+ const out = {};
3114
+ if (!collections || typeof collections !== "object") return out;
3115
+ for (const [name, def] of Object.entries(collections)) {
3116
+ const fields = def?.fields;
3117
+ if (!fields || typeof fields !== "object") continue;
3118
+ const m = {};
3119
+ for (const [f, fd] of Object.entries(fields)) {
3120
+ const t = fd?.type;
3121
+ if (typeof t !== "string") continue;
3122
+ m[f] = { type: t, ...fd.required === true ? { required: true } : {} };
3123
+ }
3124
+ out[name] = m;
3125
+ }
3126
+ return out;
3127
+ }
3128
+ function checkHookTypes(hooks, fieldTypes) {
3129
+ const errors = [];
3130
+ const warnings = [];
3131
+ if (!hooks) return { errors, warnings };
3132
+ const derived = /* @__PURE__ */ new Map();
3133
+ for (const h of Object.values(hooks)) {
3134
+ if (h.kind === "derive" && h.field && WRITE_EVENTS.has(h.event) && h.enabled !== false) {
3135
+ if (!derived.has(h.collection)) derived.set(h.collection, /* @__PURE__ */ new Set());
3136
+ derived.get(h.collection).add(h.field);
3137
+ }
3138
+ }
3139
+ for (const [id, h] of Object.entries(hooks)) {
3140
+ if (!Object.prototype.hasOwnProperty.call(fieldTypes, h.collection)) continue;
3141
+ let ast;
3142
+ try {
3143
+ ast = parseExpr(h.expr ?? "");
3144
+ } catch {
3145
+ continue;
3146
+ }
3147
+ if (validateAst(ast).length) continue;
3148
+ const r = checkHookExprTypes(ast, fieldTypes[h.collection], {
3149
+ kind: h.kind,
3150
+ event: h.event,
3151
+ present: h.kind === "validate" ? derived.get(h.collection) : void 0
3152
+ });
3153
+ if (h.enabled === false) for (const e of r.errors) warnings.push(`hooks.${id}.expr (disabled): ${e}`);
3154
+ else for (const e of r.errors) errors.push(`hooks.${id}.expr: ${e}`);
3155
+ for (const w of r.warnings) warnings.push(`hooks.${id}.expr: ${w}`);
3156
+ }
3157
+ return { errors, warnings };
3158
+ }
2696
3159
 
2697
3160
  // ../feature-configs/src/apiState.ts
2698
3161
  var FN_TRIGGER_TARGET_MARKERS = [
@@ -3003,10 +3466,32 @@ var NotificationsConfigSchema = Type.Object({
3003
3466
  },
3004
3467
  { default: {} }
3005
3468
  )),
3006
- suppression: Type.Object(
3007
- { softBounceThreshold: Type.Integer({ default: 3 }) },
3469
+ // `suppression` became an OPTIONAL bag (1 leaf either way — it held the one
3470
+ // leaf softBounceThreshold) on 2026-10-04 so the
3471
+ // `testRecipients` list rides inside it at zero leaf cost (the schema is AT
3472
+ // the cap). It KEEPS `default: {}`, so Value.Default still materializes
3473
+ // suppression.softBounceThreshold into every persisted manifest exactly as
3474
+ // before (byte-identical); only the TS type is optional (workers read via
3475
+ // `config.suppression?.…` with the shipped fallback).
3476
+ suppression: Type.Optional(Type.Object(
3477
+ {
3478
+ softBounceThreshold: Type.Integer({ default: 3 }),
3479
+ // TEST-RECIPIENT SINK (2026-10-04) — the auth `otp.testRecipients` grammar and
3480
+ // matcher (matchesTestRecipient): an exact email or a `*@domain` glob,
3481
+ // ≤20 (validateFeatureConfig refuses anything else — a +E.164 entry
3482
+ // could never match a mail recipient). A send whose recipient matches is
3483
+ // rendered in full and recorded as a delivery with status `test_sink`
3484
+ // (the render readable at GET /v1/notifications/deliveries/{id}); NO
3485
+ // provider is called and the send is not billed. Optional, no default:
3486
+ // absent = no sink. CI / store-review / QA addresses only — never a real
3487
+ // user's address.
3488
+ testRecipients: Type.Optional(Type.Array(
3489
+ Type.String({ minLength: 3, maxLength: 320 }),
3490
+ { maxItems: 20 }
3491
+ ))
3492
+ },
3008
3493
  { default: {} }
3009
- ),
3494
+ )),
3010
3495
  // `rateLimit` became an OPTIONAL bag (2 leaves → 1, the `retry` trick)
3011
3496
  // on 2026-09-19 to fund the `ses` credential bag above. It KEEPS
3012
3497
  // `default: {}`, so Value.Default still materializes
@@ -3575,7 +4060,9 @@ var CmsConfigSchema = Type.Object({
3575
4060
  maxCollections: Type.Integer({ default: 25, minimum: 1, maximum: 200 }),
3576
4061
  maxFieldsPerCollection: Type.Integer({ default: 50, minimum: 1, maximum: 200 }),
3577
4062
  maxItemBytes: Type.Integer({ default: 256 * 1024, minimum: 1024, maximum: 1024 * 1024 }),
3578
- maxItemsPerCollection: Type.Integer({ default: 1e5, minimum: 1 })
4063
+ // Capped at 10 M: every create counts the collection's live rows, so
4064
+ // the cap also bounds what each create pays.
4065
+ maxItemsPerCollection: Type.Integer({ default: 1e5, minimum: 1, maximum: 1e7 })
3579
4066
  },
3580
4067
  { default: {} }
3581
4068
  ),
@@ -4543,9 +5030,9 @@ function lowerAllTriggerBindings(def, name) {
4543
5030
  return bindings;
4544
5031
  }
4545
5032
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
4546
- var VXIL_CONFIG_PKG_VERSION = "0.10.1";
4547
- var VXIL_SDK_PKG_VERSION = "0.16.1";
4548
- var VXIL_CLI_PKG_VERSION = "0.16.1";
5033
+ var VXIL_CONFIG_PKG_VERSION = "0.12.0";
5034
+ var VXIL_SDK_PKG_VERSION = "0.18.0";
5035
+ var VXIL_CLI_PKG_VERSION = "0.18.0";
4549
5036
  function ensureScaffoldPackageJson(cwd, opts = {}) {
4550
5037
  const file = resolve(cwd, "package.json");
4551
5038
  const wanted = {
@@ -5197,7 +5684,7 @@ function handleValue(x, parameters, types2, options) {
5197
5684
  throw Errors.generic("UNDEFINED_VALUE", "Undefined values are not allowed");
5198
5685
  }
5199
5686
  return "$" + types2.push(
5200
- x instanceof Parameter ? (parameters.push(x.value), x.array ? x.array[x.type || inferType(x.value)] || x.type || firstIsString(x.value) : x.type) : (parameters.push(x), inferType(x))
5687
+ x instanceof Parameter ? (parameters.push(x.value), x.array ? x.array[x.type || inferType2(x.value)] || x.type || firstIsString(x.value) : x.type) : (parameters.push(x), inferType2(x))
5201
5688
  );
5202
5689
  }
5203
5690
  var defaultHandlers = typeHandlers(types);
@@ -5291,8 +5778,8 @@ function escapeIdentifiers(xs, { transform: { column } }) {
5291
5778
  var escapeIdentifier = function escape(str2) {
5292
5779
  return '"' + str2.replace(/"/g, '""').replace(/\./g, '"."') + '"';
5293
5780
  };
5294
- var inferType = function inferType2(x) {
5295
- return x instanceof Parameter ? x.type : x instanceof Date ? 1184 : x instanceof Uint8Array ? 17 : x === true || x === false ? 16 : typeof x === "bigint" ? 20 : Array.isArray(x) ? inferType2(x[0]) : 0;
5781
+ var inferType2 = function inferType3(x) {
5782
+ return x instanceof Parameter ? x.type : x instanceof Date ? 1184 : x instanceof Uint8Array ? 17 : x === true || x === false ? 16 : typeof x === "bigint" ? 20 : Array.isArray(x) ? inferType3(x[0]) : 0;
5296
5783
  };
5297
5784
  var escapeBackslash = /\\/g;
5298
5785
  var escapeQuote = /"/g;
@@ -6861,7 +7348,7 @@ function Postgres(a, b2) {
6861
7348
  function array(x, type) {
6862
7349
  if (!Array.isArray(x))
6863
7350
  return array(Array.from(arguments));
6864
- return new Parameter(x, type || (x.length ? inferType(x) || 25 : 0), options.shared.typeArrayMap);
7351
+ return new Parameter(x, type || (x.length ? inferType2(x) || 25 : 0), options.shared.typeArrayMap);
6865
7352
  }
6866
7353
  function handler(query) {
6867
7354
  if (ending)
@@ -8240,6 +8727,12 @@ function indexArmNote(rows) {
8240
8727
  if (rows === null) return "arms inline or through the re-index push runs (live row count unavailable)";
8241
8728
  return rows <= EQ_INDEX_INLINE_MAX_ROWS ? `arms inline (${rows} rows)` : `needs reindex (${rows} rows; push runs it)`;
8242
8729
  }
8730
+ function endUserAccessOf(v) {
8731
+ return v === "read" || v === "none" ? v : "readwrite";
8732
+ }
8733
+ function endUserAccessRank(m) {
8734
+ return m === "none" ? 0 : m === "read" ? 1 : 2;
8735
+ }
8243
8736
  function normalizeActions(raw) {
8244
8737
  if (!Array.isArray(raw)) return [];
8245
8738
  return raw.filter((a) => !!a && typeof a === "object").map((a) => ({ key: String(a.key), label: String(a.label), fn: String(a.fn) }));
@@ -8334,6 +8827,7 @@ async function readRemote2(api) {
8334
8827
  collection: c.collection,
8335
8828
  ownerField: c.owner_field ?? null,
8336
8829
  public: c.public === true,
8830
+ endUserAccess: endUserAccessOf(c.end_user_access),
8337
8831
  actions: normalizeActions(c.actions),
8338
8832
  fields: Array.isArray(c.fields) ? c.fields : []
8339
8833
  });
@@ -8382,6 +8876,12 @@ async function reindexCollection({ api, collection, field, onProgress }) {
8382
8876
  return { scanned, updated, pages, skipped, ...indexCertify ? { indexCertify } : {} };
8383
8877
  }
8384
8878
  async function planCmsSchema({ api, collections, apply, allowDestructive = false, onProgress }) {
8879
+ for (const [name, def] of Object.entries(collections)) {
8880
+ const v = def.endUserAccess;
8881
+ if (v !== void 0 && v !== "readwrite" && v !== "read" && v !== "none") {
8882
+ throw new Error(`cms.collections.${name}.endUserAccess must be 'readwrite' | 'read' | 'none' (got ${JSON.stringify(v)})`);
8883
+ }
8884
+ }
8385
8885
  const read = await readRemote2(api);
8386
8886
  if (read.unavailable) {
8387
8887
  if (apply) {
@@ -8526,6 +9026,23 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8526
9026
  detail: `${name} public \u2192 ${declaredPublic} (keyless public delivery ${declaredPublic ? "ON" : "OFF"})`
8527
9027
  });
8528
9028
  }
9029
+ const declaredAccess = endUserAccessOf(def.endUserAccess);
9030
+ if (declaredAccess !== rc.endUserAccess) {
9031
+ const widens = endUserAccessRank(declaredAccess) > endUserAccessRank(rc.endUserAccess);
9032
+ if (!widens || allowDestructive) {
9033
+ collChanges.push({
9034
+ collection: name,
9035
+ kind: "set-end-user-access",
9036
+ detail: `${name} endUserAccess \u2192 '${declaredAccess}' (was '${rc.endUserAccess}')` + (widens ? " \u2014 WIDENS end-user access (full-config reconciliation)" : " (narrows end-user access)")
9037
+ });
9038
+ } else {
9039
+ collChanges.push({
9040
+ collection: name,
9041
+ kind: "warn",
9042
+ detail: `${name}: endUserAccess is '${rc.endUserAccess}' remotely but '${declaredAccess}' in config \u2014 that WIDENS what end users may do, so it is NOT applied. Declare endUserAccess: '${rc.endUserAccess}' to keep it, or re-run with --allow-destructive to widen.`
9043
+ });
9044
+ }
9045
+ }
8529
9046
  const declaredActions = normalizeActions(def.actions);
8530
9047
  if (stableStringify(declaredActions) !== stableStringify(rc.actions)) {
8531
9048
  collChanges.push({
@@ -8597,6 +9114,8 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8597
9114
  if (def.singular) body.singular = def.singular;
8598
9115
  if (def.ownerField) body.owner_field = def.ownerField;
8599
9116
  if (def.public === true) body.public = true;
9117
+ const access = endUserAccessOf(def.endUserAccess);
9118
+ if (access !== "readwrite") body.end_user_access = access;
8600
9119
  if (def.actions && def.actions.length > 0) body.actions = normalizeActions(def.actions);
8601
9120
  const res = await api("POST", "/v1/cms/collections", body);
8602
9121
  if (res.status !== 201 && res.status !== 200 && res.body.error?.code !== "collection_exists") {
@@ -8636,6 +9155,14 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8636
9155
  throw new Error(`cms: set public on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
8637
9156
  }
8638
9157
  applied++;
9158
+ } else if (c.kind === "set-end-user-access") {
9159
+ const target = endUserAccessOf(collections[c.collection].endUserAccess);
9160
+ const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { end_user_access: target });
9161
+ if (res.status !== 200 && res.status !== 201) {
9162
+ const e = res.body.error ?? {};
9163
+ throw new Error(`cms: set endUserAccess on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
9164
+ }
9165
+ applied++;
8639
9166
  } else if (c.kind === "set-actions") {
8640
9167
  const target = normalizeActions(collections[c.collection].actions);
8641
9168
  const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { actions: target });
@@ -8701,7 +9228,7 @@ function formatCmsChanges(changes) {
8701
9228
  if (c.kind === "destructive") return ` ! ${c.detail}`;
8702
9229
  if (c.kind === "warn") return ` \u26A0 ${c.detail}`;
8703
9230
  if (c.kind === "reindex") return ` \u21BB ${c.detail}`;
8704
- if (c.kind === "alter-flags" || c.kind === "set-owner-field") return ` ~ ${c.detail}`;
9231
+ if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access") return ` ~ ${c.detail}`;
8705
9232
  return ` + ${c.detail}`;
8706
9233
  }).join("\n");
8707
9234
  }
@@ -8733,6 +9260,11 @@ function indexCertifyWarning(collection, certify) {
8733
9260
  if (certify !== "timeout" && certify !== "pending") return null;
8734
9261
  return ` \u26A0 the index on ${collection} is NOT armed yet (` + (certify === "timeout" ? "its certification ran out of time" : "rows changed during the pass") + ") \u2014 filters stay correct but unaccelerated; re-run `vxil cms reindex " + collection + "`";
8735
9262
  }
9263
+ function hookTypePreflight(cfg) {
9264
+ const hooks = cfg.features?.cms?.hooks;
9265
+ if (!hooks) return { errors: [], warnings: [] };
9266
+ return checkHookTypes(hooks, hookFieldTypesOf(cfg.cms?.collections));
9267
+ }
8736
9268
 
8737
9269
  // src/apiCmd.ts
8738
9270
  var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
@@ -9154,7 +9686,8 @@ function resolve_target(opts) {
9154
9686
  }
9155
9687
  if (selector2.kind === "named") {
9156
9688
  const name = selector2.name;
9157
- const slot = proj?.targets?.[name] ?? proj?.branches?.[name] ?? (name === "dev" ? proj?.dev : void 0);
9689
+ const slotKind = proj?.targets?.[name] ? "target" : proj?.branches?.[name] ? "branch" : name === "dev" && proj?.dev ? "dev" : void 0;
9690
+ const slot = slotKind === "target" ? proj.targets[name] : slotKind === "branch" ? proj.branches[name] : slotKind === "dev" ? proj.dev : void 0;
9158
9691
  if (!slot) {
9159
9692
  throw new TargetUnboundError(
9160
9693
  selector2,
@@ -9170,6 +9703,7 @@ function resolve_target(opts) {
9170
9703
  tenantId: slot.tenant_id,
9171
9704
  slot: "named",
9172
9705
  slotName: name,
9706
+ ...slotKind ? { slotKind } : {},
9173
9707
  envLabel: slot.env ?? envLabelFor(baseUrl2),
9174
9708
  keySource: stored ? "credentials" : "none",
9175
9709
  selector: selector2
@@ -9262,7 +9796,10 @@ function isNonProductionLabel(label) {
9262
9796
  return NON_PRODUCTION_ENV_LABELS.has(normalizeLabel(label));
9263
9797
  }
9264
9798
  function isDevSlot(t) {
9265
- return t.slot === "dev" || t.slot === "named" && t.selector.kind === "named" && t.selector.name === "dev";
9799
+ if (t.slot === "dev") return true;
9800
+ if (t.slot !== "named") return false;
9801
+ if (t.slotKind !== void 0) return t.slotKind === "branch" || t.slotKind === "dev";
9802
+ return t.selector.kind === "named" && t.selector.name === "dev";
9266
9803
  }
9267
9804
  function isProductionTarget(t) {
9268
9805
  if (isDevSlot(t)) return false;
@@ -10749,11 +11286,20 @@ function deployedVerdict(lane, binding, timeoutMs) {
10749
11286
  }
10750
11287
  return out;
10751
11288
  }
10752
- function productionLaneGuard(o) {
10753
- if (!o.production || o.explicitSelector) return null;
10754
- if (o.lane === "http" && !o.oneShot) return null;
11289
+ var NON_DEV_GUARDED_LANES = LOCAL_LANES.filter((l) => l !== "http");
11290
+ function liveLaneGuard(o) {
11291
+ if (o.devSlot || o.explicitSelector) return null;
10755
11292
  const what = o.oneShot ? `this --event delivery (${o.lane})` : `every ${o.lane} delivery of the local server`;
10756
- return `no dev tenant is bound, so ${what} would run '${o.fnName}' against the PRODUCTION binding '${o.tenant}' \u2014 its feature calls land there for real, and each local delivery carries a fresh idempotency_key, so a money-lane function grants, refunds or mails again even for an event production already handled. Bind a dev tenant (\`vxil dev up\`), or aim on purpose with --target <name>`;
11293
+ if (!o.oneShot && !NON_DEV_GUARDED_LANES.includes(o.lane)) return null;
11294
+ if (o.production) {
11295
+ return `no dev tenant is bound, so ${what} would run '${o.fnName}' against the PRODUCTION binding '${o.tenant}' \u2014 its feature calls land there for real, and each local delivery carries a fresh idempotency_key, so a money-lane function grants, refunds or mails again even for an event production already handled. Bind a dev tenant (\`vxil dev up\`), or aim on purpose with --target <name>`;
11296
+ }
11297
+ return `no dev tenant is bound, so ${what} would run '${o.fnName}' against the non-dev binding '${o.tenant}' \u2014 its feature calls land there for REAL (its data, its provider accounts), and each local delivery carries a fresh idempotency_key, so a function that grants, bills, refunds or mails does it again. Run functions dev only against a dev slot (\`vxil dev up\`), or aim on purpose with --target <name>`;
11298
+ }
11299
+ function liveWritesBanner(o) {
11300
+ if (o.devSlot) return null;
11301
+ const line = ` !! writes for REAL on '${o.tenant}' (${o.envLabel}) \u2014 not a dev slot: every feature call, grant, refund and mail is real`;
11302
+ return o.color ? `\x1B[31m${line}\x1B[0m` : line;
10757
11303
  }
10758
11304
 
10759
11305
  // src/fnDev.ts
@@ -11339,12 +11885,12 @@ var TOOLS = [
11339
11885
  {
11340
11886
  name: "notifications_list_deliveries",
11341
11887
  feature: "notifications",
11342
- description: "List recent notification deliveries with status (queued|sent|failed|suppressed). Filter by user_id, status, or engagement (delivered|opened|clicked, stamped from the signed provider webhook).",
11888
+ description: "List recent notification deliveries with status (queued|sent|failed|suppressed|test_sink \u2014 test_sink = the recipient is in the project's suppression.testRecipients: rendered and recorded, never sent, never billed). Filter by user_id, status, or engagement (delivered|opened|clicked, stamped from the signed provider webhook).",
11343
11889
  inputSchema: {
11344
11890
  type: "object",
11345
11891
  properties: {
11346
11892
  user_id: { type: "string" },
11347
- status: { type: "string", enum: ["queued", "sent", "failed", "suppressed"] },
11893
+ status: { type: "string", enum: ["queued", "sent", "failed", "suppressed", "test_sink"] },
11348
11894
  engagement: { type: "string", enum: ["delivered", "opened", "clicked"] },
11349
11895
  limit: { type: "number", default: 20 }
11350
11896
  }
@@ -11576,7 +12122,7 @@ var TOOLS = [
11576
12122
  {
11577
12123
  name: "jobs_get_run",
11578
12124
  feature: "jobs",
11579
- description: "Read ONE run by run_id: the row jobs_list_runs summarises plus payload_json (your own jobs; a platform-enqueued run reads { redacted: true }), any generation_* fields, result (what the run completed with: its signed callback's result, or a generation's settled value; null when nothing reported one), progress (the latest { progress 0..100, stage, message, at } report, or null), concurrency_key / concurrency_limit (null when unkeyed) and, on a generation run, mirror_error (the last status_mirror write the target refused \u2014 e.g. a callback key the collection does not declare: the status word was retried alone and fields_dropped lists what was not written \u2014 or null). Pass wait (1..25 seconds) to WAIT FOR THIS RUN: the read holds until the run is terminal (succeeded | failed | dead | cancelled) or the deadline passes, then returns the current row \u2014 on a timeout (or when the per-tenant wait budget of 60 held reads a minute declined to hold) state is still non-terminal, so check it and call again. Use it right after jobs_enqueue or an async function invoke instead of polling jobs_list_runs.",
12125
+ description: "Read ONE run by run_id: the row jobs_list_runs summarises plus payload_json (your own jobs; a platform-enqueued run reads { redacted: true }), any generation_* fields, result (what the run completed with: its signed callback's result, or a generation's settled value; null when nothing reported one), progress (the latest { progress 0..100, stage, message, at } report, or null), concurrency_key / concurrency_limit (null when unkeyed) and, on a generation run, mirror_error (the last status_mirror write the target refused \u2014 e.g. a callback key the collection does not declare: the status word was retried alone and fields_dropped lists what was not written; status_written says whether the record holds the status word \u2014 or null; a later clean completed/failed write clears it into last_mirror_error, with mirror_ok_at saying when). Pass wait (1..25 seconds) to WAIT FOR THIS RUN: the read holds until the run is terminal (succeeded | failed | dead | cancelled) or the deadline passes, then returns the current row \u2014 on a timeout (or when the per-tenant wait budget of 60 held reads a minute declined to hold) state is still non-terminal, so check it and call again. Use it right after jobs_enqueue or an async function invoke instead of polling jobs_list_runs.",
11580
12126
  inputSchema: {
11581
12127
  type: "object",
11582
12128
  properties: {
@@ -11720,7 +12266,7 @@ var TOOLS = [
11720
12266
  {
11721
12267
  name: "jobs_cancel_run",
11722
12268
  feature: "jobs",
11723
- description: "Cancel a run that has not started its current attempt \u2014 queued, delayed or retrying \u2014 or a plain run handed off to its signed callback (waiting after its handler answered 202): it ends in state cancelled and is never delivered again (a handed-off run's callback URL is consumed). A generation run's credit hold is released and its status mirror is set. 409 not_cancellable for a run that is running (a delivery in flight, or a generation waiting on its provider \u2014 settle that one through its completion callback), waiting on an event (wake it with jobs_emit_event), or already terminal.",
12269
+ description: "Cancel a run that has not started its current attempt \u2014 queued, delayed or retrying \u2014 or a plain run handed off to its signed callback (waiting after its handler answered 202): it ends in state cancelled and is never delivered again (a handed-off run's callback URL is consumed). A generation run's credit hold is released and its status mirror is set \u2014 a STARTED generation too (its provider accepted it, the run waits on its callback): it ends failed (error_class Cancelled) and a late provider callback is a no-op, but the provider is NOT told, so its work and its charge may still complete. 409 not_cancellable for a plain run that is running (a delivery in flight), a run waiting on an event (wake it with jobs_emit_event), or one already terminal.",
11724
12270
  inputSchema: {
11725
12271
  type: "object",
11726
12272
  properties: { run_id: { type: "string" } },
@@ -11759,7 +12305,7 @@ var TOOLS = [
11759
12305
  {
11760
12306
  name: "jobs_enqueue_generation",
11761
12307
  feature: "jobs",
11762
- description: "Start a long-running EXTERNAL generation (a render, a model job): vxil calls provider.url (https; your provider key in provider.headers), then learns completion by POLLING completion.poll.url every interval_ms or by the provider's WEBHOOK (completion.mode 'webhook' \u2014 a signed single-use callback URL is appended as completion.callback.query_param). completion.status_path names the status field; status_map maps provider words to completed / failed / processing. status_mirror writes the status onto your record (e.g. cms collection + record_id); timeout.after_ms ends it as failed; reserve_credits holds a user's credits, committed on completion and released on failure; when payments cannot be reached the call answers 503 payments_unavailable (nothing started or held \u2014 retry the same call after Retry-After) unless reserve_credits.on_unavailable is 'proceed'. max_attempts = provider START calls (a 408/429/5xx retries; a provider Retry-After on 429/503 is honoured). Answers 202 { run_id, generation_status: 'pending' } (deduplicated:true for a repeated idempotency_key); 429 with Retry-After when the project's open-generation cap is reached. Read it with jobs_get_run (generation_status, result, progress, mirror_error).",
12308
+ description: "Start a long-running EXTERNAL generation (a render, a model job): vxil calls provider.url (https; your provider key in provider.headers), then learns completion by POLLING completion.poll.url every interval_ms or by the provider's WEBHOOK (completion.mode 'webhook' \u2014 a signed single-use callback URL is appended as completion.callback.query_param). completion.status_path names the status field; status_map maps provider words to completed / failed / processing. status_mirror writes the status onto your record (e.g. cms collection + record_id); timeout.after_ms ends it as failed; reserve_credits holds a user's credits, committed on completion and released on failure; when payments cannot be reached the call answers 503 payments_unavailable (nothing started or held \u2014 retry the same call after Retry-After) unless reserve_credits.on_unavailable is 'proceed'. max_attempts = provider START calls (a 408/429/5xx retries; a provider Retry-After on 429/503 is honoured). Answers 202 { run_id, generation_status: 'pending' } (deduplicated:true for a repeated idempotency_key with the SAME body; a different body under that key will answer 422 idempotency_key_reused once enforced \u2014 today a mismatch is only logged and still deduplicated, so keep the body deterministic; 409 generation_in_progress while that run is still placing its hold \u2014 retry after Retry-After); 429 with Retry-After when the project's open-generation cap is reached. Read it with jobs_get_run (generation_status, result, progress, mirror_error).",
11763
12309
  inputSchema: {
11764
12310
  type: "object",
11765
12311
  properties: {
@@ -11802,7 +12348,12 @@ var TOOLS = [
11802
12348
  collection: { type: "string" },
11803
12349
  record_id: { type: "string" },
11804
12350
  column: { type: "string" },
11805
- progress_fields: { type: "array", items: { type: "string", enum: ["progress", "stage", "message"] } }
12351
+ progress_fields: { type: "array", items: { type: "string", enum: ["progress", "stage", "message"] } },
12352
+ fence_field: {
12353
+ type: "string",
12354
+ maxLength: 80,
12355
+ description: "cms only: a declared string field stamped with this run's run_id on its first write; every later write requires it, so an older run never overwrites a row a newer run claimed."
12356
+ }
11806
12357
  },
11807
12358
  required: ["feature", "collection", "record_id"]
11808
12359
  },
@@ -11920,7 +12471,7 @@ var TOOLS = [
11920
12471
  {
11921
12472
  name: "files_create_upload_url",
11922
12473
  feature: "files",
11923
- description: "Mint a presigned PUT URL for a new file (bytes go straight to storage, never through Vxil). After uploading, call files_complete to make the object available. Optional expiresInSeconds (60 s to 100 years) makes the object expire and be deleted that long after the mint; the answer's expires_at is when (null = never), expires_in is the upload URL's life in seconds.",
12474
+ description: "Mint a presigned PUT URL for a new file (bytes go straight to storage, never through Vxil). After uploading, call files_complete to make the object available. Optional expiresInSeconds (60 s to 100 years) makes the object expire and be deleted that long after the mint; the answer's expires_at is when (null = never), expires_in is the upload URL's life in seconds. Optional checksum_sha256 is signed into the URL (the PUT must send the answer's upload_headers) and kept with the upload: files_complete answers 409 checksum_mismatch when the stored bytes hash differently, or 409 checksum_unverified when the store reports no checksum.",
11924
12475
  inputSchema: {
11925
12476
  type: "object",
11926
12477
  properties: {
@@ -11928,7 +12479,8 @@ var TOOLS = [
11928
12479
  filename: { type: "string" },
11929
12480
  content_type: { type: "string" },
11930
12481
  size_bytes: { type: "number" },
11931
- expiresInSeconds: { type: ["integer", "null"], minimum: 60, maximum: 31536e5, description: "Delete the object this many seconds after the mint, 60..3153600000 (100 years) (null = never; omitted = the files.ttl default when enabled, else never)." }
12482
+ expiresInSeconds: { type: ["integer", "null"], minimum: 60, maximum: 31536e5, description: "Delete the object this many seconds after the mint, 60..3153600000 (100 years) (null = never; omitted = the files.ttl default when enabled, else never)." },
12483
+ checksum_sha256: { type: "string", minLength: 44, maxLength: 64, description: "SHA-256 of the bytes: 64 hex or 44 base64 characters. Signed into upload_url as x-amz-checksum-sha256 (returned in upload_headers)." }
11932
12484
  },
11933
12485
  required: ["user_id", "filename", "content_type", "size_bytes"]
11934
12486
  },
@@ -12093,7 +12645,7 @@ var TOOLS = [
12093
12645
  {
12094
12646
  name: "cms_list_collections",
12095
12647
  feature: "cms",
12096
- description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value.",
12648
+ description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value. Each collection row also carries end_user_access: 'readwrite' (default), 'read' (a verified end user \u2014 a thin-client key with a session \u2014 may read but every write answers 403 server_only) or 'none' (end-user reads and writes are 403 server_only); server keys are never affected.",
12097
12649
  inputSchema: { type: "object", properties: {} },
12098
12650
  method: "GET",
12099
12651
  path: "/v1/cms/collections"
@@ -18561,12 +19113,12 @@ export default defineConfig({
18561
19113
  "render_token",
18562
19114
  "vxil_jobs_key"
18563
19115
  ],
18564
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Render Farm\" \u2014 long renders and transcodes (minutes, ffmpeg, a headless\n// browser) run on a runtime YOU rent: Trigger.dev, Modal, a container on your\n// own cloud account. vxil keeps the four things that must not be lost while\n// that runtime works: the credits held for the render, the deadline, the\n// signed completion callback, and the status row the app watches.\n//\n// request-render (your function, end-user mode)\n// \u2192 creates-or-finds the user's `renders` row (unique render_key)\n// \u2192 POST /v1/jobs/generation in WEBHOOK mode: your render endpoint,\n// credits HELD, a deadline, the status mirrored onto the row\n// vxil's generation lane\n// \u2192 POSTs your render endpoint { render_id, user_id, composition, props,\n// payload, callback_url } with your bearer token\n// your runtime (README: Trigger.dev, or your own containers)\n// \u2192 acks within 20 s, renders, POSTs { status: 'processing', \u2026 } and then\n// { status: 'completed', output_url, duration_s, \u2026 } to callback_url\n// vxil\n// \u2192 completed: credits COMMIT, every callback key lands on the row\n// \u2192 failed / no answer by the deadline: credits REFUNDED, row says failed\n// \u2192 job.generation.completed | failed \u2192 notify-ready tells the owner\n// redrive-pending (cron, every minute)\n// \u2192 starts renders that waited at the concurrency cap (429 \u2014 the app was\n// told `queued: true`), same key; tells the owner if one never starts\n//\n// No container tier and no workflow engine inside vxil: the runtime is yours,\n// the bookkeeping is vxil's. \"Credits\" are usage units on the deterministic\n// `mock` payments integration \u2014 not money; vxil is never in the flow of funds.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n generation: {\n // how many renders may be in flight at once for this project\n maxConcurrent: 20,\n // a render that never calls back fails (and refunds) after 30 minutes\u2026\n defaultTimeoutMs: 1_800_000,\n // \u2026and no render may ask for more than the platform ceiling, one hour\n maxTimeoutMs: 3_600_000,\n // one render never holds more than 50 credits\n maxReserveCredits: 50,\n // all of this project's in-flight renders together hold at most 5,000\n maxOutstandingReserveCredits: 5_000,\n },\n },\n\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n productMap: {\n render_pack_100: { creditType: 'render_credits', amount: 100, period: 'once' },\n },\n // no subscription tiers in this blueprint \u2014 credits come from packs\n tierMap: {},\n // a render that fails, times out or is cancelled gives its credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {\n // a render row is live the moment it is written\n draftPublish: false,\n // in end-user mode a signed-in user sees only the renders they own\n strictEndUserScope: true,\n // Lane-A hook (guide ch. 7): render_key IS owner + ':' + request_key,\n // server-enforced, so one user's request_key can never collide with \u2014\n // or block \u2014 another user's.\n hooks: {\n render_key_shape: {\n collection: 'renders',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.render_key == concat(item.owner, ':', item.request_key)\",\n message: \"render_key must be owner + ':' + request_key\",\n },\n },\n },\n\n notifications: { provider: 'mock', fromEmail: 'renders@render-farm.example' },\n functions: { enabled: true },\n\n // the README's keyless-container coordinator uploads each finished output\n // into this project's files (`output_file` below). The per-object ceiling\n // the upload-url call pre-checks is `maxObjectBytes` \u2014 100 MB by default,\n // which fits the coordinator's buffered upload (\"tens of MB\"); raise it\n // (up to 5 GB) for long or high-bitrate renders. Not using the coordinator?\n // Remove this and the `output_file` field.\n files: { enabled: true },\n },\n\n cms: {\n collections: {\n renders: {\n singular: 'render',\n ownerField: 'owner',\n fields: {\n // THE DEDUPE ANCHOR: a double tap or a retried request is a 409 that\n // request-render reads back \u2014 and the same key is the generation\n // run's idempotency_key, so the render endpoint is asked once.\n render_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n request_key: { type: 'string', required: true },\n owner: { type: 'string', indexSlot: 's2' },\n // which composition / preset your runtime renders (your vocabulary)\n composition: { type: 'string', required: true, indexSlot: 's3' },\n // written by vxil's status mirror: pending \u2192 processing \u2192 completed | failed\n status: { type: 'string', indexSlot: 's4' },\n credits: { type: 'int', indexSlot: 'n1' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n props: { type: 'json' },\n run_id: { type: 'text' },\n // \u2500\u2500 keys your runtime sends back. EVERY key of the completion body is\n // written onto this row, so each one must be a declared field (a\n // write naming an unknown field is refused: vxil then writes the\n // status word alone and the run reports `mirror_error` with the\n // keys it dropped \u2014 declare the field so its value lands too).\n output_url: { type: 'text' },\n // the files-feature object id, when a coordinator uploads the output\n // into this project's files (README \"Keys stay out of the container\")\n output_file: { type: 'file' },\n duration_s: { type: 'float' },\n // progress keys: a `processing` ping carries them onto the row\n // (request-render's status_mirror.progress_fields \u2014 progress 0..100,\n // a float: the run keeps 2 decimals,\n // stage \u2264 64 chars, message \u2264 200), and the completion body writes\n // them too (progress: 100, stage: 'done').\n progress: { type: 'float', validation: { min: 0, max: 100 } },\n stage: { type: 'string' },\n message: { type: 'text' },\n // written by notify-ready from job.generation.failed (and by\n // redrive-pending when a render never got a run)\n error: { type: 'text' },\n // written by redrive-pending: how often it tried to start this render,\n // and when it last did. The re-driver's read needs no new index \u2014\n // `status` (s4) and `created_at` (t1) are slots; `run_id: null` is a\n // residual test over the rows they pick.\n redrive_attempts: { type: 'int' },\n redriven_at: { type: 'datetime' },\n },\n },\n },\n },\n\n functions: {\n // Starts ONE render for the signed-in user. Invoke it in END-USER mode\n // (with the user's session): the held credits are forced onto that user,\n // and the row is theirs.\n 'request-render': {\n entry: './functions/request-render.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'jobs:write'],\n // render_url: your render endpoint (https). render_token: the bearer\n // token that endpoint checks. Both ride the generation run; vxil's\n // generation lane \u2014 not this function \u2014 calls the endpoint.\n secrets: ['secret:render_url', 'secret:render_token'],\n egressAllow: [],\n signature: {\n input: { composition: 'string', request_key: 'string', props: 'json?' },\n output:\n '{ item_id: string; run_id: string; credits: number }'\n + ' | { duplicate: true; request_key: string; item_id: string; run_id: string | null; status: string }',\n },\n },\n\n // THE BACKLOG RE-DRIVER. At the generation cap request-render answers 429\n // and the row waits `pending` with no run; every minute this starts the\n // oldest such rows (\u2265 30 s old, 20 per tick) with the SAME idempotency key,\n // stops at the first 429 (or at a refusal that means the setup is wrong),\n // and fails \u2014 and tells the owner of \u2014 a row that waited over an hour.\n // overlap 'skip': a slow tick is never doubled by the next one.\n // Free plan: a function cron may fire at most every 15 minutes \u2014 use\n // '*/15 * * * *' there (README \"The backlog\").\n 'redrive-pending': {\n entry: './functions/redrive-pending.ts',\n trigger: { kind: 'cron', schedule: '* * * * *', overlap: 'skip' },\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n // vxil_jobs_key: an API key of this backend holding ONLY jobs:write \u2014 a\n // cron tick has no signed-in user to hold credits for, so the start is\n // made as your trusted server (README \"The backlog\")\n secrets: ['secret:vxil_jobs_key', 'secret:render_url', 'secret:render_token'],\n egressAllow: [],\n },\n\n // job.generation.completed | failed \u2192 write the failure cause onto the row\n // and tell the owner. A non-2xx is retried; the notification's\n // Idempotency-Key (one per run) makes a redelivery send nothing twice.\n 'notify-ready': {\n entry: './functions/notify-ready.ts',\n trigger: { kind: 'webhook', source: 'job.generation.', retry: { maxAttempts: 3 } },\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n egressAllow: [],\n },\n },\n\n secrets: {\n render_url: {\n feature: 'functions',\n description: 'your render endpoint \u2014 the https URL vxil POSTs each render to (a Trigger.dev relay, or your own container endpoint)',\n },\n render_token: {\n feature: 'functions',\n description: 'a long random token your render endpoint checks on the Authorization header (Bearer \u2026)',\n },\n vxil_jobs_key: {\n feature: 'functions',\n description: 'an API key of this backend holding ONLY jobs:write \u2014 redrive-pending starts backlogged renders with it (vxil keys mint --name render-redrive --scopes jobs:write). jobs:write also lets it cancel or replay any run, enqueue jobs and manage schedules and flow rules: a server key, kept only here',\n },\n },\n});\n",
18565
- "readme": "# Render Farm \u2014 long renders on a runtime you rent, with vxil holding the credits, the deadline and the callback\n\n```bash\nmkdir my-renders && cd my-renders\nvxil init --template render-farm # init scaffolds into the CURRENT directory\nprintf '%s' \"$RENDER_URL\" | vxil secrets set functions/render_url # your render endpoint (https)\nprintf '%s' \"$RENDER_TOKEN\" | vxil secrets set functions/render_token # a long random token it checks\n# the backlog re-driver's key: an API key of this backend holding ONLY jobs:write\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name render-redrive --scopes jobs:write --json | jq -r .api_key | vxil secrets set functions/vxil_jobs_key\nvxil push\n```\n\n> **Plan note.** The functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n> On the Free plan a function cron may also fire at most every 15 minutes, so change `redrive-pending`'s\n> schedule to `'*/15 * * * *'` there. The blueprint is written for Developer and up, where it runs every\n> minute. Everything else works the same; a backlog just drains more slowly.\n\nA video render, a transcode, a headless-browser capture: minutes of CPU, ffmpeg or Chromium. That does\nnot fit in a vxil function (a delivered trigger gets about a minute), and vxil will not grow a container\ntier or a workflow engine to run it. So the work runs on **a runtime you rent** \u2014 Trigger.dev, Modal,\na container on your own cloud account \u2014 and vxil keeps the four things that must survive while it\nruns:\n\n| vxil holds | so that |\n|---|---|\n| **the credits** reserved for the render | a failed, abandoned or cancelled render gives them back, and a user can never start more than they can pay for |\n| **the deadline** | a render your runtime never reports on fails and refunds after 30 minutes (at most one hour) |\n| **the signed completion callback** | your runtime needs no vxil key: the URL it is handed is the credential for that one render |\n| **the status row** | the app reads (or subscribes to) one `renders` row: `pending \u2192 processing \u2192 completed | failed`, plus everything your runtime sent back |\n\n**What this blueprint teaches that the others do not:** the hand-off to **your own** long-running\nruntime through a **webhook-mode generation run** \u2014 the contract your endpoint and your worker must\nkeep, and two complete runtime options below. (`fal-media` shows the same lane against a vendor queue\nAPI; `job-runner` shows a provider call vxil polls.)\n\n## What you get\n\n- **`renders`** \u2014 one row per render, owned by the user who asked for it (`strictEndUserScope`: a\n signed-in user reads only their own). `render_key` is the owner + `:` + the client's `request_key`\n (the composition is enforced by a `beforeWrite` hook) and is **unique**, so a double tap or a retried\n request finds the first row instead of starting a second render. Your runtime's answer lands on the\n row: `output_url`, `duration_s`, `progress`, `stage`, `message`.\n- **`request-render`** (http function, end-user mode) \u2014 creates-or-finds the row, then starts ONE\n generation run: your endpoint (`render_url`), your token on its `Authorization` header, a status\n mirror onto the row, `reserve_credits` for the render (5 `render_credits`), a 30-minute deadline, and\n the `render_key` as the run's `idempotency_key` \u2014 so a re-driven start gets the same run back. The\n credits it holds are the function's fixed price, never a value read from the row.\n- **`redrive-pending`** (cron function, every minute, `overlap: 'skip'`) \u2014 drains the backlog. When\n the project already has `generation.maxConcurrent` renders in flight, `request-render` answers `429`\n (with `queued: true`) and the row waits `pending` with no run. This function starts those rows,\n oldest first, with the **same** idempotency key, and tells the owner when one never starts\n ([the backlog](#the-backlog-maxconcurrent-429-and-the-re-driver)).\n- **`notify-ready`** (webhook function on `job.generation.`) \u2014 writes the failure cause onto the row (a\n platform class such as `GenerationExpired` gets a short human hint after it) and sends the owner one\n message per run (`Idempotency-Key: render-ready:<run_id>`). A render refused for too few credits\n keeps `insufficient_credits`; when `request-render` refused it, the caller already got the `402`\n and nothing is sent, and when the re-driver started it (the user last heard \"queued\"), the owner\n is told once (`Idempotency-Key: render-not-started:<item_id>`, the re-driver's own key). It\n **branches on `error_class`**, because not every `job.generation.failed` is a render that failed:\n `PaymentsUnavailable` (the credits could not be held at the start \u2014 the row is still queued and a\n new run follows) is skipped (and when a re-sent `request-render` raced that start and linked its\n run, the row is unlinked \u2014 `run_id: null`, only while it is still `pending` on that run \u2014 so\n `redrive-pending` starts it), and `Cancelled` (your own cancel) writes `error: cancelled` and sends\n nothing. Both are also lower-level events (`warn` / `info`), so they stay out of an immediate\n failure digest.\n- **credits** \u2014 a payments integration on the `mock` provider (no provider account needed to try it).\n \"Credits\" are usage units you meter, not money.\n\nThe row is a **view** for the app; the run and the ledger are the truth. If your app's client key\ncarries `cms:write`, a signed-in user can edit their own `renders` row (say, set `status` to\n`completed`) \u2014 that changes nothing they are charged or given. Anything that grants something on\ncompletion should read the run (`GET /v1/jobs/runs/{run_id}`) or react to `job.generation.completed`,\nas `notify-ready` does \u2014 or give the client key only `cms:read`.\n\n## The contract your runtime keeps\n\nWhatever runs the render, these are the only things it has to do:\n\n| Step | What arrives / what to send |\n|---|---|\n| **Start** | vxil `POST`s your `render_url` with `Authorization: Bearer <render_token>`, the header `x-vxil-run-id` and JSON `{ render_id, user_id, composition, props, payload: { generation_id, correlation_id, deadline_at }, callback_url }` (`user_id` is the render's owner). Check the token, **store or queue the work and answer `2xx` within 20 seconds** \u2014 never render inline, and never wait on a container's cold start. When you cannot take the work right now, say so at once with a `503` instead of holding the request open; the start is then sent again later, as after a timeout. `408` / `429` / `5xx` / a timeout is retried with backoff (30 s, then 1, 2 and 4 minutes, doubling up to 10 minutes; a `Retry-After` on your answer sets the wait, 1 s to 10 min, never past the deadline; `max_attempts` counts start calls) until the run's attempts are used \u2014 with the default five, the last try comes 7 to 8 minutes after the first, so leave room for that in the deadline. Any other `4xx` ends the run and refunds the credits. A start can arrive more than once (a lost answer is retried), so **dedupe by the run id** (`x-vxil-run-id`; in this blueprint `render_id` = `payload.generation_id` names the same single run), never by a hash of the content ([why](#one-render-one-run-dedupe-a-doubled-start)). |\n| **Progress** (optional, best-effort) | `POST callback_url` with `{\"status\": \"processing\", \"progress\": 40, \"stage\": \"encoding\"}`. A processing ping moves the row to `processing` and writes its `progress` (0\u2013100) / `stage` (\u2264 64 chars) / `message` (\u2264 200 chars) onto the row \u2014 `request-render` asks for that with `status_mirror.progress_fields` \u2014 so the app shows live progress by watching the row. Other keys on a ping are not written to the row (the run keeps the latest report, `GET /v1/jobs/runs/{run_id}` \u2192 `progress`). **At most one ping every 5 seconds**, sending the latest state; a ping that fails or answers `429` is simply dropped ([callback limits](#callback-limits-progress-is-best-effort-the-final-post-must-arrive)). |\n| **Done** (must arrive) | `POST callback_url` with `{\"status\": \"completed\", \"output_url\": \"https://\u2026\", \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\"}` \u2014 or, when the output is uploaded into this project's files, `{\"status\": \"completed\", \"output_file\": \"obj_\u2026\", \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\"}` ([below](#keys-stay-out-of-the-container-the-recommended-shape)). At most 256 KiB, and **every key a declared field of `renders`** (add a field before you send a new key; [test it](#a-contract-test-for-your-runtime)). The credits are committed and every key is written onto the row. **Retry this post on `429`, `5xx` and network errors, honouring `Retry-After`, until `deadline_at`** \u2014 never drop it (`postCallback` below). |\n| **Failed** (must arrive) | `POST callback_url` with `{\"status\": \"failed\", \"error\": \"render_failed\", \"hint\": \"ffmpeg exited 1: \u2026\"}`. The credits are refunded; `error` (a short code) and `hint` reach `notify-ready` as `error_class` / `error_hint` and land on the row's `error`. Retried exactly like `completed`. |\n| **Never answers** | the run fails at the deadline (`GenerationExpired`), the credits are refunded, and a callback after that changes nothing. |\n| **The deadline** | `payload.deadline_at` (an ISO time) is when vxil stops waiting \u2014 to the second: any callback that arrives at or after it ends the run as `GenerationExpired` (credits refunded), and a `completed` posted then is answered `{ generation_status: \"failed\", expired: true }` \u2014 the output exists, but the user was refunded and the row says failed. Check it before each attempt starts: a render that cannot finish by then should post `failed` and stop. |\n\n`callback_url` needs no other credential \u2014 and nothing else should see it. A repeated `completed` or\n`failed` post is answered with the settled state and changes nothing, so your worker can safely retry\nits own callback on a network error. The output bytes stay where your runtime wrote them (your bucket,\nyour CDN) and vxil stores the keys, not the file, unless a coordinator uploads the output into this\nproject's files ([next section](#keys-stay-out-of-the-container-the-recommended-shape)).\n\nEach start request also carries an `X-Vxil-Jobs-Signature` header (verifiable with your project's\njobs signing secret, `GET /v1/jobs/signing-secret`) and `x-vxil-run-id`. This blueprint uses the\nbearer token because it is one string comparison in any language.\n\n### Callback limits: progress is best-effort, the final post must arrive\n\nEach render's `callback_url` has its **own** budget at vxil's edge: about 120 posts a minute for that\none run. Every post over it, pings included, is counted against a small separate allowance (about 20\na minute), and on that allowance only a `completed` or `failed` post is accepted. A runtime that pings\nevery 5 seconds never gets near either number. One that sends more than about 140 posts in a minute\nuses the allowance up as well, and its final post waits for the next minute. All of a project's\ncallbacks together also share a ceiling of about 3,000 a minute, plus about 300 a minute for final posts.\nEvery number here is best-effort (counted per serving machine). The signature in the URL identifies\nthe run, so fifty renders posting from one shared egress IP do not share a budget. Over a budget the\nanswer is `429 rate_limited` with `Retry-After: 30`.\n\nTwo rules follow, and the helpers below keep both:\n\n- **Progress is best-effort.** Post at most one `processing` ping every 5 seconds, carrying the latest\n state; a ping that fails or answers `429` is dropped, and the next one carries the newer state.\n- **The final post must arrive.** A `completed` or `failed` post that answers `429`, `5xx` or never\n gets an answer is sent again, after `Retry-After` when there is one, until `deadline_at`. A repeat\n of a final post is harmless: it is answered with the settled state and changes nothing. Any other\n `4xx` means the URL or the body is wrong, so retrying will not help.\n\n```ts\n// callbacks.ts \u2014 for the coordinator, a task, or any worker that posts to callback_url\n/** completed / failed: MUST arrive. Retries 429, 5xx and network errors (honouring Retry-After)\n * until `untilMs`; throws only when it cannot deliver in time, or on another 4xx. */\nexport async function postCallback(callbackUrl: string, body: Record<string, unknown>, untilMs: number): Promise<void> {\n for (let attempt = 0; ; attempt++) {\n let res: Response | undefined;\n try {\n res = await fetch(callbackUrl, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n });\n } catch { /* a network error: send it again */ }\n if (res && res.ok) return;\n if (res && res.status !== 429 && res.status < 500) throw new Error(`callback refused: ${res.status}`);\n const retryAfterS = Number(res?.headers.get('retry-after'));\n const waitMs = retryAfterS > 0 ? retryAfterS * 1000 : Math.min(1000 * 2 ** attempt, 30_000);\n if (Date.now() + waitMs > untilMs) throw new Error(`callback not delivered in time (last answer: ${res?.status ?? 'none'})`);\n await new Promise((r) => setTimeout(r, waitMs));\n }\n}\n\n/** processing: best-effort, at most one post every `gapMs`; a refused or failed ping is dropped. */\nexport function progressReporter(callbackUrl: string, gapMs = 5_000) {\n let last = 0;\n return async (p: { progress?: number; stage?: string; message?: string }): Promise<void> => {\n if (Date.now() - last < gapMs) return; // coalesced: the next ping carries newer state\n last = Date.now();\n await fetch(callbackUrl, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ status: 'processing', ...p }),\n }).catch(() => undefined);\n };\n}\n```\n\n### One render, one run: dedupe a doubled start\n\nvxil sends the start again when it did not get your answer: a timeout, a dropped connection, a `5xx`.\nSo the same start can reach you twice, even while the first copy is still being handled. Claim the\nwork under the **run id** (`x-vxil-run-id`) before you launch anything. Answer `202` to a repeat\nonly once the work is launched. While the first copy is still launching, answer `503`, so the start\nstays open and vxil sends it again; a `202` would end vxil's retries even if that first launch then failed. In this blueprint `render_id` (= `payload.generation_id`, the row's id)\nnames the same single run, because `request-render` starts one run per row.\n\nNever key the claim by a hash of the content (the composition and props). Two users who ask for the\nsame render produce two runs with one hash: the second start would find the first's claim and be\nrefused, or overwrite the first's `callback_url`, and that run would wait out its deadline and refund.\nIf you want identical renders to share their output, claim by run id and look the output up by hash\nas a separate step.\n\n## Keys stay out of the container (the recommended shape)\n\nThe render container is the part of your system that runs the most third-party code (ffmpeg,\nChromium, fonts and media from the user's props), so give it **no vxil key at all**. This is the\nshape the blueprint recommends, whatever runs the container:\n\n```\nvxil \u2500\u2500start\u2500\u2500\u25B6 coordinator \u2500\u2500launch\u2500\u2500\u25B6 container\n (render_url) \u2502 progress / failed \u2500\u2500\u2500\u2500\u2500\u2500\u25B6 callback_url (keyless)\n \u25B2 output bytes \u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u2514\u2500 mints the upload URL with ITS files:write key, PUTs the bytes,\n completes the object, POSTs \"completed\" + output_file \u2500\u2500\u25B6 callback_url\n```\n\n- **The files feature is on.** The blueprint's config enables it (`files: { enabled: true }`) and\n declares `output_file` as a `file` field; without it every upload-url call is refused and the\n render waits out its deadline. Its per-object ceiling, `maxObjectBytes`, is 100 MB by default,\n enough for the buffered coordinator below; raise it for long or high-bitrate renders.\n- **The coordinator** is your `render_url`: a small endpoint in your own account \u2014 an edge worker\n with a per-render lock, or a route on any thin server. It is the only piece that holds a vxil key,\n and that key holds only **`files:write`** (`vxil keys mint --name render-uploads --scopes files:write`).\n- **The container** gets the job, the keyless `callback_url` (for `processing` pings and for\n `failed`), an `output_url` on the coordinator and an **output ticket** for it: a token signed for\n that one render and useless after its deadline, sent on the `Authorization` header (never in the\n URL, where access logs would keep it).\n- **The upload happens once the size is known.** The container POSTs the finished file to its\n ticket. The coordinator reads it, mints the files upload URL for exactly that size\n (the quota pre-check uses it), PUTs the bytes, completes the object, and only then posts\n `completed` with the object id as `output_file`. A crash anywhere before that leaves the render\n open, and it refunds at the deadline like any other.\n\n```ts\n// coordinator.ts \u2014 your render_url. A standard fetch handler (an edge worker, or a Node 18+ adapter).\n// Env: RENDER_TOKEN (= the vxil secret render_token), TICKET_SECRET (a long random string),\n// VXIL_FILES_KEY (an API key holding ONLY files:write), VXIL_BASE (https://api.vxil.com).\nimport { postCallback } from './callbacks';\n\ntype Start = {\n render_id: string; user_id: string; composition: string; props?: Record<string, unknown>;\n payload: { generation_id: string; deadline_at: string }; callback_url: string;\n};\ntype Ticket = { run_id: string; render_id: string; user_id: string; callback_url: string; exp: number };\ntype Env = { RENDER_TOKEN: string; TICKET_SECRET: string; VXIL_FILES_KEY: string; VXIL_BASE: string };\n\nconst enc = new TextEncoder();\nconst b64u = (b: ArrayBuffer | Uint8Array) =>\n btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\nconst unb64u = (s: string) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));\nconst hmacKey = (secret: string) =>\n crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);\nasync function sealTicket(env: Env, t: Ticket): Promise<string> {\n const body = b64u(enc.encode(JSON.stringify(t)));\n return `${body}.${b64u(await crypto.subtle.sign('HMAC', await hmacKey(env.TICKET_SECRET), enc.encode(body)))}`;\n}\nasync function openTicket(env: Env, raw: string): Promise<Ticket | null> {\n const [body, sig] = raw.split('.');\n if (!body || !sig) return null;\n try {\n // crypto.subtle.verify compares in constant time (a `!==` on the signature would not)\n if (!(await crypto.subtle.verify('HMAC', await hmacKey(env.TICKET_SECRET), unb64u(sig), enc.encode(body)))) return null;\n const t = JSON.parse(new TextDecoder().decode(unb64u(body))) as Ticket;\n return t.exp > Date.now() ? t : null;\n } catch {\n return null; // not base64url / not JSON\n }\n}\n/** The output's file extension, from the Content-Type the container sends. */\nconst EXT: Record<string, string> = {\n 'video/mp4': 'mp4', 'video/webm': 'webm', 'image/gif': 'gif', 'image/png': 'png', 'image/jpeg': 'jpg', 'application/pdf': 'pdf',\n};\n/** Sent with a 503: when to send the request again. */\nconst AGAIN_IN_30S = { 'retry-after': '30' };\nconst vxil = (env: Env, path: string, body?: unknown) => fetch(`${env.VXIL_BASE}${path}`, {\n method: 'POST',\n headers: { authorization: `Bearer ${env.VXIL_FILES_KEY}`, 'content-type': 'application/json' },\n ...(body ? { body: JSON.stringify(body) } : {}),\n});\n\nexport default {\n async fetch(req: Request, env: Env): Promise<Response> {\n const url = new URL(req.url);\n\n // 1. the start: check the token, launch the container, answer inside 20 s\n if (req.method === 'POST' && url.pathname === '/start') {\n if (req.headers.get('authorization') !== `Bearer ${env.RENDER_TOKEN}`) return new Response('unauthorized', { status: 401 });\n const s = (await req.json()) as Start;\n // a run started before request-render sent user_id (an older copy of this\n // blueprint): a server-mode upload must name its user, so refuse the start \u2014\n // a 4xx ends that run and refunds it, instead of a 422 at upload time\n if (!s.user_id) return new Response('start body has no user_id: redeploy request-render', { status: 400 });\n // one run, one render: claim the work under the RUN id before launching anything. The claim\n // says `launching` (and expires after a minute) until the launch succeeds, then `launched`.\n // A start vxil re-sends (a lost answer) is acknowledged with 202 only once the work is\n // `launched`; while another copy is still launching it gets 503, so vxil keeps retrying\n // instead of treating a launch that may yet fail as accepted.\n // Never claim by a content hash: two users' identical renders are two runs.\n const runId = req.headers.get('x-vxil-run-id') ?? s.payload.generation_id;\n const claimKey = `start:${runId}`;\n if (!(await store.claim(claimKey, 'launching', 60_000))) {\n if ((await store.get(claimKey)) === 'launched') return Response.json({ accepted: true, duplicate: true }, { status: 202 });\n return new Response('this render is still being launched', { status: 503, headers: AGAIN_IN_30S });\n }\n const ticket = await sealTicket(env, {\n run_id: runId, render_id: s.render_id, user_id: s.user_id, callback_url: s.callback_url,\n exp: Date.parse(s.payload.deadline_at), // useless once vxil stops waiting\n });\n try {\n await launchContainer({ // YOUR container platform's API: it must QUEUE\n run_id: runId, render_id: s.render_id, // the job and return at once \u2014 never wait.\n // Pass run_id as its idempotency key if it has one\n composition: s.composition, props: s.props ?? {},// here for a cold start\n deadline_at: s.payload.deadline_at,\n callback_url: s.callback_url, // for processing pings and `failed`\n output_url: `${url.origin}/output`, // POST the file here, with\n output_ticket: ticket, // Authorization: Bearer <output_ticket>\n });\n } catch {\n await store.release(claimKey); // not launched: let vxil's retry try again\n return new Response('cannot take the render right now', { status: 503, headers: AGAIN_IN_30S });\n }\n await store.put(claimKey, 'launched'); // no expiry: every later copy is a duplicate\n return Response.json({ accepted: true }, { status: 202 });\n }\n\n // 2. the output: the container POSTs the finished file here (again, on any non-2xx answer)\n if (req.method === 'POST' && url.pathname === '/output') {\n const t = await openTicket(env, (req.headers.get('authorization') ?? '').replace(/^Bearer /, ''));\n if (!t) return new Response('bad or expired ticket', { status: 403 });\n const contentType = (req.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase();\n const ext = EXT[contentType];\n if (!ext) return new Response(`send the output's Content-Type (one of: ${Object.keys(EXT).join(', ')})`, { status: 415 });\n // a re-sent output after the upload already happened: skip straight to the settle\n let object_id = await store.get(`output:${t.run_id}`);\n if (!object_id) {\n // buffered: fine for outputs of tens of MB \u2014 see \"Very large outputs\" below\n const bytes = await req.arrayBuffer();\n const size = bytes.byteLength;\n if (size === 0) return new Response('empty output', { status: 400 });\n\n const minted = await vxil(env, '/v1/files/upload-url', {\n user_id: t.user_id, filename: `${t.render_id}.${ext}`, content_type: contentType, size_bytes: size,\n });\n if (!minted.ok) return new Response(`upload-url ${minted.status}`, { status: 502 }); // the container sends the file again\n const m = ((await minted.json()) as { data: { object_id: string; upload_url: string } }).data;\n const put = await fetch(m.upload_url, { method: 'PUT', headers: { 'content-type': contentType }, body: bytes });\n if (!put.ok) return new Response(`upload ${put.status}`, { status: 502 });\n const done = await vxil(env, `/v1/files/${encodeURIComponent(m.object_id)}/complete`);\n if (!done.ok) return new Response(`complete ${done.status}`, { status: 502 });\n object_id = m.object_id;\n await store.put(`output:${t.run_id}`, object_id);\n }\n\n // 3. settle the render on the keyless callback: a MUST-ARRIVE post. Retry here for up to a\n // minute (never past the deadline); if it still did not land, answer 503 and the container\n // sends the output again \u2014 the note above turns that into one more settle attempt.\n try {\n await postCallback(t.callback_url,\n { status: 'completed', output_file: object_id, progress: 100, stage: 'done' },\n Math.min(t.exp, Date.now() + 60_000));\n } catch (e) {\n return new Response(`callback: ${String(e)}`, { status: 503, headers: AGAIN_IN_30S });\n }\n return Response.json({ object_id });\n }\n return new Response('not found', { status: 404 });\n },\n};\n\ndeclare function launchContainer(job: Record<string, unknown>): Promise<void>; // your container platform's API\n/** Your coordinator's own small store: a key-value namespace, a table, a per-key lock. `claim` is\n * an insert-if-absent (of `value`, expiring after `ttlMs`) that answers true for the FIRST caller\n * only; `put` writes without an expiry. */\ndeclare const store: {\n claim(key: string, value: string, ttlMs: number): Promise<boolean>; release(key: string): Promise<void>;\n get(key: string): Promise<string | null>; put(key: string, value: string): Promise<void>;\n};\n```\n\nThe container's side is three kinds of HTTP call and no vxil key: `processing` pings to\n`callback_url`, the file to `output_url` (with `Authorization: Bearer <output_ticket>` and the\nfile's `Content-Type`), and `{\"status\": \"failed\", \u2026}` to `callback_url` if it gives up. The\ncontainer sends its pings through `progressReporter` and its `failed` through `postCallback`\n([callbacks.ts](#callback-limits-progress-is-best-effort-the-final-post-must-arrive)), and it sends\nthe output again on any non-`2xx` answer until `deadline_at`. Five notes on the shape:\n\n- **A doubled start launches one container.** The coordinator claims `start:<run id>` as\n `launching` before it launches and marks it `launched` once the launch succeeded. A start vxil\n re-sends is answered `202` and launches nothing only when the claim says `launched`; while the\n first copy is still launching, the re-sent one is answered `503`, so vxil keeps the start alive.\n (A `202` there would end vxil's retries, and if the first launch then failed nothing would run\n and the render would refund at its deadline.) When the launch fails, the coordinator releases\n the claim and answers `503` at once, and vxil sends the start again later. A coordinator that\n dies mid-launch leaves a `launching` claim that expires after a minute, so a later copy launches;\n pass the run id as your container platform's idempotency key, where it has one, so that copy\n cannot start a second container if the first launch did go through.\n- **A retried output post** (the container saw a network error after the coordinator had uploaded)\n finds `output:<run id>` in the coordinator's store, skips the upload and only sends the\n `completed` post again. A repeated `completed` is answered with the settled state and changes\n nothing, so the final post can be retried as often as it takes.\n- **Very large outputs.** The coordinator above holds the whole file in memory, and passing gigabytes\n through it costs its bandwidth too. For big files, have the container report the size first\n (`POST /output-url` with its ticket on the `Authorization` header and `{ size_bytes }`) and let the coordinator answer with the\n presigned upload URL it minted; the container PUTs straight to it, and the coordinator completes\n the object and posts `completed` when the container says it is done. The container still holds no\n key: a presigned URL is good for one object for a few minutes.\n- **Upgrading an earlier copy of this blueprint.** Renders started before `request-render` put\n `user_id` in the start body have none, and a server-mode upload must name its user. The\n coordinator refuses such a start with a `400`, which ends that run and refunds it at once;\n redeploy `request-render` (`vxil push`) before you point `render_url` at the coordinator.\n- **Serving it.** The row now holds a files object id. Read it back with a signed download URL, or\n publish it from a settle function with a server key (`vx.files.publish(object_id)`) for a stable\n public URL served from the edge cache.\n\nOptions A and B below show the same contract with the runtime posting `completed` itself (an\n`output_url` in your own bucket). Either can adopt the coordinator: point `render_url` at it, and have\nstep 1 trigger the Trigger.dev task or spawn the Modal function.\n\n## Option A \u2014 Trigger.dev (v4)\n\nTrigger.dev runs the render as a task on a machine you pick, with ffmpeg or Chromium baked into the\nimage, retries, and its own run dashboard. Two pieces: a **relay endpoint** that turns vxil's start\nrequest into a Trigger.dev trigger, and the **task**.\n\n**Why a relay, and not `render_url` pointed straight at Trigger.dev's trigger API?** vxil sends\n`callback_url` beside `payload` at the top level of the body, and Trigger.dev's trigger API passes\nonly `payload` to the task \u2014 the task would never see where to report. The relay is ~30 lines and is\nalso where your `render_token` is checked. Host it anywhere that serves https: a serverless function on\nyour web host, a small edge worker, a route in your existing API.\n\n```ts\n// relay.ts \u2014 your render_url. Standard fetch handler (edge worker / serverless function / Node 18+ adapter).\n// Env: RENDER_TOKEN (the same value as the vxil secret render_token), TRIGGER_SECRET_KEY (tr_prod_\u2026 / tr_dev_\u2026).\ntype Start = {\n render_id: string; composition: string; props?: Record<string, unknown>;\n payload: { generation_id: string; correlation_id?: string; deadline_at: string }; callback_url: string;\n};\n\nexport default {\n async fetch(req: Request, env: { RENDER_TOKEN: string; TRIGGER_SECRET_KEY: string }): Promise<Response> {\n if (req.method !== 'POST') return new Response('method not allowed', { status: 405 });\n if (req.headers.get('authorization') !== `Bearer ${env.RENDER_TOKEN}`) {\n return new Response('unauthorized', { status: 401 }); // a 4xx ends the vxil run (and refunds)\n }\n const s = (await req.json()) as Start;\n const res = await fetch('https://api.trigger.dev/api/v1/tasks/render-video/trigger', {\n method: 'POST',\n headers: { authorization: `Bearer ${env.TRIGGER_SECRET_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n payload: {\n render_id: s.render_id, composition: s.composition, props: s.props ?? {},\n callback_url: s.callback_url, deadline_at: s.payload.deadline_at,\n },\n options: {\n // a start vxil re-sends (a lost answer) triggers the SAME Trigger.dev run\n idempotencyKey: `render:${s.render_id}`,\n // a render still queued after 3 minutes is dropped (it never runs, and no\n // onFailure fires \u2014 vxil refunds it at its deadline). Part of the budget below.\n ttl: '3m',\n tags: [`render_${s.render_id}`],\n },\n }),\n });\n if (res.ok) return Response.json({ accepted: true }, { status: 202 });\n // Trigger.dev busy or down: answer 503 and vxil tries the start again; anything else ends the run\n return new Response(`trigger.dev ${res.status}`, { status: res.status === 429 || res.status >= 500 ? 503 : 400 });\n },\n};\n```\n\n```ts\n// trigger.config.ts \u2014 ffmpeg and Chromium in the task image\nimport { defineConfig } from '@trigger.dev/sdk';\nimport { ffmpeg } from '@trigger.dev/build/extensions/core';\nimport { puppeteer } from '@trigger.dev/build/extensions/puppeteer';\n\nexport default defineConfig({\n project: '<your project ref>',\n dirs: ['./trigger'],\n maxDuration: 600, // CPU seconds PER ATTEMPT \u2014 the task sets its own; see the budget below\n build: { extensions: [ffmpeg(), puppeteer()] }, // puppeteer also needs PUPPETEER_EXECUTABLE_PATH set in the Trigger.dev env\n});\n```\n\n```ts\n// trigger/render-video.ts \u2014 the task: render, upload, report to vxil\nimport { task, metadata, logger } from '@trigger.dev/sdk';\nimport { postCallback, progressReporter } from '../callbacks'; // the helpers above\n\ntype Payload = {\n render_id: string; composition: string; props: Record<string, unknown>;\n callback_url: string; deadline_at: string; // when vxil stops waiting (ISO)\n};\n\n/** The longest one attempt takes, wall clock, with margin. An attempt that cannot\n * finish before deadline_at does not start: it tells vxil, so the credits come back now. */\nconst ATTEMPT_WALL_MS = 12 * 60_000;\n\n/** completed / failed: retried on 429 / 5xx / network errors (Retry-After honoured) until the\n * deadline \u2014 a repeated final post is a no-op on vxil's side, so retrying is always safe. */\nconst report = (p: Payload, body: Record<string, unknown>) =>\n postCallback(p.callback_url, body, Date.parse(p.deadline_at));\n\nexport const renderVideo = task({\n id: 'render-video',\n machine: 'large-1x', // 4 vCPU / 8 GB \u2014 size to your renders\n maxDuration: 600, // CPU seconds per attempt (10 min) \u2014 not wall time\n retry: { maxAttempts: 2, minTimeoutInMs: 5_000, maxTimeoutInMs: 30_000 },\n run: async (p: Payload) => {\n if (Date.now() + ATTEMPT_WALL_MS > Date.parse(p.deadline_at)) {\n // too late to finish inside vxil's deadline: refund now, and do not retry\n await report(p, { status: 'failed', error: 'deadline', hint: 'no time left for another attempt' });\n return { skipped: 'deadline' };\n }\n metadata.set('stage', 'rendering'); // Trigger.dev's own run view\n const progress = progressReporter(p.callback_url); // best-effort, at most one ping per 5 s\n await progress({ stage: 'rendering', progress: 0 });\n\n // \u2026your render: drive Chromium for frames, run ffmpeg \u2014 call progress({ progress, stage })\n // as often as you like (it coalesces) \u2014 and write the file\n // to YOUR bucket keyed by render_id (so a retried attempt overwrites, not duplicates)\u2026\n const outputUrl = `https://cdn.example.com/renders/${p.render_id}.mp4`;\n const durationS = 31.2;\n logger.info('rendered', { render_id: p.render_id, outputUrl });\n if (Date.now() > Date.parse(p.deadline_at)) {\n // vxil has already failed and refunded this render: the post below is answered\n // with that settled state. Your sizing is off \u2014 widen the budget below.\n logger.warn('finished after the vxil deadline', { render_id: p.render_id });\n }\n\n await report(p, {\n status: 'completed', output_url: outputUrl, duration_s: durationS, progress: 100, stage: 'done',\n });\n return { output_url: outputUrl };\n },\n // after the last attempt THROWS: tell vxil, so the credits come back now, not at the\n // deadline. Not called when an attempt exceeds maxDuration or the run expires on its\n // ttl \u2014 those refund only at vxil's deadline.\n onFailure: async ({ payload, error }) => {\n await report(payload, {\n status: 'failed', error: 'render_failed', hint: String(error instanceof Error ? error.message : error).slice(0, 200),\n });\n },\n});\n```\n\n**Budget the wall clock.** Trigger.dev's limits and vxil's deadline are separate clocks, and only\nvxil's refunds. Size them so a render always ends \u2014 `completed` or `failed` \u2014 before vxil's deadline:\n\n```\nttl + maxAttempts \xD7 (longest attempt, wall clock) + retry backoff < timeout.after_ms\n3 min + 2 \xD7 12 min + \u2264 1 min = 28 min < 30 min\n```\n\n`maxDuration` counts **CPU time per attempt**, not wall time across the run, so it does not bound\nthe sum: the `deadline_at` check at the start of each attempt does. If your renders need more, raise\n`RENDER_DEADLINE_MS` in `request-render` (up to `generation.maxTimeoutMs`, one hour) and resize the\nrest to fit.\n\n**Be honest with yourself about three things before you ship on Trigger.dev Cloud:**\n\n- **Data residency.** Trigger.dev Cloud keeps its operational and log data \u2014 including each run's\n payload \u2014 in the US (us-east-1), even when the machines run elsewhere. Your render props and the\n `callback_url` pass through it. If that rules it out, self-host Trigger.dev for your own app, or use\n option B.\n- **The callback URL is a credential for one render.** It appears in Trigger.dev's run payload and\n dashboard. It can settle only that render, and stops mattering once the render is settled.\n- **Two clocks, and `onFailure` is not a guarantee.** Trigger.dev calls `onFailure` only after the last\n attempt throws. A run that exceeds `maxDuration`, or expires on its `ttl` before it starts, ends\n without it \u2014 vxil refunds those at its deadline, not sooner. Keep the budget above, and keep the\n `deadline_at` check, so a late attempt refunds early instead of finishing after the refund.\n\n## Option B \u2014 your own container runtime (Modal, Fly, a container on your own cloud account)\n\nSame contract, no relay: the endpoint you deploy **is** `render_url`. It must answer within 20 seconds,\nso it only checks the token, hands the job to a background worker and answers `2xx`; the worker renders\nand posts back. On Modal:\n\n```python\n# render_app.py \u2014 `modal deploy render_app.py`; render_url = the endpoint's https URL\nimport http.client, json, os, time, urllib.error, urllib.request\nfrom datetime import datetime, timedelta, timezone\nimport modal\nfrom fastapi import HTTPException, Request # also `pip install fastapi` where you run `modal deploy`\n\nimage = (modal.Image.debian_slim()\n .apt_install(\"ffmpeg\", \"chromium\")\n .pip_install(\"fastapi[standard]\"))\napp = modal.App(\"render-farm\", image=image)\nsecrets = [modal.Secret.from_name(\"render-farm\")] # RENDER_TOKEN\n\ndef _post(callback_url: str, body: dict) -> None:\n req = urllib.request.Request(callback_url, data=json.dumps(body).encode(),\n headers={\"content-type\": \"application/json\"}, method=\"POST\")\n with urllib.request.urlopen(req, timeout=30) as res:\n res.read()\n\ndef report(callback_url: str, body: dict, deadline: datetime) -> None:\n \"\"\"completed / failed: MUST arrive. Retries 429, 5xx and network errors (honouring\n Retry-After) until the deadline; a repeated final post changes nothing on vxil's side.\"\"\"\n attempt = 0\n while True:\n wait = min(2 ** attempt, 30)\n attempt += 1\n try:\n _post(callback_url, body)\n return\n except urllib.error.HTTPError as e:\n if e.code != 429 and e.code < 500:\n raise # another 4xx: the URL or the body is wrong\n ra = e.headers.get(\"retry-after\")\n wait = int(ra) if ra and ra.isdigit() else wait\n except (OSError, http.client.HTTPException):\n pass # no answer, or the connection dropped mid-answer\n # (URLError, a reset, RemoteDisconnected, IncompleteRead,\n # a timeout): send it again\n if datetime.now(timezone.utc) + timedelta(seconds=wait) > deadline:\n raise RuntimeError(\"callback not delivered before the deadline\")\n time.sleep(wait)\n\n_last_ping = {}\ndef ping(callback_url: str, body: dict) -> None:\n \"\"\"processing: best-effort, at most one every 5 s; a refused or failed ping is dropped.\"\"\"\n now = time.monotonic()\n if now - _last_ping.get(callback_url, 0.0) < 5:\n return\n _last_ping[callback_url] = now\n try:\n _post(callback_url, {\"status\": \"processing\", **body})\n except Exception:\n pass\n\nATTEMPT_WALL_S = 25 * 60 # = the timeout below; a call cut off there may never reach its except\n\n@app.function(cpu=4, memory=8192, timeout=ATTEMPT_WALL_S, secrets=secrets)\ndef render(job: dict) -> None:\n cb = job[\"callback_url\"]\n deadline = datetime.fromisoformat(job[\"payload\"][\"deadline_at\"].replace(\"Z\", \"+00:00\"))\n if datetime.now(timezone.utc) + timedelta(seconds=ATTEMPT_WALL_S) > deadline:\n # queued too long to finish before vxil stops waiting: refund now\n report(cb, {\"status\": \"failed\", \"error\": \"deadline\", \"hint\": \"started too late to finish\"}, deadline)\n return\n try:\n ping(cb, {\"stage\": \"rendering\", \"progress\": 0})\n # \u2026render with ffmpeg / chromium (ping(cb, {...}) as often as you like),\n # upload to YOUR bucket keyed by job[\"render_id\"]\u2026\n output_url = f\"https://cdn.example.com/renders/{job['render_id']}.mp4\"\n except Exception as e: # the RENDER failed: tell vxil now, so the credits come back before the deadline\n report(cb, {\"status\": \"failed\", \"error\": \"render_failed\", \"hint\": str(e)[:200]}, deadline)\n raise\n # the output exists: only `completed` may follow. Outside the try on purpose, so a\n # trouble delivering it can never turn into a `failed` post that refunds a finished render.\n report(cb, {\"status\": \"completed\", \"output_url\": output_url, \"duration_s\": 31.2,\n \"progress\": 100, \"stage\": \"done\"}, deadline)\n\n@app.function(secrets=secrets)\n@modal.fastapi_endpoint(method=\"POST\")\nasync def start(request: Request):\n if request.headers.get(\"authorization\") != f\"Bearer {os.environ['RENDER_TOKEN']}\":\n raise HTTPException(status_code=401, detail=\"unauthorized\") # a 4xx ends the vxil run\n job = await request.json()\n await render.spawn.aio(job) # queued; returns at once, well inside the 20-second window\n return {\"accepted\": True}\n```\n\n`spawn` queues the call and returns immediately, so the endpoint answers in well under a second. A\nstart can arrive twice (a lost answer is retried), so dedupe on the run id\n(`request.headers[\"x-vxil-run-id\"]`; never a hash of the props): keep the run ids you have spawned in\na `modal.Dict`, or make the render overwrite the same output key.\n\nAnything else that can (1) answer an https POST in under 20 s, (2) run the work in the background and\n(3) POST JSON to a URL fits the same contract: a Fly Machine started per job, a container service with\na queue in front, your own GPU box. vxil does not care what runs the render \u2014 only that the start is\nacknowledged quickly and the callback eventually comes.\n\n## Run it\n\nGive a user some credits from your server (or sell `render_pack_100` through your payments provider):\n\n```bash\ncurl -s -X POST \"https://api.vxil.com/v1/payments/credits/grant\" \\\n -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n -H 'idempotency-key: welcome-u1' \\\n -d '{\"user_id\":\"<the user id>\",\"credit_type\":\"render_credits\",\"amount\":25,\"source\":\"welcome\"}'\n```\n\nStart a render **with the user's session** (end-user mode \u2014 the held credits are forced onto that\nuser):\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// after `vxil gen`, vx.fn['request-render'] is typed from the function's declared signature\nconst vx = new Vxil({ apiKey: process.env.VXIL_PUBLISHABLE_KEY!, endUserToken: process.env.USER_SESSION! });\nconst started = await vx.fn['request-render']({\n composition: 'promo-30s', request_key: 'promo-1', props: { headline: 'Spring sale' },\n});\n// \u2192 { item_id, run_id, credits: 5 }\n// (or { duplicate: true, request_key, item_id, run_id, status } on a retry)\n```\n\nThen read the row \u2014 or subscribe to its changes \u2014 until `status` is `completed`:\n\n```ts\nif ('item_id' in started) {\n const row = await vx.from('renders').get(started.item_id);\n // row.status \u2192 'completed', row.output_url \u2192 'https://cdn.example.com/renders/\u2026.mp4'\n}\n```\n\n**Try it before you have a runtime.** Point `render_url` at any https endpoint that answers `2xx`\n(a request-bin works) and play the runtime yourself: copy `callback_url` from the request it received,\nthen\n\n```bash\ncurl -s -X POST \"$CALLBACK_URL\" -H 'content-type: application/json' \\\n -d '{\"status\":\"completed\",\"output_url\":\"https://cdn.example.com/x.mp4\",\"duration_s\":12.5,\"progress\":100,\"stage\":\"done\"}'\n# \u2192 { \"data\": { \"run_id\": \"run_\u2026\", \"generation_status\": \"completed\" } } \u2014 and the row says so\n```\n\n## How it fails, and what the user sees\n\n| what happened | the run | the row | the credits |\n|---|---|---|---|\n| runtime posted `completed` | `completed` | `status: completed` + every key it sent | committed |\n| runtime posted `failed` | `failed` | `status: failed`, `error` = its code + hint (written by `notify-ready`) | refunded |\n| runtime never called back | `failed` (`GenerationExpired`) at the deadline | `failed`, `error: GenerationExpired: the render did not finish before its deadline` | refunded |\n| runtime finished after the deadline | `failed` (`GenerationExpired`) \u2014 exact to the second; the late `completed` is answered `failed` / `expired: true` | `failed` | refunded (your compute was spent \u2014 budget the clocks) |\n| the final post was answered `429` (or `5xx`, or got no answer) | still open: nothing is settled until a post lands | unchanged | still held \u2014 `postCallback` sends it again after `Retry-After`; give up only at `deadline_at`, when the run refunds anyway |\n| a progress ping was answered `429` | unchanged | the previous progress stays | unchanged \u2014 drop the ping; the next one carries the newer state |\n| your endpoint answered `5xx` / timed out | start retried with backoff; terminal after the attempts | `processing` \u2192 `failed`, `error: RetryableHttp: the render endpoint kept failing to accept the render (retries exhausted)` (or `NetworkError: \u2026` when it could not be reached) | held until then, then refunded |\n| your endpoint answered another `4xx` (a bad token) | `failed` at once | `failed` | refunded |\n| the user had too few credits (at `request-render`) | ended at once (`ReserveInsufficient`), never started | `failed`, `error: insufficient_credits`; that `request_key` is spent; the caller got the `402`, no message | nothing held |\n| too many renders in flight, payments briefly unreachable, or a jobs-side fault | not created yet (the caller gets `429` / `503` with `queued: true` and `retry_after`, or `502` with `queued: true`) | `pending`, no run \u2014 **queued**: `redrive-pending` starts it when a slot frees up (or the app calls again with the **same** `request_key`) | held when it starts |\n| the user had too few credits when the re-driver started it | ended at once (`ReserveInsufficient`) | `failed`, `error: insufficient_credits`; the owner is told once (they last heard \"queued\") | nothing held |\n| still no free slot an hour later | never created | `failed`, `error: not_started: the render waited too long for a free slot` (written by `redrive-pending`); the owner is told once | nothing held |\n| the re-driver's start was refused (`400` / `401` / `403` / `422`: a non-https or private `render_url`, a wrong or revoked `vxil_jobs_key`) | not created | still `pending` \u2014 the tick stops and reports it (`stopped: { status, code }` in the function's logs); fix the setup and the next tick carries on; the one-hour bound still applies | nothing held |\n\n## The backlog: `maxConcurrent`, `429` and the re-driver\n\n`generation.maxConcurrent` is how many generation runs this project may have **open** at once: 20 by\ndefault, settable up to 200 in the jobs config. This blueprint sets 20; raise it to what your plan\nand your runtime can carry:\n\n```ts\njobs: { enabled: true, generation: { maxConcurrent: 50 /* 1\u2013200, default 20 */ } },\n```\n\nAt the cap a new start is refused with `429` and `Retry-After: 5`. **No run is created and nothing\nis held yet.** The same holds when the credits cannot be held because payments is briefly\nunreachable: the start is refused with `503 payments_unavailable` (nothing started, nothing held;\nit names a 15-second wait), `request-render` answers `503` with `queued: true`, and the re-driver starts\nthe row on a later tick with the same key (a fresh run). Never a free render: that only happens if\nyou opt in with `reserve_credits.on_unavailable: 'proceed'`. `request-render` passes the `429` on to the app with `queued: true` (and the\n`item_id` and `request_key`), and leaves the row `pending` with no `run_id`. That render is\n**queued, not refused**: the re-driver will start it, and hold its credits then, up to an hour\nlater. So the app must treat a `429` with `queued: true` as \"queued\" \u2014 show it, watch the row \u2014 and\nmust **never retry it with a new `request_key`**: that is a second render, and both are charged. A\nretry with the **same** `request_key` is always safe. Rows like that are the backlog, and two\nthings drain it:\n\n1. **`redrive-pending`, every minute.** It reads the oldest `pending` rows with no run that are at\n least 30 s old (`{ status: 'pending', created_at: { $lt: \u2026 }, run_id: null }`, sorted by\n `created_at`, 20 per tick; `status` and `created_at` are index slots, so the read stays cheap at\n any size) and starts each one with the same descriptor and the **same idempotency key** as\n `request-render`, so a row the app is re-driving at the same moment still gets one run. It\n **stops at the first `429`** (or `503 payments_unavailable`), since the rest of the batch would\n get the same answer, and the next tick carries on. Each try is recorded on the row (`redrive_attempts`, `redriven_at`). A `402`\n fails the row with `insufficient_credits`. A `400`, `401`, `403` or `422` also **stops** the tick\n and fails nothing: every re-driven row sends the same descriptor, so a refusal means the setup is\n wrong (a non-https or private `render_url`, a revoked key), not the row; the tick reports it as\n `stopped: { status, code }`. The only thing that fails a waiting row is age: a row still waiting\n after **one hour** is failed with `not_started: the render waited too long for a free slot`.\n Nothing was ever held, so nothing is refunded. In both the `402` and the one-hour case the owner\n is told once (`Idempotency-Key: render-not-started:<item_id>`), because the last thing they heard\n was \"queued\". (The function's scopes include `notifications:send` for that.)\n2. **The app**, calling `request-render` again with the same `request_key`, if it wants the render\n started sooner than the next tick.\n\n`overlap: 'skip'` keeps a slow tick from being doubled by the next one. On the Free plan, run it\nevery 15 minutes (see the plan note at the top).\n\n**Why it has its own key.** A credit hold placed by a function must name the signed-in user the\nfunction acts for, and a cron tick has none. So `redrive-pending` makes the start with\n`vxil_jobs_key`, an API key holding only `jobs:write`, the way your trusted server would. That is\nmore than \"hold credits and start runs\": `jobs:write` also lets the key cancel or replay any run of\nthis project, enqueue any job, and create or change schedules and flow rules, and it can hold\ncredits against any of your users and start runs against any public https endpoint. Treat it as a\nserver key: keep it only in this function's secrets, never in an app, and rotate it like any\nserver key. The re-driver never reads the\nprice from the row (a signed-in user can edit their own row): the hold is the function's fixed\n`RENDER_CREDITS`, and a row whose `render_key` is not `owner:request_key` is failed, not started.\n(A hand-written row like that which also breaks the `render_key_shape` hook cannot be written at\nall, so the tick counts it as `unwritable` and it keeps one of the 20 slots: fix or delete it by\nhand.)\n\n## A contract test for your runtime\n\nEvery key your runtime posts in the `completed` body is written onto the row, so every key must be a\ndeclared field of `renders`. Keep that true in your own CI with a test beside your runtime's code. It\nfails the day someone adds a key to the completion body and forgets the field:\n\n```ts\n// render-contract.test.ts \u2014 vitest, in YOUR repo (the one holding vxil.config.ts)\nimport { describe, expect, it } from 'vitest';\nimport config from './vxil.config';\n// the bodies your runtime (or coordinator) really posts: import the builders from that code,\n// so the test follows it instead of a hand-copied list\nimport { completedBody, progressBody } from './runtime/callback-bodies';\n\ndescribe('render callbacks', () => {\n const declared = new Set(Object.keys(config.cms!.collections!.renders!.fields!));\n\n it('every key of the completion body is a declared renders field', () => {\n const body = completedBody({ objectId: 'obj_test', durationS: 1 });\n expect(Object.keys(body).filter((k) => !declared.has(k))).toEqual([]);\n });\n\n it('a progress ping carries only the keys the mirror keeps', () => {\n const ping = progressBody({ progress: 40, stage: 'encoding' });\n expect(Object.keys(ping).filter((k) => !['status', 'progress', 'stage', 'message'].includes(k))).toEqual([]);\n });\n});\n```\n\nThe platform also has a fallback for the day that test is missing. When the row refuses a completion\nmirror because of an undeclared key, vxil writes the status alone, so the row still says\n`completed`, and the run reports what it dropped (`GET /v1/jobs/runs/{run_id}` \u2192 `mirror_error`,\nwith `fields_dropped`). The app no longer hangs on `processing`, but the dropped keys are not on the\nrow. The test is what keeps them there.\n\n## The bounds to design against\n\n- **Deadline**: the run's timeout is clamped to `generation.maxTimeoutMs` \u2014 one hour at most. A render\n that can take longer should be split (the next step starts from `notify-ready`), or tracked on your own\n row without a platform-held reserve.\n- **In flight**: `generation.maxConcurrent` renders at once (20 here and by default; up to 200). Over\n it, `request-render` answers `429` and the row waits for `redrive-pending`, or for a retry with the\n same `request_key` ([the backlog](#the-backlog-maxconcurrent-429-and-the-re-driver)).\n- **Holds**: one render's reserve is clamped to `generation.maxReserveCredits` (50 here), and the sum of\n all open holds is capped by `generation.maxOutstandingReserveCredits` (5,000 here).\n- **Callback**: at most 256 KiB per post, JSON, every key a declared field of `renders`. About 120\n posts a minute per render plus a small allowance kept for the final post (both best-effort, per\n serving machine); progress at most every 5 s, and the final post retried until it lands\n ([callback limits](#callback-limits-progress-is-best-effort-the-final-post-must-arrive)).\n\n## Your token, and where it lives\n\n`render_url` and `render_token` are function secrets; `request-render` reads them at invoke time and\nputs them on the generation run, which vxil stores with the run (your project only) for as long as the\njobs retention keeps it. The token is **never returned by a read**: `GET /v1/jobs/runs/{run_id}` (and\nthe dashboard and MCP reads built on it) shows the provider header names with every value as\n`[redacted]`. Rotate it in both places (`vxil secrets set functions/render_token` and your endpoint's\nenv); runs already started keep the token they were started with.\n\n## Evidence\n\n- **Read in vxil's code**: the start request body (`provider.body` + `payload` + `callback_url`), the\n 20-second start bound, the retry ladder, the status mirror writing every completion key onto the row,\n the hold committed on `completed` and released on `failed` / the deadline, and `error` / `hint`\n becoming `error_class` / `error_hint` on `job.generation.failed`.\n- **Read in Trigger.dev's and Modal's documentation, not executed from vxil**: the trigger endpoint\n `POST https://api.trigger.dev/api/v1/tasks/{taskId}/trigger` with `{ payload, options }` and the\n `idempotencyKey` / `ttl` / `tags` / `machine` options; `task({ id, machine, maxDuration, retry, run,\n onFailure })` and `retry` options, `maxDuration` being CPU time per attempt with no `onFailure` when\n it is exceeded, `metadata.set`, and the `ffmpeg()` / `puppeteer()` build extensions; Trigger.dev\n Cloud's US-hosted operational data; Modal's `@modal.fastapi_endpoint` and `.spawn()`. Check each\n vendor's current docs before you ship.\n",
19116
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Render Farm\" \u2014 long renders and transcodes (minutes, ffmpeg, a headless\n// browser) run on a runtime YOU rent: Trigger.dev, Modal, a container on your\n// own cloud account. vxil keeps the four things that must not be lost while\n// that runtime works: the credits held for the render, the deadline, the\n// signed completion callback, and the status row the app watches.\n//\n// request-render (your function, end-user mode)\n// \u2192 creates-or-finds the user's `renders` row (unique render_key)\n// \u2192 POST /v1/jobs/generation in WEBHOOK mode: your render endpoint,\n// credits HELD, a deadline, the status mirrored onto the row\n// vxil's generation lane\n// \u2192 POSTs your render endpoint { render_id, user_id, composition, props,\n// payload, callback_url } with your bearer token\n// your runtime (README: Trigger.dev, or your own containers)\n// \u2192 acks within 20 s, renders, POSTs { status: 'processing', \u2026 } and then\n// { status: 'completed', output_url, duration_s, \u2026 } to callback_url\n// vxil\n// \u2192 completed: credits COMMIT, every callback key lands on the row\n// \u2192 failed / no answer by the deadline: credits REFUNDED, row says failed\n// \u2192 job.generation.completed | failed \u2192 notify-ready tells the owner\n// redrive-pending (cron, every minute)\n// \u2192 starts renders that waited at the concurrency cap (429 \u2014 the app was\n// told `queued: true`), same key; tells the owner if one never starts\n//\n// No container tier and no workflow engine inside vxil: the runtime is yours,\n// the bookkeeping is vxil's. \"Credits\" are usage units on the deterministic\n// `mock` payments integration \u2014 not money; vxil is never in the flow of funds.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n generation: {\n // how many renders may be in flight at once for this project\n maxConcurrent: 20,\n // a render that never calls back fails (and refunds) after 30 minutes\u2026\n defaultTimeoutMs: 1_800_000,\n // \u2026and no render may ask for more than the platform ceiling, one hour\n maxTimeoutMs: 3_600_000,\n // one render never holds more than 50 credits\n maxReserveCredits: 50,\n // all of this project's in-flight renders together hold at most 5,000\n maxOutstandingReserveCredits: 5_000,\n },\n },\n\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n productMap: {\n render_pack_100: { creditType: 'render_credits', amount: 100, period: 'once' },\n },\n // no subscription tiers in this blueprint \u2014 credits come from packs\n tierMap: {},\n // a render that fails, times out or is cancelled gives its credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {\n // a render row is live the moment it is written\n draftPublish: false,\n // in end-user mode a signed-in user sees only the renders they own\n strictEndUserScope: true,\n // Lane-A hook (guide ch. 7): render_key IS owner + ':' + request_key,\n // server-enforced, so one user's request_key can never collide with \u2014\n // or block \u2014 another user's.\n hooks: {\n render_key_shape: {\n collection: 'renders',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.render_key == concat(item.owner, ':', item.request_key)\",\n message: \"render_key must be owner + ':' + request_key\",\n },\n },\n },\n\n notifications: { provider: 'mock', fromEmail: 'renders@render-farm.example' },\n functions: { enabled: true },\n\n // the README's keyless-container coordinator uploads each finished output\n // into this project's files (`output_file` below). The per-object ceiling\n // the upload-url call pre-checks is `maxObjectBytes` \u2014 100 MB by default,\n // which fits the coordinator's buffered upload (\"tens of MB\"); raise it\n // (up to 5 GB) for long or high-bitrate renders. Not using the coordinator?\n // Remove this and the `output_file` field.\n files: { enabled: true },\n },\n\n cms: {\n collections: {\n renders: {\n singular: 'render',\n ownerField: 'owner',\n fields: {\n // THE DEDUPE ANCHOR: a double tap or a retried request is a 409 that\n // request-render reads back \u2014 and the same key is the generation\n // run's idempotency_key, so the render endpoint is asked once.\n render_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n request_key: { type: 'string', required: true },\n owner: { type: 'string', indexSlot: 's2' },\n // which composition / preset your runtime renders (your vocabulary)\n composition: { type: 'string', required: true, indexSlot: 's3' },\n // written by vxil's status mirror: pending \u2192 processing \u2192 completed | failed\n status: { type: 'string', indexSlot: 's4' },\n credits: { type: 'int', indexSlot: 'n1' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n props: { type: 'json' },\n run_id: { type: 'text' },\n // the run's deadline_at, computed ONCE when the row is created and\n // re-sent unchanged by every start of it (request-render's re-drive,\n // redrive-pending): jobs hands an existing run back only to the SAME\n // request, so a value rebuilt per send will answer 422\n // idempotency_key_reused once that check is enforced (logged today).\n // null = no run holds the key (after a 429 / 503): the next start\n // stores a fresh one.\n deadline_at: { type: 'datetime' },\n // \u2500\u2500 keys your runtime sends back. EVERY key of the completion body is\n // written onto this row, so each one must be a declared field (a\n // write naming an unknown field is refused: vxil then writes the\n // status word alone and the run reports `mirror_error` with the\n // keys it dropped \u2014 declare the field so its value lands too).\n output_url: { type: 'text' },\n // the files-feature object id, when a coordinator uploads the output\n // into this project's files (README \"Keys stay out of the container\")\n output_file: { type: 'file' },\n duration_s: { type: 'float' },\n // progress keys: a `processing` ping carries them onto the row\n // (request-render's status_mirror.progress_fields \u2014 progress 0..100,\n // a float: the run keeps 2 decimals,\n // stage \u2264 64 chars, message \u2264 200), and the completion body writes\n // them too (progress: 100, stage: 'done').\n progress: { type: 'float', validation: { min: 0, max: 100 } },\n stage: { type: 'string' },\n message: { type: 'text' },\n // written by notify-ready from job.generation.failed (and by\n // redrive-pending when a render never got a run)\n error: { type: 'text' },\n // written by redrive-pending: how often it tried to start this render,\n // and when it last did. The re-driver's read needs no new index \u2014\n // `status` (s4) and `created_at` (t1) are slots; `run_id: null` is a\n // residual test over the rows they pick.\n redrive_attempts: { type: 'int' },\n redriven_at: { type: 'datetime' },\n },\n },\n },\n },\n\n functions: {\n // Starts ONE render for the signed-in user. Invoke it in END-USER mode\n // (with the user's session): the held credits are forced onto that user,\n // and the row is theirs.\n 'request-render': {\n entry: './functions/request-render.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'jobs:write'],\n // render_url: your render endpoint (https). render_token: the bearer\n // token that endpoint checks. Both ride the generation run; vxil's\n // generation lane \u2014 not this function \u2014 calls the endpoint.\n secrets: ['secret:render_url', 'secret:render_token'],\n egressAllow: [],\n signature: {\n input: { composition: 'string', request_key: 'string', props: 'json?' },\n output:\n '{ item_id: string; run_id: string; credits: number }'\n + ' | { duplicate: true; request_key: string; item_id: string; run_id: string | null; status: string }',\n },\n },\n\n // THE BACKLOG RE-DRIVER. At the generation cap request-render answers 429\n // and the row waits `pending` with no run; every minute this starts the\n // oldest such rows (\u2265 30 s old, 20 per tick) with the SAME idempotency key,\n // stops at the first 429 (or at a refusal that means the setup is wrong),\n // and fails \u2014 and tells the owner of \u2014 a row that waited over an hour.\n // overlap 'skip': a slow tick is never doubled by the next one.\n // Free plan: a function cron may fire at most every 15 minutes \u2014 use\n // '*/15 * * * *' there (README \"The backlog\").\n 'redrive-pending': {\n entry: './functions/redrive-pending.ts',\n trigger: { kind: 'cron', schedule: '* * * * *', overlap: 'skip' },\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n // vxil_jobs_key: an API key of this backend holding ONLY jobs:write \u2014 a\n // cron tick has no signed-in user to hold credits for, so the start is\n // made as your trusted server (README \"The backlog\")\n secrets: ['secret:vxil_jobs_key', 'secret:render_url', 'secret:render_token'],\n egressAllow: [],\n },\n\n // job.generation.completed | failed \u2192 write the failure cause onto the row\n // and tell the owner. A non-2xx is retried; the notification's\n // Idempotency-Key (one per run) makes a redelivery send nothing twice.\n 'notify-ready': {\n entry: './functions/notify-ready.ts',\n trigger: { kind: 'webhook', source: 'job.generation.', retry: { maxAttempts: 3 } },\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n egressAllow: [],\n },\n },\n\n secrets: {\n render_url: {\n feature: 'functions',\n description: 'your render endpoint \u2014 the https URL vxil POSTs each render to (a Trigger.dev relay, or your own container endpoint)',\n },\n render_token: {\n feature: 'functions',\n description: 'a long random token your render endpoint checks on the Authorization header (Bearer \u2026)',\n },\n vxil_jobs_key: {\n feature: 'functions',\n description: 'an API key of this backend holding ONLY jobs:write \u2014 redrive-pending starts backlogged renders with it (vxil keys mint --name render-redrive --scopes jobs:write). jobs:write also lets it cancel or replay any run, enqueue jobs and manage schedules and flow rules: a server key, kept only here',\n },\n },\n});\n",
19117
+ "readme": "# Render Farm \u2014 long renders on a runtime you rent, with vxil holding the credits, the deadline and the callback\n\n```bash\nmkdir my-renders && cd my-renders\nvxil init --template render-farm # init scaffolds into the CURRENT directory\nprintf '%s' \"$RENDER_URL\" | vxil secrets set functions/render_url # your render endpoint (https)\nprintf '%s' \"$RENDER_TOKEN\" | vxil secrets set functions/render_token # a long random token it checks\n# the backlog re-driver's key: an API key of this backend holding ONLY jobs:write\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name render-redrive --scopes jobs:write --json | jq -r .api_key | vxil secrets set functions/vxil_jobs_key\nvxil push\n```\n\n> **Plan note.** The functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n> On the Free plan a function cron may also fire at most every 15 minutes, so change `redrive-pending`'s\n> schedule to `'*/15 * * * *'` there. The blueprint is written for Developer and up, where it runs every\n> minute. Everything else works the same; a backlog just drains more slowly.\n\nA video render, a transcode, a headless-browser capture: minutes of CPU, ffmpeg or Chromium. That does\nnot fit in a vxil function (a delivered trigger gets about a minute), and vxil will not grow a container\ntier or a workflow engine to run it. So the work runs on **a runtime you rent** \u2014 Trigger.dev, Modal,\na container on your own cloud account \u2014 and vxil keeps the four things that must survive while it\nruns:\n\n| vxil holds | so that |\n|---|---|\n| **the credits** reserved for the render | a failed, abandoned or cancelled render gives them back, and a user can never start more than they can pay for |\n| **the deadline** | a render your runtime never reports on fails and refunds after 30 minutes (at most one hour) |\n| **the signed completion callback** | your runtime needs no vxil key: the URL it is handed is the credential for that one render |\n| **the status row** | the app reads (or subscribes to) one `renders` row: `pending \u2192 processing \u2192 completed | failed`, plus everything your runtime sent back |\n\n**What this blueprint teaches that the others do not:** the hand-off to **your own** long-running\nruntime through a **webhook-mode generation run** \u2014 the contract your endpoint and your worker must\nkeep, and two complete runtime options below. (`fal-media` shows the same lane against a vendor queue\nAPI; `job-runner` shows a provider call vxil polls.)\n\n## What you get\n\n- **`renders`** \u2014 one row per render, owned by the user who asked for it (`strictEndUserScope`: a\n signed-in user reads only their own). `render_key` is the owner + `:` + the client's `request_key`\n (the composition is enforced by a `beforeWrite` hook) and is **unique**, so a double tap or a retried\n request finds the first row instead of starting a second render. Your runtime's answer lands on the\n row: `output_url`, `duration_s`, `progress`, `stage`, `message`.\n- **`request-render`** (http function, end-user mode) \u2014 creates-or-finds the row, then starts ONE\n generation run: your endpoint (`render_url`), your token on its `Authorization` header, a status\n mirror onto the row, `reserve_credits` for the render (5 `render_credits`), a 30-minute deadline, and\n the `render_key` as the run's `idempotency_key` \u2014 so a re-driven start gets the same run back. That\n works only because every start of a row sends the **same body**. Jobs compares a fingerprint of\n the request \u2014 canonical JSON, with defaults applied and keys sorted, and the *values* of\n `provider.headers` / `completion.poll.headers` left out (so a rotated token is not a different\n request) \u2014 not its raw bytes. A different body under a used key will answer `422\n idempotency_key_reused`; that check is being rolled out, and today a mismatch is only logged, on\n every project, and still handed the first run (a changelog entry will announce enforcement). So nothing in the descriptor is rebuilt per send \u2014 the run's `deadline_at`\n is computed **once**, stored on the row (`deadline_at`) when the row is created, and re-sent as\n stored by every start (cleared only when jobs says no run holds the key \u2014 a `429` or `503\n payments_unavailable` \u2014 so the next start stores a fresh one). Never put `Date.now()`, a random id or\n any other per-send value into a generation body you may re-send. The credits it holds are the\n function's fixed price, never a value read from the row.\n- **`redrive-pending`** (cron function, every minute, `overlap: 'skip'`) \u2014 drains the backlog. When\n the project already has `generation.maxConcurrent` renders in flight, `request-render` answers `429`\n (with `queued: true`) and the row waits `pending` with no run. This function starts those rows,\n oldest first, with the **same** idempotency key, and tells the owner when one never starts\n ([the backlog](#the-backlog-maxconcurrent-429-and-the-re-driver)).\n- **`notify-ready`** (webhook function on `job.generation.`) \u2014 writes the failure cause onto the row (a\n platform class such as `GenerationExpired` gets a short human hint after it) and sends the owner one\n message per run (`Idempotency-Key: render-ready:<run_id>`). A render refused for too few credits\n keeps `insufficient_credits`; when `request-render` refused it, the caller already got the `402`\n and nothing is sent, and when the re-driver started it (the user last heard \"queued\"), the owner\n is told once (`Idempotency-Key: render-not-started:<item_id>`, the re-driver's own key). It\n **branches on `error_class`**, because not every `job.generation.failed` is a render that failed:\n `PaymentsUnavailable` (the credits could not be held at the start \u2014 the row is still queued and a\n new run follows) is skipped (and when a re-sent `request-render` raced that start and linked its\n run, the row is unlinked \u2014 `run_id: null`, only while it is still `pending` on that run \u2014 so\n `redrive-pending` starts it), and `Cancelled` (your own cancel) writes `error: cancelled` and sends\n nothing. Both are also lower-level events (`warn` / `info`), so they stay out of an immediate\n failure digest.\n- **credits** \u2014 a payments integration on the `mock` provider (no provider account needed to try it).\n \"Credits\" are usage units you meter, not money.\n\nThe row is a **view** for the app; the run and the ledger are the truth. If your app's client key\ncarries `cms:write`, a signed-in user can edit their own `renders` row (say, set `status` to\n`completed`) \u2014 that changes nothing they are charged or given. Anything that grants something on\ncompletion should read the run (`GET /v1/jobs/runs/{run_id}`) or react to `job.generation.completed`,\nas `notify-ready` does \u2014 or give the client key only `cms:read`.\n\n### Upgrading a render-farm project scaffolded before 2026-10-04\n\nProjects created from an earlier copy of this blueprint compute `deadline_at` on every send. To\nkeep every start of a row the same request:\n\n1. add the `deadline_at: { type: 'datetime' }` field to `renders` in your `vxil.config.ts`;\n2. take the current `functions/request-render.ts` and `functions/redrive-pending.ts` (they store\n the deadline on the row and re-send the stored value), then `vxil push`.\n\nRows created before the upgrade have no stored deadline: their next start (a re-drive) stores a\nfresh one, so that one send differs from the run's first body. Today that is only logged and the\nexisting run is handed back; once the check is enforced, such a send answers `422\nidempotency_key_reused` naming the run, which both functions handle by linking that run to the row.\n\n## The contract your runtime keeps\n\nWhatever runs the render, these are the only things it has to do:\n\n| Step | What arrives / what to send |\n|---|---|\n| **Start** | vxil `POST`s your `render_url` with `Authorization: Bearer <render_token>`, the header `x-vxil-run-id` and JSON `{ render_id, user_id, composition, props, payload: { generation_id, correlation_id, deadline_at }, callback_url }` (`user_id` is the render's owner; `deadline_at` is the row's stored deadline, the same on every start of that row). Check the token, **store or queue the work and answer `2xx` within 20 seconds** \u2014 never render inline, and never wait on a container's cold start. When you cannot take the work right now, say so at once with a `503` instead of holding the request open; the start is then sent again later, as after a timeout. `408` / `429` / `5xx` / a timeout is retried with backoff (30 s, then 1, 2 and 4 minutes, doubling up to 10 minutes; a `Retry-After` on your answer sets the wait, 1 s to 10 min, never past the deadline; `max_attempts` counts start calls) until the run's attempts are used \u2014 with the default five, the last try comes 7 to 8 minutes after the first, so leave room for that in the deadline. Any other `4xx` ends the run and refunds the credits. A start can arrive more than once (a lost answer is retried), so **dedupe by the run id** (`x-vxil-run-id`; in this blueprint `render_id` = `payload.generation_id` names the same single run), never by a hash of the content ([why](#one-render-one-run-dedupe-a-doubled-start)). |\n| **Progress** (optional, best-effort) | `POST callback_url` with `{\"status\": \"processing\", \"progress\": 40, \"stage\": \"encoding\"}`. A processing ping moves the row to `processing` and writes its `progress` (0\u2013100) / `stage` (\u2264 64 chars) / `message` (\u2264 200 chars) onto the row \u2014 `request-render` asks for that with `status_mirror.progress_fields` \u2014 so the app shows live progress by watching the row. Other keys on a ping are not written to the row (the run keeps the latest report, `GET /v1/jobs/runs/{run_id}` \u2192 `progress`). **At most one ping every 5 seconds**, sending the latest state; a ping that fails or answers `429` is simply dropped ([callback limits](#callback-limits-progress-is-best-effort-the-final-post-must-arrive)). |\n| **Done** (must arrive) | `POST callback_url` with `{\"status\": \"completed\", \"output_url\": \"https://\u2026\", \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\"}` \u2014 or, when the output is uploaded into this project's files, `{\"status\": \"completed\", \"output_file\": \"obj_\u2026\", \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\"}` ([below](#keys-stay-out-of-the-container-the-recommended-shape)). At most 256 KiB, and **every key a declared field of `renders`** (add a field before you send a new key; [test it](#a-contract-test-for-your-runtime)). The credits are committed and every key is written onto the row. **Retry this post on `429`, `5xx` and network errors, honouring `Retry-After`, until `deadline_at`** \u2014 never drop it (`postCallback` below). |\n| **Failed** (must arrive) | `POST callback_url` with `{\"status\": \"failed\", \"error\": \"render_failed\", \"hint\": \"ffmpeg exited 1: \u2026\"}`. The credits are refunded; `error` (a short code) and `hint` reach `notify-ready` as `error_class` / `error_hint` and land on the row's `error`. Retried exactly like `completed`. |\n| **Never answers** | the run fails at the deadline (`GenerationExpired`), the credits are refunded, and a callback after that changes nothing. |\n| **The deadline** | `payload.deadline_at` (an ISO time) is the row's stored deadline: treat it as the latest moment to finish. vxil's own deadline is the run's `timeout` (30 minutes) counted from when **that run** was queued; any callback that arrives at or after vxil's deadline ends the run as `GenerationExpired` (credits refunded), and a `completed` posted then is answered `{ generation_status: \"failed\", expired: true }` \u2014 the output exists, but the user was refunded and the row says failed. The stored value is computed when the row is created and re-sent unchanged, so on a row whose run started later (a re-drive after a lost answer or a `409`) `deadline_at` can be **earlier** than vxil's real stop time \u2014 even already past when the start arrives (a row may wait up to an hour for a slot). It is never later, so honouring it is always safe. Check it before each attempt starts: a render that cannot finish by then should post `failed` and stop. |\n\n`callback_url` needs no other credential \u2014 and nothing else should see it. A repeated `completed` or\n`failed` post is answered with the settled state and changes nothing, so your worker can safely retry\nits own callback on a network error. The output bytes stay where your runtime wrote them (your bucket,\nyour CDN) and vxil stores the keys, not the file, unless a coordinator uploads the output into this\nproject's files ([next section](#keys-stay-out-of-the-container-the-recommended-shape)).\n\nEach start request also carries an `X-Vxil-Jobs-Signature` header (verifiable with your project's\njobs signing secret, `GET /v1/jobs/signing-secret`) and `x-vxil-run-id`. This blueprint uses the\nbearer token because it is one string comparison in any language.\n\n### Callback limits: progress is best-effort, the final post must arrive\n\nEach render's `callback_url` has its **own** budget at vxil's edge: about 120 posts a minute for that\none run. Every post over it, pings included, is counted against a small separate allowance (about 20\na minute), and on that allowance only a `completed` or `failed` post is accepted. A runtime that pings\nevery 5 seconds never gets near either number. One that sends more than about 140 posts in a minute\nuses the allowance up as well, and its final post waits for the next minute. All of a project's\ncallbacks together also share a ceiling of about 3,000 a minute, plus about 300 a minute for final posts.\nEvery number here is best-effort (counted per serving machine). The signature in the URL identifies\nthe run, so fifty renders posting from one shared egress IP do not share a budget. Over a budget the\nanswer is `429 rate_limited` with `Retry-After: 30`.\n\nTwo rules follow, and the helpers below keep both:\n\n- **Progress is best-effort.** Post at most one `processing` ping every 5 seconds, carrying the latest\n state; a ping that fails or answers `429` is dropped, and the next one carries the newer state.\n- **The final post must arrive.** A `completed` or `failed` post that answers `429`, `5xx` or never\n gets an answer is sent again, after `Retry-After` when there is one, until `deadline_at`. A repeat\n of a final post is harmless: it is answered with the settled state and changes nothing. Any other\n `4xx` means the URL or the body is wrong, so retrying will not help.\n\n```ts\n// callbacks.ts \u2014 for the coordinator, a task, or any worker that posts to callback_url\n/** completed / failed: MUST arrive. Retries 429, 5xx and network errors (honouring Retry-After)\n * until `untilMs`; throws only when it cannot deliver in time, or on another 4xx. */\nexport async function postCallback(callbackUrl: string, body: Record<string, unknown>, untilMs: number): Promise<void> {\n for (let attempt = 0; ; attempt++) {\n let res: Response | undefined;\n try {\n res = await fetch(callbackUrl, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n });\n } catch { /* a network error: send it again */ }\n if (res && res.ok) return;\n if (res && res.status !== 429 && res.status < 500) throw new Error(`callback refused: ${res.status}`);\n const retryAfterS = Number(res?.headers.get('retry-after'));\n const waitMs = retryAfterS > 0 ? retryAfterS * 1000 : Math.min(1000 * 2 ** attempt, 30_000);\n if (Date.now() + waitMs > untilMs) throw new Error(`callback not delivered in time (last answer: ${res?.status ?? 'none'})`);\n await new Promise((r) => setTimeout(r, waitMs));\n }\n}\n\n/** processing: best-effort, at most one post every `gapMs`; a refused or failed ping is dropped. */\nexport function progressReporter(callbackUrl: string, gapMs = 5_000) {\n let last = 0;\n return async (p: { progress?: number; stage?: string; message?: string }): Promise<void> => {\n if (Date.now() - last < gapMs) return; // coalesced: the next ping carries newer state\n last = Date.now();\n await fetch(callbackUrl, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ status: 'processing', ...p }),\n }).catch(() => undefined);\n };\n}\n```\n\n### One render, one run: dedupe a doubled start\n\nvxil sends the start again when it did not get your answer: a timeout, a dropped connection, a `5xx`.\nSo the same start can reach you twice, even while the first copy is still being handled. Claim the\nwork under the **run id** (`x-vxil-run-id`) before you launch anything. Answer `202` to a repeat\nonly once the work is launched. While the first copy is still launching, answer `503`, so the start\nstays open and vxil sends it again; a `202` would end vxil's retries even if that first launch then failed. In this blueprint `render_id` (= `payload.generation_id`, the row's id)\nnames the same single run, because `request-render` starts one run per row.\n\nNever key the claim by a hash of the content (the composition and props). Two users who ask for the\nsame render produce two runs with one hash: the second start would find the first's claim and be\nrefused, or overwrite the first's `callback_url`, and that run would wait out its deadline and refund.\nIf you want identical renders to share their output, claim by run id and look the output up by hash\nas a separate step.\n\n## Keys stay out of the container (the recommended shape)\n\nThe render container is the part of your system that runs the most third-party code (ffmpeg,\nChromium, fonts and media from the user's props), so give it **no vxil key at all**. This is the\nshape the blueprint recommends, whatever runs the container:\n\n```\nvxil \u2500\u2500start\u2500\u2500\u25B6 coordinator \u2500\u2500launch\u2500\u2500\u25B6 container\n (render_url) \u2502 progress / failed \u2500\u2500\u2500\u2500\u2500\u2500\u25B6 callback_url (keyless)\n \u25B2 output bytes \u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u2514\u2500 mints the upload URL with ITS files:write key, PUTs the bytes,\n completes the object, POSTs \"completed\" + output_file \u2500\u2500\u25B6 callback_url\n```\n\n- **The files feature is on.** The blueprint's config enables it (`files: { enabled: true }`) and\n declares `output_file` as a `file` field; without it every upload-url call is refused and the\n render waits out its deadline. Its per-object ceiling, `maxObjectBytes`, is 100 MB by default,\n enough for the buffered coordinator below; raise it for long or high-bitrate renders.\n- **The coordinator** is your `render_url`: a small endpoint in your own account \u2014 an edge worker\n with a per-render lock, or a route on any thin server. It is the only piece that holds a vxil key,\n and that key holds only **`files:write`** (`vxil keys mint --name render-uploads --scopes files:write`).\n- **The container** gets the job, the keyless `callback_url` (for `processing` pings and for\n `failed`), an `output_url` on the coordinator and an **output ticket** for it: a token signed for\n that one render and useless after its deadline, sent on the `Authorization` header (never in the\n URL, where access logs would keep it).\n- **The upload happens once the size is known.** The container POSTs the finished file to its\n ticket. The coordinator reads it, mints the files upload URL for exactly that size\n (the quota pre-check uses it), PUTs the bytes, completes the object, and only then posts\n `completed` with the object id as `output_file`. A crash anywhere before that leaves the render\n open, and it refunds at the deadline like any other.\n\n```ts\n// coordinator.ts \u2014 your render_url. A standard fetch handler (an edge worker, or a Node 18+ adapter).\n// Env: RENDER_TOKEN (= the vxil secret render_token), TICKET_SECRET (a long random string),\n// VXIL_FILES_KEY (an API key holding ONLY files:write), VXIL_BASE (https://api.vxil.com).\nimport { postCallback } from './callbacks';\n\ntype Start = {\n render_id: string; user_id: string; composition: string; props?: Record<string, unknown>;\n payload: { generation_id: string; deadline_at: string }; callback_url: string;\n};\ntype Ticket = { run_id: string; render_id: string; user_id: string; callback_url: string; exp: number };\ntype Env = { RENDER_TOKEN: string; TICKET_SECRET: string; VXIL_FILES_KEY: string; VXIL_BASE: string };\n\nconst enc = new TextEncoder();\nconst b64u = (b: ArrayBuffer | Uint8Array) =>\n btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\nconst unb64u = (s: string) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));\nconst hmacKey = (secret: string) =>\n crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);\nasync function sealTicket(env: Env, t: Ticket): Promise<string> {\n const body = b64u(enc.encode(JSON.stringify(t)));\n return `${body}.${b64u(await crypto.subtle.sign('HMAC', await hmacKey(env.TICKET_SECRET), enc.encode(body)))}`;\n}\nasync function openTicket(env: Env, raw: string): Promise<Ticket | null> {\n const [body, sig] = raw.split('.');\n if (!body || !sig) return null;\n try {\n // crypto.subtle.verify compares in constant time (a `!==` on the signature would not)\n if (!(await crypto.subtle.verify('HMAC', await hmacKey(env.TICKET_SECRET), unb64u(sig), enc.encode(body)))) return null;\n const t = JSON.parse(new TextDecoder().decode(unb64u(body))) as Ticket;\n return t.exp > Date.now() ? t : null;\n } catch {\n return null; // not base64url / not JSON\n }\n}\n/** The output's file extension, from the Content-Type the container sends. */\nconst EXT: Record<string, string> = {\n 'video/mp4': 'mp4', 'video/webm': 'webm', 'image/gif': 'gif', 'image/png': 'png', 'image/jpeg': 'jpg', 'application/pdf': 'pdf',\n};\n/** Sent with a 503: when to send the request again. */\nconst AGAIN_IN_30S = { 'retry-after': '30' };\nconst vxil = (env: Env, path: string, body?: unknown) => fetch(`${env.VXIL_BASE}${path}`, {\n method: 'POST',\n headers: { authorization: `Bearer ${env.VXIL_FILES_KEY}`, 'content-type': 'application/json' },\n ...(body ? { body: JSON.stringify(body) } : {}),\n});\n\nexport default {\n async fetch(req: Request, env: Env): Promise<Response> {\n const url = new URL(req.url);\n\n // 1. the start: check the token, launch the container, answer inside 20 s\n if (req.method === 'POST' && url.pathname === '/start') {\n if (req.headers.get('authorization') !== `Bearer ${env.RENDER_TOKEN}`) return new Response('unauthorized', { status: 401 });\n const s = (await req.json()) as Start;\n // a run started before request-render sent user_id (an older copy of this\n // blueprint): a server-mode upload must name its user, so refuse the start \u2014\n // a 4xx ends that run and refunds it, instead of a 422 at upload time\n if (!s.user_id) return new Response('start body has no user_id: redeploy request-render', { status: 400 });\n // one run, one render: claim the work under the RUN id before launching anything. The claim\n // says `launching` (and expires after a minute) until the launch succeeds, then `launched`.\n // A start vxil re-sends (a lost answer) is acknowledged with 202 only once the work is\n // `launched`; while another copy is still launching it gets 503, so vxil keeps retrying\n // instead of treating a launch that may yet fail as accepted.\n // Never claim by a content hash: two users' identical renders are two runs.\n const runId = req.headers.get('x-vxil-run-id') ?? s.payload.generation_id;\n const claimKey = `start:${runId}`;\n if (!(await store.claim(claimKey, 'launching', 60_000))) {\n if ((await store.get(claimKey)) === 'launched') return Response.json({ accepted: true, duplicate: true }, { status: 202 });\n return new Response('this render is still being launched', { status: 503, headers: AGAIN_IN_30S });\n }\n const ticket = await sealTicket(env, {\n run_id: runId, render_id: s.render_id, user_id: s.user_id, callback_url: s.callback_url,\n exp: Date.parse(s.payload.deadline_at), // useless once vxil stops waiting\n });\n try {\n await launchContainer({ // YOUR container platform's API: it must QUEUE\n run_id: runId, render_id: s.render_id, // the job and return at once \u2014 never wait.\n // Pass run_id as its idempotency key if it has one\n composition: s.composition, props: s.props ?? {},// here for a cold start\n deadline_at: s.payload.deadline_at,\n callback_url: s.callback_url, // for processing pings and `failed`\n output_url: `${url.origin}/output`, // POST the file here, with\n output_ticket: ticket, // Authorization: Bearer <output_ticket>\n });\n } catch {\n await store.release(claimKey); // not launched: let vxil's retry try again\n return new Response('cannot take the render right now', { status: 503, headers: AGAIN_IN_30S });\n }\n await store.put(claimKey, 'launched'); // no expiry: every later copy is a duplicate\n return Response.json({ accepted: true }, { status: 202 });\n }\n\n // 2. the output: the container POSTs the finished file here (again, on any non-2xx answer)\n if (req.method === 'POST' && url.pathname === '/output') {\n const t = await openTicket(env, (req.headers.get('authorization') ?? '').replace(/^Bearer /, ''));\n if (!t) return new Response('bad or expired ticket', { status: 403 });\n const contentType = (req.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase();\n const ext = EXT[contentType];\n if (!ext) return new Response(`send the output's Content-Type (one of: ${Object.keys(EXT).join(', ')})`, { status: 415 });\n // a re-sent output after the upload already happened: skip straight to the settle\n let object_id = await store.get(`output:${t.run_id}`);\n if (!object_id) {\n // buffered: fine for outputs of tens of MB \u2014 see \"Very large outputs\" below\n const bytes = await req.arrayBuffer();\n const size = bytes.byteLength;\n if (size === 0) return new Response('empty output', { status: 400 });\n\n const minted = await vxil(env, '/v1/files/upload-url', {\n user_id: t.user_id, filename: `${t.render_id}.${ext}`, content_type: contentType, size_bytes: size,\n });\n if (!minted.ok) return new Response(`upload-url ${minted.status}`, { status: 502 }); // the container sends the file again\n const m = ((await minted.json()) as { data: { object_id: string; upload_url: string } }).data;\n const put = await fetch(m.upload_url, { method: 'PUT', headers: { 'content-type': contentType }, body: bytes });\n if (!put.ok) return new Response(`upload ${put.status}`, { status: 502 });\n const done = await vxil(env, `/v1/files/${encodeURIComponent(m.object_id)}/complete`);\n if (!done.ok) return new Response(`complete ${done.status}`, { status: 502 });\n object_id = m.object_id;\n await store.put(`output:${t.run_id}`, object_id);\n }\n\n // 3. settle the render on the keyless callback: a MUST-ARRIVE post. Retry here for up to a\n // minute (never past the deadline); if it still did not land, answer 503 and the container\n // sends the output again \u2014 the note above turns that into one more settle attempt.\n try {\n await postCallback(t.callback_url,\n { status: 'completed', output_file: object_id, progress: 100, stage: 'done' },\n Math.min(t.exp, Date.now() + 60_000));\n } catch (e) {\n return new Response(`callback: ${String(e)}`, { status: 503, headers: AGAIN_IN_30S });\n }\n return Response.json({ object_id });\n }\n return new Response('not found', { status: 404 });\n },\n};\n\ndeclare function launchContainer(job: Record<string, unknown>): Promise<void>; // your container platform's API\n/** Your coordinator's own small store: a key-value namespace, a table, a per-key lock. `claim` is\n * an insert-if-absent (of `value`, expiring after `ttlMs`) that answers true for the FIRST caller\n * only; `put` writes without an expiry. */\ndeclare const store: {\n claim(key: string, value: string, ttlMs: number): Promise<boolean>; release(key: string): Promise<void>;\n get(key: string): Promise<string | null>; put(key: string, value: string): Promise<void>;\n};\n```\n\nThe container's side is three kinds of HTTP call and no vxil key: `processing` pings to\n`callback_url`, the file to `output_url` (with `Authorization: Bearer <output_ticket>` and the\nfile's `Content-Type`), and `{\"status\": \"failed\", \u2026}` to `callback_url` if it gives up. The\ncontainer sends its pings through `progressReporter` and its `failed` through `postCallback`\n([callbacks.ts](#callback-limits-progress-is-best-effort-the-final-post-must-arrive)), and it sends\nthe output again on any non-`2xx` answer until `deadline_at`. Five notes on the shape:\n\n- **A doubled start launches one container.** The coordinator claims `start:<run id>` as\n `launching` before it launches and marks it `launched` once the launch succeeded. A start vxil\n re-sends is answered `202` and launches nothing only when the claim says `launched`; while the\n first copy is still launching, the re-sent one is answered `503`, so vxil keeps the start alive.\n (A `202` there would end vxil's retries, and if the first launch then failed nothing would run\n and the render would refund at its deadline.) When the launch fails, the coordinator releases\n the claim and answers `503` at once, and vxil sends the start again later. A coordinator that\n dies mid-launch leaves a `launching` claim that expires after a minute, so a later copy launches;\n pass the run id as your container platform's idempotency key, where it has one, so that copy\n cannot start a second container if the first launch did go through.\n- **A retried output post** (the container saw a network error after the coordinator had uploaded)\n finds `output:<run id>` in the coordinator's store, skips the upload and only sends the\n `completed` post again. A repeated `completed` is answered with the settled state and changes\n nothing, so the final post can be retried as often as it takes.\n- **Very large outputs.** The coordinator above holds the whole file in memory, and passing gigabytes\n through it costs its bandwidth too. For big files, have the container report the size first\n (`POST /output-url` with its ticket on the `Authorization` header and `{ size_bytes }`) and let the coordinator answer with the\n presigned upload URL it minted; the container PUTs straight to it, and the coordinator completes\n the object and posts `completed` when the container says it is done. The container still holds no\n key: a presigned URL is good for one object for a few minutes.\n- **Upgrading an earlier copy of this blueprint.** Renders started before `request-render` put\n `user_id` in the start body have none, and a server-mode upload must name its user. The\n coordinator refuses such a start with a `400`, which ends that run and refunds it at once;\n redeploy `request-render` (`vxil push`) before you point `render_url` at the coordinator.\n- **Serving it.** The row now holds a files object id. Read it back with a signed download URL, or\n publish it from a settle function with a server key (`vx.files.publish(object_id)`) for a stable\n public URL served from the edge cache.\n\nOptions A and B below show the same contract with the runtime posting `completed` itself (an\n`output_url` in your own bucket). Either can adopt the coordinator: point `render_url` at it, and have\nstep 1 trigger the Trigger.dev task or spawn the Modal function.\n\n## Option A \u2014 Trigger.dev (v4)\n\nTrigger.dev runs the render as a task on a machine you pick, with ffmpeg or Chromium baked into the\nimage, retries, and its own run dashboard. Two pieces: a **relay endpoint** that turns vxil's start\nrequest into a Trigger.dev trigger, and the **task**.\n\n**Why a relay, and not `render_url` pointed straight at Trigger.dev's trigger API?** vxil sends\n`callback_url` beside `payload` at the top level of the body, and Trigger.dev's trigger API passes\nonly `payload` to the task \u2014 the task would never see where to report. The relay is ~30 lines and is\nalso where your `render_token` is checked. Host it anywhere that serves https: a serverless function on\nyour web host, a small edge worker, a route in your existing API.\n\n```ts\n// relay.ts \u2014 your render_url. Standard fetch handler (edge worker / serverless function / Node 18+ adapter).\n// Env: RENDER_TOKEN (the same value as the vxil secret render_token), TRIGGER_SECRET_KEY (tr_prod_\u2026 / tr_dev_\u2026).\ntype Start = {\n render_id: string; composition: string; props?: Record<string, unknown>;\n payload: { generation_id: string; correlation_id?: string; deadline_at: string }; callback_url: string;\n};\n\nexport default {\n async fetch(req: Request, env: { RENDER_TOKEN: string; TRIGGER_SECRET_KEY: string }): Promise<Response> {\n if (req.method !== 'POST') return new Response('method not allowed', { status: 405 });\n if (req.headers.get('authorization') !== `Bearer ${env.RENDER_TOKEN}`) {\n return new Response('unauthorized', { status: 401 }); // a 4xx ends the vxil run (and refunds)\n }\n const s = (await req.json()) as Start;\n const res = await fetch('https://api.trigger.dev/api/v1/tasks/render-video/trigger', {\n method: 'POST',\n headers: { authorization: `Bearer ${env.TRIGGER_SECRET_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n payload: {\n render_id: s.render_id, composition: s.composition, props: s.props ?? {},\n callback_url: s.callback_url, deadline_at: s.payload.deadline_at,\n },\n options: {\n // a start vxil re-sends (a lost answer) triggers the SAME Trigger.dev run\n idempotencyKey: `render:${s.render_id}`,\n // a render still queued after 3 minutes is dropped (it never runs, and no\n // onFailure fires \u2014 vxil refunds it at its deadline). Part of the budget below.\n ttl: '3m',\n tags: [`render_${s.render_id}`],\n },\n }),\n });\n if (res.ok) return Response.json({ accepted: true }, { status: 202 });\n // Trigger.dev busy or down: answer 503 and vxil tries the start again; anything else ends the run\n return new Response(`trigger.dev ${res.status}`, { status: res.status === 429 || res.status >= 500 ? 503 : 400 });\n },\n};\n```\n\n```ts\n// trigger.config.ts \u2014 ffmpeg and Chromium in the task image\nimport { defineConfig } from '@trigger.dev/sdk';\nimport { ffmpeg } from '@trigger.dev/build/extensions/core';\nimport { puppeteer } from '@trigger.dev/build/extensions/puppeteer';\n\nexport default defineConfig({\n project: '<your project ref>',\n dirs: ['./trigger'],\n maxDuration: 600, // CPU seconds PER ATTEMPT \u2014 the task sets its own; see the budget below\n build: { extensions: [ffmpeg(), puppeteer()] }, // puppeteer also needs PUPPETEER_EXECUTABLE_PATH set in the Trigger.dev env\n});\n```\n\n```ts\n// trigger/render-video.ts \u2014 the task: render, upload, report to vxil\nimport { task, metadata, logger } from '@trigger.dev/sdk';\nimport { postCallback, progressReporter } from '../callbacks'; // the helpers above\n\ntype Payload = {\n render_id: string; composition: string; props: Record<string, unknown>;\n callback_url: string; deadline_at: string; // when vxil stops waiting (ISO)\n};\n\n/** The longest one attempt takes, wall clock, with margin. An attempt that cannot\n * finish before deadline_at does not start: it tells vxil, so the credits come back now. */\nconst ATTEMPT_WALL_MS = 12 * 60_000;\n\n/** completed / failed: retried on 429 / 5xx / network errors (Retry-After honoured) until the\n * deadline \u2014 a repeated final post is a no-op on vxil's side, so retrying is always safe. */\nconst report = (p: Payload, body: Record<string, unknown>) =>\n postCallback(p.callback_url, body, Date.parse(p.deadline_at));\n\nexport const renderVideo = task({\n id: 'render-video',\n machine: 'large-1x', // 4 vCPU / 8 GB \u2014 size to your renders\n maxDuration: 600, // CPU seconds per attempt (10 min) \u2014 not wall time\n retry: { maxAttempts: 2, minTimeoutInMs: 5_000, maxTimeoutInMs: 30_000 },\n run: async (p: Payload) => {\n if (Date.now() + ATTEMPT_WALL_MS > Date.parse(p.deadline_at)) {\n // too late to finish inside vxil's deadline: refund now, and do not retry\n await report(p, { status: 'failed', error: 'deadline', hint: 'no time left for another attempt' });\n return { skipped: 'deadline' };\n }\n metadata.set('stage', 'rendering'); // Trigger.dev's own run view\n const progress = progressReporter(p.callback_url); // best-effort, at most one ping per 5 s\n await progress({ stage: 'rendering', progress: 0 });\n\n // \u2026your render: drive Chromium for frames, run ffmpeg \u2014 call progress({ progress, stage })\n // as often as you like (it coalesces) \u2014 and write the file\n // to YOUR bucket keyed by render_id (so a retried attempt overwrites, not duplicates)\u2026\n const outputUrl = `https://cdn.example.com/renders/${p.render_id}.mp4`;\n const durationS = 31.2;\n logger.info('rendered', { render_id: p.render_id, outputUrl });\n if (Date.now() > Date.parse(p.deadline_at)) {\n // vxil has already failed and refunded this render: the post below is answered\n // with that settled state. Your sizing is off \u2014 widen the budget below.\n logger.warn('finished after the vxil deadline', { render_id: p.render_id });\n }\n\n await report(p, {\n status: 'completed', output_url: outputUrl, duration_s: durationS, progress: 100, stage: 'done',\n });\n return { output_url: outputUrl };\n },\n // after the last attempt THROWS: tell vxil, so the credits come back now, not at the\n // deadline. Not called when an attempt exceeds maxDuration or the run expires on its\n // ttl \u2014 those refund only at vxil's deadline.\n onFailure: async ({ payload, error }) => {\n await report(payload, {\n status: 'failed', error: 'render_failed', hint: String(error instanceof Error ? error.message : error).slice(0, 200),\n });\n },\n});\n```\n\n**Budget the wall clock.** Trigger.dev's limits and vxil's deadline are separate clocks, and only\nvxil's refunds. Size them so a render always ends \u2014 `completed` or `failed` \u2014 before vxil's deadline:\n\n```\nttl + maxAttempts \xD7 (longest attempt, wall clock) + retry backoff < timeout.after_ms\n3 min + 2 \xD7 12 min + \u2264 1 min = 28 min < 30 min\n```\n\n`maxDuration` counts **CPU time per attempt**, not wall time across the run, so it does not bound\nthe sum: the `deadline_at` check at the start of each attempt does. If your renders need more, raise\n`RENDER_DEADLINE_MS` in `request-render` (up to `generation.maxTimeoutMs`, one hour) and resize the\nrest to fit.\n\n**Be honest with yourself about three things before you ship on Trigger.dev Cloud:**\n\n- **Data residency.** Trigger.dev Cloud keeps its operational and log data \u2014 including each run's\n payload \u2014 in the US (us-east-1), even when the machines run elsewhere. Your render props and the\n `callback_url` pass through it. If that rules it out, self-host Trigger.dev for your own app, or use\n option B.\n- **The callback URL is a credential for one render.** It appears in Trigger.dev's run payload and\n dashboard. It can settle only that render, and stops mattering once the render is settled.\n- **Two clocks, and `onFailure` is not a guarantee.** Trigger.dev calls `onFailure` only after the last\n attempt throws. A run that exceeds `maxDuration`, or expires on its `ttl` before it starts, ends\n without it \u2014 vxil refunds those at its deadline, not sooner. Keep the budget above, and keep the\n `deadline_at` check, so a late attempt refunds early instead of finishing after the refund.\n\n## Option B \u2014 your own container runtime (Modal, Fly, a container on your own cloud account)\n\nSame contract, no relay: the endpoint you deploy **is** `render_url`. It must answer within 20 seconds,\nso it only checks the token, hands the job to a background worker and answers `2xx`; the worker renders\nand posts back. On Modal:\n\n```python\n# render_app.py \u2014 `modal deploy render_app.py`; render_url = the endpoint's https URL\nimport http.client, json, os, time, urllib.error, urllib.request\nfrom datetime import datetime, timedelta, timezone\nimport modal\nfrom fastapi import HTTPException, Request # also `pip install fastapi` where you run `modal deploy`\n\nimage = (modal.Image.debian_slim()\n .apt_install(\"ffmpeg\", \"chromium\")\n .pip_install(\"fastapi[standard]\"))\napp = modal.App(\"render-farm\", image=image)\nsecrets = [modal.Secret.from_name(\"render-farm\")] # RENDER_TOKEN\n\ndef _post(callback_url: str, body: dict) -> None:\n req = urllib.request.Request(callback_url, data=json.dumps(body).encode(),\n headers={\"content-type\": \"application/json\"}, method=\"POST\")\n with urllib.request.urlopen(req, timeout=30) as res:\n res.read()\n\ndef report(callback_url: str, body: dict, deadline: datetime) -> None:\n \"\"\"completed / failed: MUST arrive. Retries 429, 5xx and network errors (honouring\n Retry-After) until the deadline; a repeated final post changes nothing on vxil's side.\"\"\"\n attempt = 0\n while True:\n wait = min(2 ** attempt, 30)\n attempt += 1\n try:\n _post(callback_url, body)\n return\n except urllib.error.HTTPError as e:\n if e.code != 429 and e.code < 500:\n raise # another 4xx: the URL or the body is wrong\n ra = e.headers.get(\"retry-after\")\n wait = int(ra) if ra and ra.isdigit() else wait\n except (OSError, http.client.HTTPException):\n pass # no answer, or the connection dropped mid-answer\n # (URLError, a reset, RemoteDisconnected, IncompleteRead,\n # a timeout): send it again\n if datetime.now(timezone.utc) + timedelta(seconds=wait) > deadline:\n raise RuntimeError(\"callback not delivered before the deadline\")\n time.sleep(wait)\n\n_last_ping = {}\ndef ping(callback_url: str, body: dict) -> None:\n \"\"\"processing: best-effort, at most one every 5 s; a refused or failed ping is dropped.\"\"\"\n now = time.monotonic()\n if now - _last_ping.get(callback_url, 0.0) < 5:\n return\n _last_ping[callback_url] = now\n try:\n _post(callback_url, {\"status\": \"processing\", **body})\n except Exception:\n pass\n\nATTEMPT_WALL_S = 25 * 60 # = the timeout below; a call cut off there may never reach its except\n\n@app.function(cpu=4, memory=8192, timeout=ATTEMPT_WALL_S, secrets=secrets)\ndef render(job: dict) -> None:\n cb = job[\"callback_url\"]\n deadline = datetime.fromisoformat(job[\"payload\"][\"deadline_at\"].replace(\"Z\", \"+00:00\"))\n if datetime.now(timezone.utc) + timedelta(seconds=ATTEMPT_WALL_S) > deadline:\n # queued too long to finish before vxil stops waiting: refund now\n report(cb, {\"status\": \"failed\", \"error\": \"deadline\", \"hint\": \"started too late to finish\"}, deadline)\n return\n try:\n ping(cb, {\"stage\": \"rendering\", \"progress\": 0})\n # \u2026render with ffmpeg / chromium (ping(cb, {...}) as often as you like),\n # upload to YOUR bucket keyed by job[\"render_id\"]\u2026\n output_url = f\"https://cdn.example.com/renders/{job['render_id']}.mp4\"\n except Exception as e: # the RENDER failed: tell vxil now, so the credits come back before the deadline\n report(cb, {\"status\": \"failed\", \"error\": \"render_failed\", \"hint\": str(e)[:200]}, deadline)\n raise\n # the output exists: only `completed` may follow. Outside the try on purpose, so a\n # trouble delivering it can never turn into a `failed` post that refunds a finished render.\n report(cb, {\"status\": \"completed\", \"output_url\": output_url, \"duration_s\": 31.2,\n \"progress\": 100, \"stage\": \"done\"}, deadline)\n\n@app.function(secrets=secrets)\n@modal.fastapi_endpoint(method=\"POST\")\nasync def start(request: Request):\n if request.headers.get(\"authorization\") != f\"Bearer {os.environ['RENDER_TOKEN']}\":\n raise HTTPException(status_code=401, detail=\"unauthorized\") # a 4xx ends the vxil run\n job = await request.json()\n await render.spawn.aio(job) # queued; returns at once, well inside the 20-second window\n return {\"accepted\": True}\n```\n\n`spawn` queues the call and returns immediately, so the endpoint answers in well under a second. A\nstart can arrive twice (a lost answer is retried), so dedupe on the run id\n(`request.headers[\"x-vxil-run-id\"]`; never a hash of the props): keep the run ids you have spawned in\na `modal.Dict`, or make the render overwrite the same output key.\n\nAnything else that can (1) answer an https POST in under 20 s, (2) run the work in the background and\n(3) POST JSON to a URL fits the same contract: a Fly Machine started per job, a container service with\na queue in front, your own GPU box. vxil does not care what runs the render \u2014 only that the start is\nacknowledged quickly and the callback eventually comes.\n\n## Run it\n\nGive a user some credits from your server (or sell `render_pack_100` through your payments provider):\n\n```bash\ncurl -s -X POST \"https://api.vxil.com/v1/payments/credits/grant\" \\\n -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n -H 'idempotency-key: welcome-u1' \\\n -d '{\"user_id\":\"<the user id>\",\"credit_type\":\"render_credits\",\"amount\":25,\"source\":\"welcome\"}'\n```\n\nStart a render **with the user's session** (end-user mode \u2014 the held credits are forced onto that\nuser):\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// after `vxil gen`, vx.fn['request-render'] is typed from the function's declared signature\nconst vx = new Vxil({ apiKey: process.env.VXIL_PUBLISHABLE_KEY!, endUserToken: process.env.USER_SESSION! });\nconst started = await vx.fn['request-render']({\n composition: 'promo-30s', request_key: 'promo-1', props: { headline: 'Spring sale' },\n});\n// \u2192 { item_id, run_id, credits: 5 }\n// (or { duplicate: true, request_key, item_id, run_id, status } on a retry)\n```\n\nThen read the row \u2014 or subscribe to its changes \u2014 until `status` is `completed`:\n\n```ts\nif ('item_id' in started) {\n const row = await vx.from('renders').get(started.item_id);\n // row.status \u2192 'completed', row.output_url \u2192 'https://cdn.example.com/renders/\u2026.mp4'\n}\n```\n\n**Try it before you have a runtime.** Point `render_url` at any https endpoint that answers `2xx`\n(a request-bin works) and play the runtime yourself: copy `callback_url` from the request it received,\nthen\n\n```bash\ncurl -s -X POST \"$CALLBACK_URL\" -H 'content-type: application/json' \\\n -d '{\"status\":\"completed\",\"output_url\":\"https://cdn.example.com/x.mp4\",\"duration_s\":12.5,\"progress\":100,\"stage\":\"done\"}'\n# \u2192 { \"data\": { \"run_id\": \"run_\u2026\", \"generation_status\": \"completed\" } } \u2014 and the row says so\n```\n\n## How it fails, and what the user sees\n\n| what happened | the run | the row | the credits |\n|---|---|---|---|\n| runtime posted `completed` | `completed` | `status: completed` + every key it sent | committed |\n| runtime posted `failed` | `failed` | `status: failed`, `error` = its code + hint (written by `notify-ready`) | refunded |\n| runtime never called back | `failed` (`GenerationExpired`) at the deadline | `failed`, `error: GenerationExpired: the render did not finish before its deadline` | refunded |\n| runtime finished after the deadline | `failed` (`GenerationExpired`) \u2014 exact to the second; the late `completed` is answered `failed` / `expired: true` | `failed` | refunded (your compute was spent \u2014 budget the clocks) |\n| the final post was answered `429` (or `5xx`, or got no answer) | still open: nothing is settled until a post lands | unchanged | still held \u2014 `postCallback` sends it again after `Retry-After`; give up only at `deadline_at`, when the run refunds anyway |\n| a progress ping was answered `429` | unchanged | the previous progress stays | unchanged \u2014 drop the ping; the next one carries the newer state |\n| your endpoint answered `5xx` / timed out | start retried with backoff; terminal after the attempts | `processing` \u2192 `failed`, `error: RetryableHttp: the render endpoint kept failing to accept the render (retries exhausted)` (or `NetworkError: \u2026` when it could not be reached) | held until then, then refunded |\n| your endpoint answered another `4xx` (a bad token) | `failed` at once | `failed` | refunded |\n| the user had too few credits (at `request-render`) | ended at once (`ReserveInsufficient`), never started | `failed`, `error: insufficient_credits`; that `request_key` is spent; the caller got the `402`, no message | nothing held |\n| too many renders in flight, payments briefly unreachable, or a jobs-side fault | not created yet (the caller gets `429` / `503` with `queued: true` and `retry_after`, or `502` with `queued: true`) | `pending`, no run \u2014 **queued**: `redrive-pending` starts it when a slot frees up (or the app calls again with the **same** `request_key`) | held when it starts |\n| the user had too few credits when the re-driver started it | ended at once (`ReserveInsufficient`) | `failed`, `error: insufficient_credits`; the owner is told once (they last heard \"queued\") | nothing held |\n| still no free slot an hour later | never created | `failed`, `error: not_started: the render waited too long for a free slot` (written by `redrive-pending`); the owner is told once | nothing held |\n| the re-driver's start was refused (`400` / `401` / `403` / `422`: a non-https or private `render_url`, a wrong or revoked `vxil_jobs_key`) | not created | still `pending` \u2014 the tick stops and reports it (`stopped: { status, code }` in the function's logs); fix the setup and the next tick carries on; the one-hour bound still applies | nothing held |\n\n## The backlog: `maxConcurrent`, `429` and the re-driver\n\n`generation.maxConcurrent` is how many generation runs this project may have **open** at once: 20 by\ndefault, settable up to 200 in the jobs config. This blueprint sets 20; raise it to what your plan\nand your runtime can carry:\n\n```ts\njobs: { enabled: true, generation: { maxConcurrent: 50 /* 1\u2013200, default 20 */ } },\n```\n\nAt the cap a new start is refused with `429` and `Retry-After: 5`. **No run is created and nothing\nis held yet.** The same holds when the credits cannot be held because payments is briefly\nunreachable: the start is refused with `503 payments_unavailable` (nothing started, nothing held;\nit names a 15-second wait), `request-render` answers `503` with `queued: true`, and the re-driver starts\nthe row on a later tick with the same key (a fresh run). Never a free render: that only happens if\nyou opt in with `reserve_credits.on_unavailable: 'proceed'`. `request-render` passes the `429` on to the app with `queued: true` (and the\n`item_id` and `request_key`), and leaves the row `pending` with no `run_id`. That render is\n**queued, not refused**: the re-driver will start it, and hold its credits then, up to an hour\nlater. So the app must treat a `429` with `queued: true` as \"queued\" \u2014 show it, watch the row \u2014 and\nmust **never retry it with a new `request_key`**: that is a second render, and both are charged. A\nretry with the **same** `request_key` is always safe. Rows like that are the backlog, and two\nthings drain it:\n\n1. **`redrive-pending`, every minute.** It reads the oldest `pending` rows with no run that are at\n least 30 s old (`{ status: 'pending', created_at: { $lt: \u2026 }, run_id: null }`, sorted by\n `created_at`, 20 per tick; `status` and `created_at` are index slots, so the read stays cheap at\n any size) and starts each one with the same descriptor and the **same idempotency key** as\n `request-render`, so a row the app is re-driving at the same moment still gets one run. It\n **stops at the first `429`** (or `503 payments_unavailable`), since the rest of the batch would\n get the same answer, and the next tick carries on. Each try is recorded on the row (`redrive_attempts`, `redriven_at`). It\n sends the row's **stored** `deadline_at` (a row with none gets one stored first, only while none\n is), so its body is the same request as the first start's and jobs hands that run back. A `409\n generation_in_progress` (another start of the row is still placing its credit hold) is recorded\n and the tick goes on; a `422 idempotency_key_reused` that names a run (a run of this key exists\n under another body) links that run to the row. A `402`\n fails the row with `insufficient_credits`. Any other `400`, `401`, `403` or `422` also **stops** the tick\n and fails nothing: every re-driven row sends the same descriptor, so a refusal means the setup is\n wrong (a non-https or private `render_url`, a revoked key), not the row; the tick reports it as\n `stopped: { status, code }`. The only thing that fails a waiting row is age: a row still waiting\n after **one hour** is failed with `not_started: the render waited too long for a free slot`.\n Nothing was ever held, so nothing is refunded. In both the `402` and the one-hour case the owner\n is told once (`Idempotency-Key: render-not-started:<item_id>`), because the last thing they heard\n was \"queued\". (The function's scopes include `notifications:send` for that.)\n2. **The app**, calling `request-render` again with the same `request_key`, if it wants the render\n started sooner than the next tick.\n\n`overlap: 'skip'` keeps a slow tick from being doubled by the next one. On the Free plan, run it\nevery 15 minutes (see the plan note at the top).\n\n**Why it has its own key.** A credit hold placed by a function must name the signed-in user the\nfunction acts for, and a cron tick has none. So `redrive-pending` makes the start with\n`vxil_jobs_key`, an API key holding only `jobs:write`, the way your trusted server would. That is\nmore than \"hold credits and start runs\": `jobs:write` also lets the key cancel or replay any run of\nthis project, enqueue any job, and create or change schedules and flow rules, and it can hold\ncredits against any of your users and start runs against any public https endpoint. Treat it as a\nserver key: keep it only in this function's secrets, never in an app, and rotate it like any\nserver key. The re-driver never reads the\nprice from the row (a signed-in user can edit their own row): the hold is the function's fixed\n`RENDER_CREDITS`, and a row whose `render_key` is not `owner:request_key` is failed, not started.\n(A hand-written row like that which also breaks the `render_key_shape` hook cannot be written at\nall, so the tick counts it as `unwritable` and it keeps one of the 20 slots: fix or delete it by\nhand.)\n\n## A contract test for your runtime\n\nEvery key your runtime posts in the `completed` body is written onto the row, so every key must be a\ndeclared field of `renders`. Keep that true in your own CI with a test beside your runtime's code. It\nfails the day someone adds a key to the completion body and forgets the field:\n\n```ts\n// render-contract.test.ts \u2014 vitest, in YOUR repo (the one holding vxil.config.ts)\nimport { describe, expect, it } from 'vitest';\nimport config from './vxil.config';\n// the bodies your runtime (or coordinator) really posts: import the builders from that code,\n// so the test follows it instead of a hand-copied list\nimport { completedBody, progressBody } from './runtime/callback-bodies';\n\ndescribe('render callbacks', () => {\n const declared = new Set(Object.keys(config.cms!.collections!.renders!.fields!));\n\n it('every key of the completion body is a declared renders field', () => {\n const body = completedBody({ objectId: 'obj_test', durationS: 1 });\n expect(Object.keys(body).filter((k) => !declared.has(k))).toEqual([]);\n });\n\n it('a progress ping carries only the keys the mirror keeps', () => {\n const ping = progressBody({ progress: 40, stage: 'encoding' });\n expect(Object.keys(ping).filter((k) => !['status', 'progress', 'stage', 'message'].includes(k))).toEqual([]);\n });\n});\n```\n\nThe platform also has a fallback for the day that test is missing. When the row refuses a completion\nmirror because of an undeclared key, vxil writes the status alone, so the row still says\n`completed`, and the run reports what it dropped (`GET /v1/jobs/runs/{run_id}` \u2192 `mirror_error`,\nwith `fields_dropped`). The app no longer hangs on `processing`, but the dropped keys are not on the\nrow. The test is what keeps them there.\n\n## The bounds to design against\n\n- **Deadline**: the run's timeout is clamped to `generation.maxTimeoutMs` \u2014 one hour at most. A render\n that can take longer should be split (the next step starts from `notify-ready`), or tracked on your own\n row without a platform-held reserve.\n- **In flight**: `generation.maxConcurrent` renders at once (20 here and by default; up to 200). Over\n it, `request-render` answers `429` and the row waits for `redrive-pending`, or for a retry with the\n same `request_key` ([the backlog](#the-backlog-maxconcurrent-429-and-the-re-driver)).\n- **Holds**: one render's reserve is clamped to `generation.maxReserveCredits` (50 here), and the sum of\n all open holds is capped by `generation.maxOutstandingReserveCredits` (5,000 here).\n- **Callback**: at most 256 KiB per post, JSON, every key a declared field of `renders`. About 120\n posts a minute per render plus a small allowance kept for the final post (both best-effort, per\n serving machine); progress at most every 5 s, and the final post retried until it lands\n ([callback limits](#callback-limits-progress-is-best-effort-the-final-post-must-arrive)).\n\n## Your token, and where it lives\n\n`render_url` and `render_token` are function secrets; `request-render` reads them at invoke time and\nputs them on the generation run, which vxil stores with the run (your project only) for as long as the\njobs retention keeps it. The token is **never returned by a read**: `GET /v1/jobs/runs/{run_id}` (and\nthe dashboard and MCP reads built on it) shows the provider header names with every value as\n`[redacted]`. Rotate it in both places (`vxil secrets set functions/render_token` and your endpoint's\nenv); runs already started keep the token they were started with.\n\n## Evidence\n\n- **Read in vxil's code**: the start request body (`provider.body` + `payload` + `callback_url`), the\n 20-second start bound, the retry ladder, the status mirror writing every completion key onto the row,\n the hold committed on `completed` and released on `failed` / the deadline, and `error` / `hint`\n becoming `error_class` / `error_hint` on `job.generation.failed`.\n- **Read in Trigger.dev's and Modal's documentation, not executed from vxil**: the trigger endpoint\n `POST https://api.trigger.dev/api/v1/tasks/{taskId}/trigger` with `{ payload, options }` and the\n `idempotencyKey` / `ttl` / `tags` / `machine` options; `task({ id, machine, maxDuration, retry, run,\n onFailure })` and `retry` options, `maxDuration` being CPU time per attempt with no `onFailure` when\n it is exceeded, `metadata.set`, and the `ffmpeg()` / `puppeteer()` build extensions; Trigger.dev\n Cloud's US-hosted operational data; Modal's `@modal.fastapi_endpoint` and `.spawn()`. Check each\n vendor's current docs before you ship.\n",
18566
19118
  "functions": {
18567
19119
  "notify-ready.ts": "// notify-ready.ts \u2014 job.generation.completed | failed \u2192 tell the render's owner\n// (a vxil function, webhook trigger on `job.generation.`).\n//\n// The status mirror has already written the row (status, and on completion\n// every key your runtime sent back). This function adds the two things a\n// mirror cannot: the failure cause on the row, and a message to the user.\n//\n// AT-LEAST-ONCE: an event can be delivered again. And because the trigger\n// declares `retry: { maxAttempts: 3 }`, a non-2xx answer from here goes back\n// to the jobs ladder for another attempt (without `retry` it would simply be\n// acknowledged). Every attempt carries the same event. The notification carries an\n// Idempotency-Key of one per RUN, so a redelivery sends nothing twice, and the\n// row patch writes the same value again.\n\nimport type { JobGenerationSettledEventPayload, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype RenderRow = {\n owner?: string; composition?: string; output_url?: string; error?: string; redrive_attempts?: number;\n run_id?: string | null; status?: string;\n};\n\n/** Platform error classes settle with no hint; these are the ones a render\n * can end with, in words for the row (the class stays first, for code). */\nconst PLATFORM_HINTS: Record<string, string> = {\n GenerationExpired: 'the render did not finish before its deadline',\n RetryableHttp: 'the render endpoint kept failing to accept the render (retries exhausted)',\n NetworkError: 'the render endpoint could not be reached (retries exhausted)',\n};\n\nfunction patchError(base: string, H: Record<string, string>, id: string, error: string): Promise<Response> {\n return fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(id)}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data: { error } }),\n });\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as WebhookFunctionEnvelope<JobGenerationSettledEventPayload>;\n const d = env.payload?.data;\n // job.generation.queued carries no status; a truncated event has no fields\n if (!d || 'truncated' in d || !('status' in d) || !d.generation_id) return Response.json({ skipped: true });\n const cms = env.scoped_jwts?.cms;\n const notifications = env.scoped_jwts?.notifications;\n if (!cms || !notifications) return Response.json({ error: 'missing cms/notifications scope' }, { status: 403 });\n const base = env.vxil_base;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n const rowRes = await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(d.generation_id)}`, { headers: H });\n // another job's generation event (not a render) \u2014 nothing to do\n if (rowRes.status === 404) return Response.json({ skipped: 'not a render' });\n if (!rowRes.ok) return Response.json({ error: `render read: ${rowRes.status}` }, { status: 502 }); // another attempt (header note)\n const row = ((await rowRes.json()) as { data: { data: RenderRow } }).data.data;\n if (!row.owner) return Response.json({ skipped: 'no owner' });\n\n if (d.status === 'failed') {\n // BRANCH ON error_class \u2014 not every `failed` is a render that failed:\n // \u2022 PaymentsUnavailable: the credits could not be held when the run was\n // started, so it ended before it began (nothing held). The start\n // answered 503 and the row is still pending \u2014 redrive-pending starts\n // it on a later tick (a NEW run). Nothing to tell the owner.\n // ONE race to undo: a request-render that re-sent the same key while\n // that start was still waiting on payments was handed THIS run (202,\n // deduplicated) and linked it \u2014 and redrive-pending only picks rows\n // with no run_id. Unlink it (only while the row is still pending on\n // this very run), so the re-driver starts it.\n if (d.error_class === 'PaymentsUnavailable') {\n if (row.run_id === d.run_id && (row.status ?? 'pending') === 'pending') {\n const unlinked = await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(d.generation_id)}`, {\n method: 'PATCH', headers: H,\n body: JSON.stringify({ data: { run_id: null }, if: { run_id: d.run_id, status: 'pending' } }),\n });\n // 409: the row moved on meanwhile (re-driven, failed) \u2014 nothing to undo\n if (!unlinked.ok && unlinked.status !== 409) {\n return Response.json({ error: `render patch: ${unlinked.status}` }, { status: 502 }); // another attempt (header note)\n }\n return Response.json({ skipped: 'not started: payments unavailable', unlinked: unlinked.ok });\n }\n return Response.json({ skipped: 'not started: payments unavailable' });\n }\n // \u2022 Cancelled: your own cancel (POST /v1/jobs/runs/{id}/cancel); the\n // credits were released. The row says so; the owner is not told \"try\n // again\" \u2014 tell them from where you cancelled, if at all.\n if (d.error_class === 'Cancelled') {\n if (row.error !== 'cancelled') {\n const patched = await patchError(base, H, d.generation_id, 'cancelled');\n if (!patched.ok) return Response.json({ error: `render patch: ${patched.status}` }, { status: 502 }); // another attempt (header note)\n }\n return Response.json({ ok: true, notified: false });\n }\n // Not enough credits. A render request-render started itself already\n // answered the user (402) and wrote error: 'insufficient_credits' \u2014 keep\n // that code on the row (write it only if that write was lost) and send\n // nothing. A render the RE-DRIVER started is different: the user last\n // heard 429 \"queued\", so they are told \u2014 with the re-driver's own key\n // (render-not-started:<item_id>), so the two never both send.\n if (d.error_class === 'ReserveInsufficient') {\n if (!row.error) {\n const patched = await patchError(base, H, d.generation_id, 'insufficient_credits');\n if (!patched.ok) return Response.json({ error: `render patch: ${patched.status}` }, { status: 502 }); // another attempt (header note)\n }\n if (!(typeof row.redrive_attempts === 'number' && row.redrive_attempts > 0)) {\n return Response.json({ ok: true, notified: false });\n }\n return send(base, notifications, `render-not-started:${d.generation_id}`, row.owner, {\n subject: 'Your render could not start',\n paragraph: `\"${row.composition ?? 'Your render'}\" was queued, but there were not enough credits when its turn came. Nothing was charged \u2014 top up and try again.`,\n });\n }\n // the cause the run settled with: your runtime's `error` (+ `hint`), or\n // the platform's own class \u2014 which carries no hint, so a short human\n // one is added for the ones a user can meet (PLATFORM_HINTS)\n const hint = d.error_hint ?? (d.error_class ? PLATFORM_HINTS[d.error_class] : undefined);\n const cause = [d.error_class, hint].filter(Boolean).join(': ') || 'render failed';\n if (row.error !== cause) {\n const patched = await patchError(base, H, d.generation_id, cause.slice(0, 500));\n if (!patched.ok) return Response.json({ error: `render patch: ${patched.status}` }, { status: 502 }); // another attempt (header note)\n }\n }\n\n return send(base, notifications, `render-ready:${d.run_id}`, row.owner, d.status === 'completed'\n ? { subject: 'Your render is ready', paragraph: `\"${row.composition ?? 'Your render'}\" finished. Open the app to watch it.` }\n : { subject: 'Your render could not finish', paragraph: 'Nothing was charged \u2014 the credits are back on your balance. Try again in a minute.' });\n },\n};\n\n/** One transactional message; a non-2xx answer asks the ladder for another attempt. */\nasync function send(\n base: string, token: string, idempotencyKey: string, userId: string,\n data: { subject: string; paragraph: string },\n): Promise<Response> {\n const sent = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'idempotency-key': idempotencyKey },\n body: JSON.stringify({ user_id: userId, template: 'transactional', data }),\n });\n return sent.ok\n ? Response.json({ ok: true, notified: true })\n : Response.json({ error: `notifications send: ${sent.status}` }, { status: 502 }); // another attempt (header note)\n}\n",
18568
- "redrive-pending.ts": "// redrive-pending.ts \u2014 the BACKLOG RE-DRIVER (a vxil function, cron trigger,\n// every minute; `overlap: 'skip'`).\n//\n// cron-walk: drains-filter \u2014 every row it starts is PATCHed with its run_id (and\n// every row it gives up on to status 'failed'), which takes it out of the\n// `{ status: 'pending', run_id: null }` read; a 429 stops the tick early.\n//\n// Why it exists: at the generation concurrency cap (`generation.maxConcurrent`\n// open runs \u2014 20 in this blueprint, up to 200) request-render answers 429 and\n// leaves the row `pending` with NO run. Without this function only the caller\n// re-drives it (the same request_key again). With it, a backlog drains on its\n// own: each tick reads the oldest pending rows that have had no run for at\n// least REDRIVE_AFTER_MS, and starts each one with the SAME descriptor and the\n// SAME idempotency key (the row's render_key) request-render uses \u2014 so a row a\n// user is re-driving at the same moment still gets exactly one run.\n//\n// per row, by the jobs answer:\n// 202 (new run, or an open one handed back) \u2192 PATCH run_id (the status\n// mirror settles the row from here)\n// 202 deduplicated, generation_status 'failed' \u2192 PATCH run_id + status 'failed'\n// 402 (too few credits) \u2192 PATCH status 'failed',\n// error 'insufficient_credits',\n// and TELL the owner (they last\n// heard \"queued\", not \"refused\")\n// 429 (cap reached / too many credits held), \u2192 STOP the tick; the rest wait\n// or 503 payments_unavailable (credits could for the next one (the wait\n// not be held: nothing started or held) header is reported; the same\n// key starts a fresh run then)\n// 400 / 401 / 403 / 422 \u2192 STOP the tick, report it. Every\n// re-driven row sends the same\n// descriptor apart from its own\n// (already bounded) composition and\n// props, so a refusal is a SETUP\n// error \u2014 a non-https or private\n// render_url, a wrong or revoked\n// key, a config change \u2014 that\n// would fail every row the same\n// way. Nothing is failed for it:\n// fix the setup and the next tick\n// carries on.\n// 5xx / network \u2192 record the attempt, go on\n// A row still pending with no run after BACKLOG_MAX_AGE_MS is the ONLY thing\n// the re-driver gives up on: status 'failed', error 'not_started: \u2026' (nothing\n// was ever held for it), and the owner is told once\n// (Idempotency-Key render-not-started:<item_id>).\n// Every try is recorded on the row: redrive_attempts, redriven_at.\n//\n// A HAND-WRITTEN ROW that breaks render_key_shape (written before the hook\n// existed, or by a path that bypassed it) cannot be patched either \u2014 the\n// hook judges the merged row \u2014 so it would stay pending and take one of the\n// tick's slots for good. Fix or delete such rows by hand; the tick reports\n// them as `unwritable`.\n\n// THE KEY: a function-originated credit hold must name the user it acts for,\n// and a cron tick has no signed-in user \u2014 so the jobs call is made with\n// `vxil_jobs_key`, an API key of this same backend holding ONLY `jobs:write`\n// (a trusted server key may hold credits for any of your users; README \"The\n// backlog\"). The rows are read and written with the function's own scoped cms\n// token. The credits held are this blueprint's fixed price, never the row's\n// `credits` field \u2014 a signed-in user can edit their own row, so nothing the\n// re-driver charges or starts is read from a value they could have lowered.\n// The owner is told with the function's scoped notifications token.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\n/** What one render costs \u2014 the SAME number as request-render's RENDER_CREDITS. */\nconst RENDER_CREDITS = 5;\n/** The deadline \u2014 the SAME number as request-render's RENDER_DEADLINE_MS. */\nconst RENDER_DEADLINE_MS = 1_800_000;\n/** A row is the re-driver's only once request-render has had time to start it. */\nconst REDRIVE_AFTER_MS = 30_000;\n/** Rows started per tick (bounded: a tick is one function invocation). */\nconst MAX_REDRIVE_PER_TICK = 20;\n/** A row still waiting for a run after this long is failed (nothing is held). */\nconst BACKLOG_MAX_AGE_MS = 3_600_000;\n\ntype Env = CronFunctionEnvelope;\ntype RenderRow = {\n item_id: string;\n created_at?: string;\n data: {\n render_key?: string; request_key?: string; owner?: string; composition?: string;\n props?: Record<string, unknown>; created_at?: string; redrive_attempts?: number;\n };\n};\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notifications = env.scoped_jwts?.notifications;\n const jobsKey = env.secrets?.vxil_jobs_key;\n const renderUrl = env.secrets?.render_url;\n const renderToken = env.secrets?.render_token;\n if (!cms || !notifications) return Response.json({ error: 'missing cms/notifications scope' }, { status: 403 });\n if (!jobsKey || !renderUrl || !renderToken) {\n return Response.json(\n { error: 'store the secrets: vxil secrets set functions/vxil_jobs_key (an API key holding only jobs:write), functions/render_url, functions/render_token' },\n { status: 503 },\n );\n }\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = Date.now();\n\n // the oldest pending rows with no run: `status` (s4) and `created_at` (t1)\n // are index slots, so the slots pick the rows and the `run_id: null` test\n // only runs over what they picked\n const filter = encodeURIComponent(JSON.stringify({\n status: 'pending',\n created_at: { $lt: new Date(now - REDRIVE_AFTER_MS).toISOString() },\n run_id: null,\n }));\n const listed = await fetch(\n `${base}/v1/cms/items/renders?filter=${filter}&sort=created_at&limit=${MAX_REDRIVE_PER_TICK}`,\n { headers: H },\n );\n if (!listed.ok) return Response.json({ error: `renders read: ${listed.status}` }, { status: 502 });\n const rows = ((await listed.json()) as { data?: { items?: RenderRow[] } }).data?.items ?? [];\n\n const out = { scanned: rows.length, started: 0, failed: 0, retry_later: 0, given_up: 0, unwritable: 0,\n notified: 0, notify_failed: 0,\n stopped: null as null | { status: number; code: string | null; retry_after: string | null } };\n const tell = async (row: RenderRow, why: 'credits' | 'waited') => {\n if (await notifyNotStarted(base, notifications, row, why)) out.notified += 1;\n else out.notify_failed += 1;\n };\n\n for (const row of rows) {\n const d = row.data;\n const attempts = (typeof d.redrive_attempts === 'number' ? d.redrive_attempts : 0) + 1;\n const createdAt = Date.parse(d.created_at ?? row.created_at ?? '');\n const mark = { redrive_attempts: attempts, redriven_at: new Date(now).toISOString() };\n\n // a row that cannot be a render request-render made (a hand-written row\n // missing its key parts), or one that has waited too long: fail it \u2014 no\n // run exists, so nothing is held and nothing is refunded\n const shapeOk = !!d.owner && !!d.request_key && !!d.composition\n && d.render_key === `${d.owner}:${d.request_key}`\n && d.composition.length <= 120 && JSON.stringify(d.props ?? {}).length <= 16_384;\n if (!shapeOk || (Number.isFinite(createdAt) && now - createdAt > BACKLOG_MAX_AGE_MS)) {\n const error = shapeOk\n ? 'not_started: the render waited too long for a free slot'\n : 'not_started: the row is not a render request-render created';\n // only while it is STILL pending with no run (a racing start wins)\n if (await patchRow(base, H, row.item_id, { ...mark, status: 'failed', error }, { status: 'pending', run_id: null })) {\n out.given_up += 1;\n // the owner last heard \"queued\" (a 429): say it will not happen. A\n // malformed row's owner is not trusted, so it is only failed.\n if (shapeOk) await tell(row, 'waited');\n } else if (!shapeOk) {\n out.unwritable += 1; // header note: fix it by hand\n }\n continue;\n }\n\n const enq = await startRun(base, jobsKey, renderUrl, renderToken, {\n itemId: row.item_id, user: d.owner!, renderKey: d.render_key!, requestKey: d.request_key!,\n composition: d.composition!, props: d.props ?? {},\n });\n if (!enq) { // network: try again next tick\n await patchRow(base, H, row.item_id, mark);\n out.retry_later += 1;\n continue;\n }\n if (enq.status === 402) {\n if (await patchRow(base, H, row.item_id, { ...mark, status: 'failed', error: 'insufficient_credits' })) await tell(row, 'credits');\n out.failed += 1;\n continue;\n }\n const code = enq.status === 429 || (enq.status >= 400 && enq.status !== 402)\n ? ((await enq.json().catch(() => ({}))) as { error?: { code?: string } }).error?.code ?? null\n : null;\n if (enq.status === 429 || (enq.status >= 400 && enq.status < 500) || code === 'payments_unavailable') {\n // 429: at the cap \u2014 the rest of the batch would get the same answer.\n // 503 payments_unavailable: the credits could not be held right now\n // (nothing was started or held) \u2014 the rest would get the same answer;\n // the next tick re-sends the SAME key and gets a fresh run.\n // 400 / 401 / 403 / 422: a setup error (header note) \u2014 failing rows for\n // it would be wrong and could not be undone. Either way: record the\n // try, stop, and let the next tick (Retry-After: seconds) go on.\n await patchRow(base, H, row.item_id, mark);\n out.stopped = { status: enq.status, code: enq.status === 429 ? null : code, retry_after: enq.headers.get('retry-after') };\n break;\n }\n if (!enq.ok) { // 5xx: try again next tick\n await patchRow(base, H, row.item_id, mark);\n out.retry_later += 1;\n continue;\n }\n const run = ((await enq.json()) as { data: { run_id: string; generation_status?: string; deduplicated?: boolean } }).data;\n if (run.deduplicated && run.generation_status === 'failed') {\n // the run already ENDED (its row write was lost): record it as failed\n await patchRow(base, H, row.item_id, { ...mark, run_id: run.run_id, status: 'failed' });\n out.failed += 1;\n continue;\n }\n await patchRow(base, H, row.item_id, { ...mark, run_id: run.run_id });\n out.started += 1;\n }\n // a 2xx either way: a cron tick is never retried (the next tick is the retry)\n return Response.json(out);\n },\n};\n\ninterface Start {\n itemId: string; user: string; renderKey: string; requestKey: string;\n composition: string; props: Record<string, unknown>;\n}\n\n/** The SAME webhook-mode generation request-render starts (the CI gate keeps\n * the two descriptors equal), sent with the jobs:write server key. Null on a\n * network error. */\nasync function startRun(base: string, key: string, renderUrl: string, renderToken: string, a: Start): Promise<Response | null> {\n return fetch(`${base}/v1/jobs/generation`, {\n // tenant-key: jobs:write via secret:vxil_jobs_key \u2014 called with the server\n // key, not the function's scoped token (header note); the CI gate checks\n // the secret is declared instead of a jobs scope\n method: 'POST',\n headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: 'render',\n provider: {\n url: renderUrl,\n method: 'POST',\n headers: { authorization: `Bearer ${renderToken}` },\n body: { render_id: a.itemId, user_id: a.user, composition: a.composition, props: a.props },\n },\n completion: { mode: 'webhook', status_path: 'status' },\n status_mirror: {\n feature: 'cms', collection: 'renders', record_id: a.itemId, column: 'status',\n progress_fields: ['progress', 'stage', 'message'],\n },\n reserve_credits: { amount: RENDER_CREDITS, user_id: a.user, credit_type: 'render_credits', reason: `render ${a.composition}` },\n timeout: { after_ms: RENDER_DEADLINE_MS },\n payload: {\n generation_id: a.itemId, correlation_id: a.requestKey,\n deadline_at: new Date(Date.now() + RENDER_DEADLINE_MS).toISOString(),\n },\n idempotency_key: a.renderKey,\n }),\n }).catch(() => null);\n}\n\n/** PATCH a render row (optionally only while `when` still holds); true when it landed. */\nasync function patchRow(\n base: string, H: Record<string, string>, itemId: string,\n data: Record<string, unknown>, when?: Record<string, unknown>,\n): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify(when ? { data, if: when } : { data }),\n }).catch(() => undefined);\n return res?.ok ?? false;\n}\n\n/** Tell the owner a render they were told was queued will not start. One\n * message per render (Idempotency-Key render-not-started:<item_id> \u2014 the SAME\n * key notify-ready uses for a re-driven credit refusal, so the two never both\n * send). True when it was accepted. */\nasync function notifyNotStarted(base: string, token: string, row: RenderRow, why: 'credits' | 'waited'): Promise<boolean> {\n const name = row.data.composition ?? 'Your render';\n const res = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${token}`, 'content-type': 'application/json',\n 'idempotency-key': `render-not-started:${row.item_id}`,\n },\n body: JSON.stringify({\n user_id: row.data.owner,\n template: 'transactional',\n data: why === 'credits'\n ? { subject: 'Your render could not start', paragraph: `\"${name}\" was queued, but there were not enough credits when its turn came. Nothing was charged \u2014 top up and try again.` }\n : { subject: 'Your render could not start', paragraph: `\"${name}\" waited over an hour for a free slot and was cancelled. Nothing was charged \u2014 try again later.` },\n }),\n }).catch(() => undefined);\n return res?.ok ?? false;\n}\n",
18569
- "request-render.ts": "// request-render.ts \u2014 start ONE long render for the signed-in user (a vxil\n// function, http trigger, END-USER mode).\n//\n// POST /v1/fn/request-render (with the user's session)\n// { \"composition\": \"promo-30s\", \"request_key\": \"<your idempotency key>\", \"props\": { \u2026 } }\n// \u2192 202 { item_id, run_id, credits } a new render, credits held\n// \u2192 200 { duplicate: true, request_key, item_id, run_id, status }\n// this user's request_key already started one\n// (or its run has already ended: status 'failed')\n// \u2192 402 { error: 'insufficient_credits', item_id } nothing held; use a new request_key\n// \u2192 429 / 503 / 502 { error, queued: true, item_id, request_key, retry_after }\n// no run YET (429 at the cap, 503 credits\n// could not be held right now \u2014 nothing\n// held): the row is queued and\n// redrive-pending starts it (credits\n// held then). Never retry with a NEW\n// request_key \u2014 that is a second render.\n//\n// What happens after the 202 is vxil's and your runtime's, not this function's:\n// \u2022 the generation lane POSTs your render endpoint (secret render_url) with\n// `Authorization: Bearer <render_token>` and the JSON body\n// { render_id, user_id, composition, props,\n// payload: { generation_id, correlation_id, deadline_at }, callback_url }\n// (plus an X-Vxil-Jobs-Signature header). The endpoint must answer 2xx\n// within 20 s \u2014 queue the work, do not render inline. A 408 / 429 / 5xx or\n// a timeout is retried; any other 4xx ends the run and refunds the credits.\n// \u2022 your runtime POSTs JSON to callback_url: { \"status\": \"processing\", \u2026 }\n// while it works, then { \"status\": \"completed\", \"output_url\": \"\u2026\",\n// \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\" } \u2014 or\n// { \"status\": \"failed\", \"error\": \"render_failed\", \"hint\": \"\u2026\" }.\n// \u2022 vxil settles: completed COMMITS the held credits and writes every key of\n// the body onto this render's row; failed, no answer by the deadline, or a\n// cancel REFUNDS them and the row says failed. `deadline_at` (ISO time) is\n// when vxil stops waiting: a runtime that cannot finish by then should post\n// `failed` itself and stop \u2014 a `completed` after it changes nothing (the\n// credits are already back and the row says failed).\n// Every run ends with job.generation.completed | failed (generation_id = the\n// row's item_id, correlation_id = its request_key) \u2014 notify-ready listens.\n//\n// DELIVERY IS AT-LEAST-ONCE and users double-tap: the row's render_key\n// (owner + ':' + request_key \u2014 per user) is unique, so a second start with the\n// same key is a 409. On a 409 we read THIS user's row: a row that already has\n// its run is a duplicate; a row with no run (the first start died between the\n// row and the enqueue, or lost the enqueue's answer) is RE-DRIVEN \u2014 the run\n// carries the same render_key as its idempotency_key, so jobs hands back the\n// existing run instead of starting a second render.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Input = { composition?: string; request_key?: string; props?: Record<string, unknown> };\ntype Env = HttpFunctionEnvelope<Input>;\ntype RenderRow = {\n item_id: string;\n data: { composition?: string; props?: Record<string, unknown>; run_id?: string; status?: string };\n};\n\n/** What one render costs, in `render_credits`. Price by composition if yours\n * differ \u2014 the per-run hold is clamped to `generation.maxReserveCredits`. */\nconst RENDER_CREDITS = 5;\n/** The deadline: a render your runtime never reports on fails and refunds\n * after this long. At most one hour (`generation.maxTimeoutMs`). */\nconst RENDER_DEADLINE_MS = 1_800_000;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const jobs = env.scoped_jwts?.jobs;\n if (!cms || !jobs) return Response.json({ error: 'missing cms/jobs scope' }, { status: 403 });\n const renderUrl = env.secrets?.render_url;\n const renderToken = env.secrets?.render_token;\n if (!renderUrl || !renderToken) {\n return Response.json(\n { error: 'store your render endpoint: vxil secrets set functions/render_url and functions/render_token' },\n { status: 500 },\n );\n }\n // the held credits are FORCED onto the verified end-user\n const user = env.end_user?.id;\n if (!user) return Response.json({ error: \"invoke request-render with the user's session (end-user mode)\" }, { status: 401 });\n\n const composition = typeof env.payload?.composition === 'string' ? env.payload.composition.trim().slice(0, 120) : '';\n const requestKey = typeof env.payload?.request_key === 'string' ? env.payload.request_key.slice(0, 120) : '';\n const rawProps = env.payload?.props;\n const props = rawProps && typeof rawProps === 'object' && !Array.isArray(rawProps) ? rawProps : {};\n if (!composition || !requestKey) {\n return Response.json({ error: 'need { composition, request_key, props? }' }, { status: 422 });\n }\n if (JSON.stringify(props).length > 16_384) {\n return Response.json({ error: 'props too large (16 KB max) \u2014 pass a reference to your own storage instead' }, { status: 413 });\n }\n const renderKey = `${user}:${requestKey}`;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n // 1. create-or-find the row the app watches (owned by the user \u2014 cms forces\n // `owner` in end-user mode; the render_key_shape hook checks the key)\n const created = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: H,\n body: JSON.stringify({\n data: {\n render_key: renderKey, request_key: requestKey, owner: user, composition, props,\n credits: RENDER_CREDITS, status: 'pending', created_at: new Date().toISOString(),\n },\n }),\n });\n if (created.status === 409) {\n // THIS user's row for this key (the read is owner-scoped in end-user mode)\n const filter = encodeURIComponent(JSON.stringify({ render_key: renderKey }));\n const found = await fetch(`${base}/v1/cms/items/renders?filter=${filter}&limit=1`, { headers: H });\n const row = found.ok ? ((await found.json()) as { data?: { items?: RenderRow[] } }).data?.items?.[0] : undefined;\n if (!row) return Response.json({ error: `render lookup: ${found.status}` }, { status: 502 });\n if (row.data.run_id || row.data.status === 'failed') {\n return Response.json({\n duplicate: true, request_key: requestKey, item_id: row.item_id,\n run_id: row.data.run_id ?? null, status: row.data.status ?? 'pending',\n });\n }\n // a start that never got its run: re-drive it (jobs dedupes on render_key)\n return startRun({\n base, H, jobs, renderUrl, renderToken, user, renderKey, requestKey,\n itemId: row.item_id, composition: row.data.composition ?? composition, props: row.data.props ?? props,\n });\n }\n if (!created.ok) return Response.json({ error: `render row: ${created.status}` }, { status: 502 });\n const itemId = ((await created.json()) as { data: { item_id: string } }).data.item_id;\n return startRun({ base, H, jobs, renderUrl, renderToken, user, renderKey, requestKey, itemId, composition, props });\n },\n};\n\ninterface StartArgs {\n base: string; H: Record<string, string>; jobs: string; renderUrl: string; renderToken: string;\n user: string; renderKey: string; requestKey: string; itemId: string;\n composition: string; props: Record<string, unknown>;\n}\n\n/** 2. the webhook-mode generation run: your endpoint, the held credits, the\n * deadline and the status mirror. Idempotent on render_key: a re-drive gets\n * the run that already exists. */\nasync function startRun(a: StartArgs): Promise<Response> {\n const enq = await fetch(`${a.base}/v1/jobs/generation`, {\n method: 'POST',\n headers: { authorization: `Bearer ${a.jobs}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: 'render',\n provider: {\n url: a.renderUrl,\n method: 'POST',\n // stored with the run for your project only, shown as [redacted] on\n // every run read, sent to your endpoint on the start call\n headers: { authorization: `Bearer ${a.renderToken}` },\n // your endpoint receives this, plus `payload` and `callback_url`\n // (user_id: the owner a coordinator uploads the output file for)\n body: { render_id: a.itemId, user_id: a.user, composition: a.composition, props: a.props },\n },\n completion: { mode: 'webhook', status_path: 'status' },\n // the status word onto `status`, and a `processing` ping's progress /\n // stage / message onto the same-named fields of the row\n status_mirror: {\n feature: 'cms', collection: 'renders', record_id: a.itemId, column: 'status',\n progress_fields: ['progress', 'stage', 'message'],\n },\n reserve_credits: { amount: RENDER_CREDITS, user_id: a.user, credit_type: 'render_credits', reason: `render ${a.composition}` },\n timeout: { after_ms: RENDER_DEADLINE_MS },\n // rides job.generation.* as generation_id / correlation_id, and reaches\n // your endpoint beside callback_url. deadline_at = when vxil stops\n // waiting (the deadline counts from this enqueue; a re-drive gets the\n // first run back, with ITS payload).\n payload: {\n generation_id: a.itemId, correlation_id: a.requestKey,\n deadline_at: new Date(Date.now() + RENDER_DEADLINE_MS).toISOString(),\n },\n idempotency_key: a.renderKey,\n }),\n });\n if (enq.status === 402) {\n // not enough credits: the run already ENDED (job.generation.failed,\n // ReserveInsufficient) and nothing was held. This key is spent; a new\n // attempt (after a top-up) uses a new request_key.\n const marked = await patchRow(a, { status: 'failed', error: 'insufficient_credits' });\n // if that write failed the row still says pending with no run; a retry with\n // the same key re-drives, gets the ended run back and marks it failed then\n return Response.json(\n { error: 'insufficient_credits', item_id: a.itemId, ...(marked ? {} : { row_updated: false }) },\n { status: 402 },\n );\n }\n if (!enq.ok) {\n // 429 (too many in flight) / 503 payments_unavailable (the credits could\n // not be held right now \u2014 nothing was started or held; the same key starts\n // a fresh run later) / 5xx: no run YET \u2014 the row stays pending with\n // no run, and it is QUEUED: redrive-pending (cron) starts it once a slot\n // frees up (within the hour, or the row is failed and the owner told) and\n // holds the credits then. `queued: true` says so. The app must NOT retry\n // with a NEW request_key (that is a second render, charged twice): show\n // \"queued\", watch the row, and retry only with the SAME request_key.\n return Response.json(\n { error: `generation enqueue: ${enq.status}`, queued: true, item_id: a.itemId, request_key: a.requestKey, retry_after: enq.headers.get('retry-after') },\n { status: enq.status === 429 || enq.status === 503 ? enq.status : 502 },\n );\n }\n const run = ((await enq.json()) as { data: { run_id: string; generation_status?: string; deduplicated?: boolean } }).data;\n if (run.deduplicated && run.generation_status === 'failed') {\n // a re-drive whose run already ENDED (refused for credits, failed or timed\n // out, and the row write that said so was lost): record it and say so \u2014\n // never report a fresh render with credits held\n const marked = await patchRow(a, { run_id: run.run_id, status: 'failed' });\n return Response.json({\n duplicate: true, request_key: a.requestKey, item_id: a.itemId, run_id: run.run_id, status: 'failed',\n ...(marked ? {} : { row_updated: false }),\n });\n }\n const linked = await patchRow(a, { run_id: run.run_id });\n // the run exists either way (and settles the row through the status mirror);\n // an unlinked row is linked by the next same-key call\n return Response.json(\n { item_id: a.itemId, run_id: run.run_id, credits: RENDER_CREDITS, ...(linked ? {} : { row_updated: false }) },\n { status: 202 },\n );\n}\n\n/** PATCH this render's row; true when the write landed. */\nasync function patchRow(a: StartArgs, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${a.base}/v1/cms/items/renders/${encodeURIComponent(a.itemId)}`, {\n method: 'PATCH',\n headers: a.H,\n body: JSON.stringify({ data }),\n }).catch(() => undefined);\n return res?.ok ?? false;\n}\n"
19120
+ "redrive-pending.ts": "// redrive-pending.ts \u2014 the BACKLOG RE-DRIVER (a vxil function, cron trigger,\n// every minute; `overlap: 'skip'`).\n//\n// cron-walk: drains-filter \u2014 every row it starts is PATCHed with its run_id (and\n// every row it gives up on to status 'failed'), which takes it out of the\n// `{ status: 'pending', run_id: null }` read; a 429 stops the tick early.\n//\n// Why it exists: at the generation concurrency cap (`generation.maxConcurrent`\n// open runs \u2014 20 in this blueprint, up to 200) request-render answers 429 and\n// leaves the row `pending` with NO run. Without this function only the caller\n// re-drives it (the same request_key again). With it, a backlog drains on its\n// own: each tick reads the oldest pending rows that have had no run for at\n// least REDRIVE_AFTER_MS, and starts each one with the SAME descriptor and the\n// SAME idempotency key (the row's render_key) request-render uses \u2014 so a row a\n// user is re-driving at the same moment still gets exactly one run.\n//\n// per row, by the jobs answer:\n// 202 (new run, or an open one handed back) \u2192 PATCH run_id (the status\n// mirror settles the row from here)\n// 202 deduplicated, generation_status 'failed' \u2192 PATCH run_id + status 'failed'\n// 402 (too few credits) \u2192 PATCH status 'failed',\n// error 'insufficient_credits',\n// and TELL the owner (they last\n// heard \"queued\", not \"refused\")\n// 429 (cap reached / too many credits held), \u2192 STOP the tick; the rest wait\n// or 503 payments_unavailable (credits could for the next one (the wait\n// not be held: nothing started or held) header is reported; the same\n// key starts a fresh run then)\n// 409 generation_in_progress (another start \u2192 record the attempt, go on (the\n// of this row is still placing its hold) next tick gets its run back)\n// 422 idempotency_key_reused with a run_id \u2192 PATCH that run_id: a run of this\n// (the key's run was sent another body) row's key exists, whatever its body\n// 400 / 401 / 403 / 422 \u2192 STOP the tick, report it. Every\n// re-driven row sends the same\n// descriptor apart from its own\n// (already bounded) composition and\n// props, so a refusal is a SETUP\n// error \u2014 a non-https or private\n// render_url, a wrong or revoked\n// key, a config change \u2014 that\n// would fail every row the same\n// way. Nothing is failed for it:\n// fix the setup and the next tick\n// carries on.\n// 5xx / network \u2192 record the attempt, go on\n// A row still pending with no run after BACKLOG_MAX_AGE_MS is the ONLY thing\n// the re-driver gives up on: status 'failed', error 'not_started: \u2026' (nothing\n// was ever held for it), and the owner is told once\n// (Idempotency-Key render-not-started:<item_id>).\n// Every try is recorded on the row: redrive_attempts, redriven_at.\n//\n// THE SAME BODY, EVERY SEND: jobs hands an existing run back only to a re-send\n// of the SAME request (a canonical-JSON fingerprint \u2014 defaults applied, keys\n// sorted, provider/poll header VALUES left out \u2014 not byte-for-byte). A\n// different one will answer 422 idempotency_key_reused once that check is\n// enforced (today a mismatch is only logged, on every project). So the run's deadline_at is\n// the row's STORED `deadline_at` (request-render writes it with the row), never\n// recomputed here; a row with none (no run holds its key) gets a fresh one\n// stored BEFORE the send, and a 429 / 503 payments_unavailable (no run holds\n// the key) clears it again so the next try computes a fresh one.\n//\n// A HAND-WRITTEN ROW that breaks render_key_shape (written before the hook\n// existed, or by a path that bypassed it) cannot be patched either \u2014 the\n// hook judges the merged row \u2014 so it would stay pending and take one of the\n// tick's slots for good. Fix or delete such rows by hand; the tick reports\n// them as `unwritable`.\n\n// THE KEY: a function-originated credit hold must name the user it acts for,\n// and a cron tick has no signed-in user \u2014 so the jobs call is made with\n// `vxil_jobs_key`, an API key of this same backend holding ONLY `jobs:write`\n// (a trusted server key may hold credits for any of your users; README \"The\n// backlog\"). The rows are read and written with the function's own scoped cms\n// token. The credits held are this blueprint's fixed price, never the row's\n// `credits` field \u2014 a signed-in user can edit their own row, so nothing the\n// re-driver charges or starts is read from a value they could have lowered.\n// The owner is told with the function's scoped notifications token.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\n/** What one render costs \u2014 the SAME number as request-render's RENDER_CREDITS. */\nconst RENDER_CREDITS = 5;\n/** The deadline \u2014 the SAME number as request-render's RENDER_DEADLINE_MS. */\nconst RENDER_DEADLINE_MS = 1_800_000;\n/** A row is the re-driver's only once request-render has had time to start it. */\nconst REDRIVE_AFTER_MS = 30_000;\n/** Rows started per tick (bounded: a tick is one function invocation). */\nconst MAX_REDRIVE_PER_TICK = 20;\n/** A row still waiting for a run after this long is failed (nothing is held). */\nconst BACKLOG_MAX_AGE_MS = 3_600_000;\n\ntype Env = CronFunctionEnvelope;\ntype RenderRow = {\n item_id: string;\n created_at?: string;\n data: {\n render_key?: string; request_key?: string; owner?: string; composition?: string;\n props?: Record<string, unknown>; created_at?: string; redrive_attempts?: number;\n deadline_at?: string | null;\n };\n};\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notifications = env.scoped_jwts?.notifications;\n const jobsKey = env.secrets?.vxil_jobs_key;\n const renderUrl = env.secrets?.render_url;\n const renderToken = env.secrets?.render_token;\n if (!cms || !notifications) return Response.json({ error: 'missing cms/notifications scope' }, { status: 403 });\n if (!jobsKey || !renderUrl || !renderToken) {\n return Response.json(\n { error: 'store the secrets: vxil secrets set functions/vxil_jobs_key (an API key holding only jobs:write), functions/render_url, functions/render_token' },\n { status: 503 },\n );\n }\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = Date.now();\n\n // the oldest pending rows with no run: `status` (s4) and `created_at` (t1)\n // are index slots, so the slots pick the rows and the `run_id: null` test\n // only runs over what they picked\n const filter = encodeURIComponent(JSON.stringify({\n status: 'pending',\n created_at: { $lt: new Date(now - REDRIVE_AFTER_MS).toISOString() },\n run_id: null,\n }));\n const listed = await fetch(\n `${base}/v1/cms/items/renders?filter=${filter}&sort=created_at&limit=${MAX_REDRIVE_PER_TICK}`,\n { headers: H },\n );\n if (!listed.ok) return Response.json({ error: `renders read: ${listed.status}` }, { status: 502 });\n const rows = ((await listed.json()) as { data?: { items?: RenderRow[] } }).data?.items ?? [];\n\n const out = { scanned: rows.length, started: 0, failed: 0, retry_later: 0, given_up: 0, unwritable: 0,\n notified: 0, notify_failed: 0,\n stopped: null as null | { status: number; code: string | null; retry_after: string | null } };\n const tell = async (row: RenderRow, why: 'credits' | 'waited') => {\n if (await notifyNotStarted(base, notifications, row, why)) out.notified += 1;\n else out.notify_failed += 1;\n };\n\n for (const row of rows) {\n const d = row.data;\n const attempts = (typeof d.redrive_attempts === 'number' ? d.redrive_attempts : 0) + 1;\n const createdAt = Date.parse(d.created_at ?? row.created_at ?? '');\n const mark = { redrive_attempts: attempts, redriven_at: new Date(now).toISOString() };\n\n // a row that cannot be a render request-render made (a hand-written row\n // missing its key parts), or one that has waited too long: fail it \u2014 no\n // run exists, so nothing is held and nothing is refunded\n const shapeOk = !!d.owner && !!d.request_key && !!d.composition\n && d.render_key === `${d.owner}:${d.request_key}`\n && d.composition.length <= 120 && JSON.stringify(d.props ?? {}).length <= 16_384;\n if (!shapeOk || (Number.isFinite(createdAt) && now - createdAt > BACKLOG_MAX_AGE_MS)) {\n const error = shapeOk\n ? 'not_started: the render waited too long for a free slot'\n : 'not_started: the row is not a render request-render created';\n // only while it is STILL pending with no run (a racing start wins)\n if (await patchRow(base, H, row.item_id, { ...mark, status: 'failed', error }, { status: 'pending', run_id: null })) {\n out.given_up += 1;\n // the owner last heard \"queued\" (a 429): say it will not happen. A\n // malformed row's owner is not trusted, so it is only failed.\n if (shapeOk) await tell(row, 'waited');\n } else if (!shapeOk) {\n out.unwritable += 1; // header note: fix it by hand\n }\n continue;\n }\n\n // the row's STORED deadline \u2014 the body every earlier start sent. None\n // stored: store a fresh one first, so a lost answer is re-sent the same\n // (a row that will not take it waits for the next tick).\n let deadlineAt = d.deadline_at;\n if (!deadlineAt) {\n deadlineAt = new Date(now + RENDER_DEADLINE_MS).toISOString();\n // only while none is stored (a racing request-render may have just stored its own)\n if (!(await patchRow(base, H, row.item_id, { ...mark, deadline_at: deadlineAt }, { deadline_at: null }))) {\n out.retry_later += 1;\n continue;\n }\n }\n const enq = await startRun(base, jobsKey, renderUrl, renderToken, {\n itemId: row.item_id, user: d.owner!, renderKey: d.render_key!, requestKey: d.request_key!,\n composition: d.composition!, props: d.props ?? {}, deadlineAt,\n });\n if (!enq) { // network: try again next tick\n await patchRow(base, H, row.item_id, mark);\n out.retry_later += 1;\n continue;\n }\n if (enq.status === 402) {\n if (await patchRow(base, H, row.item_id, { ...mark, status: 'failed', error: 'insufficient_credits' })) await tell(row, 'credits');\n out.failed += 1;\n continue;\n }\n const failure = enq.status === 429 || (enq.status >= 400 && enq.status !== 402)\n ? ((await enq.json().catch(() => ({}))) as { error?: { code?: string; run_id?: string } }).error\n : undefined;\n const code = failure?.code ?? null;\n if (enq.status === 422 && code === 'idempotency_key_reused' && failure?.run_id) {\n // a run already holds this row's key under another body (a start that\n // raced a deadline reset, or a row from before the stored deadline):\n // it IS this row's render \u2014 link it, never fail the row for it\n await patchRow(base, H, row.item_id, { ...mark, run_id: failure.run_id });\n out.started += 1;\n continue;\n }\n if (enq.status === 409 && code === 'generation_in_progress') {\n // another start of this row is still placing its credit hold: its run\n // comes back to the next tick's same-body re-send\n await patchRow(base, H, row.item_id, mark);\n out.retry_later += 1;\n continue;\n }\n if (enq.status === 429 || code === 'payments_unavailable') {\n // no run holds this key: the next try computes a fresh deadline\n await patchRow(base, H, row.item_id, { ...mark, deadline_at: null });\n out.stopped = { status: enq.status, code: enq.status === 429 ? null : code, retry_after: enq.headers.get('retry-after') };\n break;\n }\n if (enq.status >= 400 && enq.status < 500) {\n // 429: at the cap \u2014 the rest of the batch would get the same answer.\n // 503 payments_unavailable: the credits could not be held right now\n // (nothing was started or held) \u2014 the rest would get the same answer;\n // the next tick re-sends the SAME key and gets a fresh run.\n // 400 / 401 / 403 / 422: a setup error (header note) \u2014 failing rows for\n // it would be wrong and could not be undone. Either way: record the\n // try, stop, and let the next tick (Retry-After: seconds) go on.\n await patchRow(base, H, row.item_id, mark);\n out.stopped = { status: enq.status, code: enq.status === 429 ? null : code, retry_after: enq.headers.get('retry-after') };\n break;\n }\n if (!enq.ok) { // 5xx: try again next tick\n await patchRow(base, H, row.item_id, mark);\n out.retry_later += 1;\n continue;\n }\n const run = ((await enq.json()) as { data: { run_id: string; generation_status?: string; deduplicated?: boolean } }).data;\n if (run.deduplicated && run.generation_status === 'failed') {\n // the run already ENDED (its row write was lost): record it as failed\n await patchRow(base, H, row.item_id, { ...mark, run_id: run.run_id, status: 'failed' });\n out.failed += 1;\n continue;\n }\n await patchRow(base, H, row.item_id, { ...mark, run_id: run.run_id });\n out.started += 1;\n }\n // a 2xx either way: a cron tick is never retried (the next tick is the retry)\n return Response.json(out);\n },\n};\n\ninterface Start {\n itemId: string; user: string; renderKey: string; requestKey: string;\n composition: string; props: Record<string, unknown>;\n /** the row's stored deadline (ISO) \u2014 never recomputed per send */\n deadlineAt: string;\n}\n\n/** The SAME webhook-mode generation request-render starts (the CI gate keeps\n * the two descriptors equal), sent with the jobs:write server key. Null on a\n * network error. */\nasync function startRun(base: string, key: string, renderUrl: string, renderToken: string, a: Start): Promise<Response | null> {\n return fetch(`${base}/v1/jobs/generation`, {\n // tenant-key: jobs:write via secret:vxil_jobs_key \u2014 called with the server\n // key, not the function's scoped token (header note); the CI gate checks\n // the secret is declared instead of a jobs scope\n method: 'POST',\n headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: 'render',\n provider: {\n url: renderUrl,\n method: 'POST',\n headers: { authorization: `Bearer ${renderToken}` },\n body: { render_id: a.itemId, user_id: a.user, composition: a.composition, props: a.props },\n },\n completion: { mode: 'webhook', status_path: 'status' },\n status_mirror: {\n feature: 'cms', collection: 'renders', record_id: a.itemId, column: 'status',\n progress_fields: ['progress', 'stage', 'message'],\n },\n reserve_credits: { amount: RENDER_CREDITS, user_id: a.user, credit_type: 'render_credits', reason: `render ${a.composition}` },\n timeout: { after_ms: RENDER_DEADLINE_MS },\n payload: { generation_id: a.itemId, correlation_id: a.requestKey, deadline_at: a.deadlineAt },\n idempotency_key: a.renderKey,\n }),\n }).catch(() => null);\n}\n\n/** PATCH a render row (optionally only while `when` still holds); true when it landed. */\nasync function patchRow(\n base: string, H: Record<string, string>, itemId: string,\n data: Record<string, unknown>, when?: Record<string, unknown>,\n): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify(when ? { data, if: when } : { data }),\n }).catch(() => undefined);\n return res?.ok ?? false;\n}\n\n/** Tell the owner a render they were told was queued will not start. One\n * message per render (Idempotency-Key render-not-started:<item_id> \u2014 the SAME\n * key notify-ready uses for a re-driven credit refusal, so the two never both\n * send). True when it was accepted. */\nasync function notifyNotStarted(base: string, token: string, row: RenderRow, why: 'credits' | 'waited'): Promise<boolean> {\n const name = row.data.composition ?? 'Your render';\n const res = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${token}`, 'content-type': 'application/json',\n 'idempotency-key': `render-not-started:${row.item_id}`,\n },\n body: JSON.stringify({\n user_id: row.data.owner,\n template: 'transactional',\n data: why === 'credits'\n ? { subject: 'Your render could not start', paragraph: `\"${name}\" was queued, but there were not enough credits when its turn came. Nothing was charged \u2014 top up and try again.` }\n : { subject: 'Your render could not start', paragraph: `\"${name}\" waited over an hour for a free slot and was cancelled. Nothing was charged \u2014 try again later.` },\n }),\n }).catch(() => undefined);\n return res?.ok ?? false;\n}\n",
19121
+ "request-render.ts": "// request-render.ts \u2014 start ONE long render for the signed-in user (a vxil\n// function, http trigger, END-USER mode).\n//\n// POST /v1/fn/request-render (with the user's session)\n// { \"composition\": \"promo-30s\", \"request_key\": \"<your idempotency key>\", \"props\": { \u2026 } }\n// \u2192 202 { item_id, run_id, credits } a new render, credits held\n// \u2192 200 { duplicate: true, request_key, item_id, run_id, status }\n// this user's request_key already started one\n// (or its run has already ended: status 'failed')\n// \u2192 402 { error: 'insufficient_credits', item_id } nothing held; use a new request_key\n// \u2192 429 / 503 / 502 { error, queued: true, item_id, request_key, retry_after }\n// no run YET (429 at the cap, 503 credits\n// could not be held right now \u2014 nothing\n// held): the row is queued and\n// redrive-pending starts it (credits\n// held then). Never retry with a NEW\n// request_key \u2014 that is a second render.\n//\n// What happens after the 202 is vxil's and your runtime's, not this function's:\n// \u2022 the generation lane POSTs your render endpoint (secret render_url) with\n// `Authorization: Bearer <render_token>` and the JSON body\n// { render_id, user_id, composition, props,\n// payload: { generation_id, correlation_id, deadline_at }, callback_url }\n// (plus an X-Vxil-Jobs-Signature header). The endpoint must answer 2xx\n// within 20 s \u2014 queue the work, do not render inline. A 408 / 429 / 5xx or\n// a timeout is retried; any other 4xx ends the run and refunds the credits.\n// \u2022 your runtime POSTs JSON to callback_url: { \"status\": \"processing\", \u2026 }\n// while it works, then { \"status\": \"completed\", \"output_url\": \"\u2026\",\n// \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\" } \u2014 or\n// { \"status\": \"failed\", \"error\": \"render_failed\", \"hint\": \"\u2026\" }.\n// \u2022 vxil settles: completed COMMITS the held credits and writes every key of\n// the body onto this render's row; failed, no answer by the deadline, or a\n// cancel REFUNDS them and the row says failed. `deadline_at` (ISO time) is\n// the row's stored deadline \u2014 never later than vxil's own stop time (the\n// run's timeout counted from that run's enqueue), and on a re-driven row\n// possibly earlier, even already past: a runtime that cannot finish by then should post\n// `failed` itself and stop \u2014 a `completed` after it changes nothing (the\n// credits are already back and the row says failed).\n// Every run ends with job.generation.completed | failed (generation_id = the\n// row's item_id, correlation_id = its request_key) \u2014 notify-ready listens.\n//\n// DELIVERY IS AT-LEAST-ONCE and users double-tap: the row's render_key\n// (owner + ':' + request_key \u2014 per user) is unique, so a second start with the\n// same key is a 409. On a 409 we read THIS user's row: a row that already has\n// its run is a duplicate; a row with no run (the first start died between the\n// row and the enqueue, or lost the enqueue's answer) is RE-DRIVEN \u2014 the run\n// carries the same render_key as its idempotency_key, so jobs hands back the\n// existing run instead of starting a second render.\n//\n// THE BODY IS PART OF THE KEY: jobs hands the existing run back only to a\n// re-send of the SAME request \u2014 compared as a canonical-JSON fingerprint\n// (defaults applied, keys sorted, provider/poll header VALUES left out), not\n// byte-for-byte. A different body under a used key will answer 422\n// idempotency_key_reused (being rolled out: today a mismatch is only logged,\n// on every project, and still handed the first run). So nothing in the descriptor may be rebuilt per send:\n// the run's `deadline_at` is computed ONCE and kept on the row (`deadline_at`),\n// and every start of this row \u2014 this function's re-drive and redrive-pending's\n// \u2014 sends the stored value. It is cleared (null) only when jobs says no run\n// holds the key (429, or 503 payments_unavailable), so the next start computes\n// a fresh one.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Input = { composition?: string; request_key?: string; props?: Record<string, unknown> };\ntype Env = HttpFunctionEnvelope<Input>;\ntype RenderRow = {\n item_id: string;\n data: {\n composition?: string; props?: Record<string, unknown>; run_id?: string; status?: string;\n deadline_at?: string | null;\n };\n};\n\n/** What one render costs, in `render_credits`. Price by composition if yours\n * differ \u2014 the per-run hold is clamped to `generation.maxReserveCredits`. */\nconst RENDER_CREDITS = 5;\n/** The deadline: a render your runtime never reports on fails and refunds\n * after this long. At most one hour (`generation.maxTimeoutMs`). */\nconst RENDER_DEADLINE_MS = 1_800_000;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const jobs = env.scoped_jwts?.jobs;\n if (!cms || !jobs) return Response.json({ error: 'missing cms/jobs scope' }, { status: 403 });\n const renderUrl = env.secrets?.render_url;\n const renderToken = env.secrets?.render_token;\n if (!renderUrl || !renderToken) {\n return Response.json(\n { error: 'store your render endpoint: vxil secrets set functions/render_url and functions/render_token' },\n { status: 500 },\n );\n }\n // the held credits are FORCED onto the verified end-user\n const user = env.end_user?.id;\n if (!user) return Response.json({ error: \"invoke request-render with the user's session (end-user mode)\" }, { status: 401 });\n\n const composition = typeof env.payload?.composition === 'string' ? env.payload.composition.trim().slice(0, 120) : '';\n const requestKey = typeof env.payload?.request_key === 'string' ? env.payload.request_key.slice(0, 120) : '';\n const rawProps = env.payload?.props;\n const props = rawProps && typeof rawProps === 'object' && !Array.isArray(rawProps) ? rawProps : {};\n if (!composition || !requestKey) {\n return Response.json({ error: 'need { composition, request_key, props? }' }, { status: 422 });\n }\n if (JSON.stringify(props).length > 16_384) {\n return Response.json({ error: 'props too large (16 KB max) \u2014 pass a reference to your own storage instead' }, { status: 413 });\n }\n const renderKey = `${user}:${requestKey}`;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = Date.now();\n // computed ONCE, stored on the row, re-sent unchanged by every start of it\n const deadlineAt = new Date(now + RENDER_DEADLINE_MS).toISOString();\n\n // 1. create-or-find the row the app watches (owned by the user \u2014 cms forces\n // `owner` in end-user mode; the render_key_shape hook checks the key)\n const created = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: H,\n body: JSON.stringify({\n data: {\n render_key: renderKey, request_key: requestKey, owner: user, composition, props,\n credits: RENDER_CREDITS, status: 'pending', created_at: new Date(now).toISOString(),\n deadline_at: deadlineAt,\n },\n }),\n });\n if (created.status === 409) {\n // THIS user's row for this key (the read is owner-scoped in end-user mode)\n const filter = encodeURIComponent(JSON.stringify({ render_key: renderKey }));\n const found = await fetch(`${base}/v1/cms/items/renders?filter=${filter}&limit=1`, { headers: H });\n const row = found.ok ? ((await found.json()) as { data?: { items?: RenderRow[] } }).data?.items?.[0] : undefined;\n if (!row) return Response.json({ error: `render lookup: ${found.status}` }, { status: 502 });\n if (row.data.run_id || row.data.status === 'failed') {\n return Response.json({\n duplicate: true, request_key: requestKey, item_id: row.item_id,\n run_id: row.data.run_id ?? null, status: row.data.status ?? 'pending',\n });\n }\n // a start that never got its run: re-drive it (jobs dedupes on render_key)\n // with the row's STORED deadline \u2014 the same body the first start sent.\n // None stored (no run holds the key, or a row from before this field):\n // store a fresh one BEFORE sending, so a lost answer is re-sent the same.\n const a: StartArgs = {\n base, H, jobs, renderUrl, renderToken, user, renderKey, requestKey,\n itemId: row.item_id, composition: row.data.composition ?? composition, props: row.data.props ?? props,\n deadlineAt: row.data.deadline_at ?? deadlineAt,\n };\n // (only while none is stored \u2014 a racing redrive-pending may have stored its own)\n if (!row.data.deadline_at && !(await patchRow(a, { deadline_at: a.deadlineAt }, { deadline_at: null }))) {\n return Response.json(\n { error: 'render row: could not store the deadline', queued: true, item_id: row.item_id, request_key: requestKey, retry_after: null },\n { status: 502 },\n );\n }\n return startRun(a);\n }\n if (!created.ok) return Response.json({ error: `render row: ${created.status}` }, { status: 502 });\n const itemId = ((await created.json()) as { data: { item_id: string } }).data.item_id;\n return startRun({ base, H, jobs, renderUrl, renderToken, user, renderKey, requestKey, itemId, composition, props, deadlineAt });\n },\n};\n\ninterface StartArgs {\n base: string; H: Record<string, string>; jobs: string; renderUrl: string; renderToken: string;\n user: string; renderKey: string; requestKey: string; itemId: string;\n composition: string; props: Record<string, unknown>;\n /** the row's stored deadline (ISO) \u2014 never recomputed per send */\n deadlineAt: string;\n}\n\n/** 2. the webhook-mode generation run: your endpoint, the held credits, the\n * deadline and the status mirror. Idempotent on render_key: a re-drive gets\n * the run that already exists. */\nasync function startRun(a: StartArgs): Promise<Response> {\n const enq = await fetch(`${a.base}/v1/jobs/generation`, {\n method: 'POST',\n headers: { authorization: `Bearer ${a.jobs}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: 'render',\n provider: {\n url: a.renderUrl,\n method: 'POST',\n // stored with the run for your project only, shown as [redacted] on\n // every run read, sent to your endpoint on the start call\n headers: { authorization: `Bearer ${a.renderToken}` },\n // your endpoint receives this, plus `payload` and `callback_url`\n // (user_id: the owner a coordinator uploads the output file for)\n body: { render_id: a.itemId, user_id: a.user, composition: a.composition, props: a.props },\n },\n completion: { mode: 'webhook', status_path: 'status' },\n // the status word onto `status`, and a `processing` ping's progress /\n // stage / message onto the same-named fields of the row\n status_mirror: {\n feature: 'cms', collection: 'renders', record_id: a.itemId, column: 'status',\n progress_fields: ['progress', 'stage', 'message'],\n },\n reserve_credits: { amount: RENDER_CREDITS, user_id: a.user, credit_type: 'render_credits', reason: `render ${a.composition}` },\n timeout: { after_ms: RENDER_DEADLINE_MS },\n // rides job.generation.* as generation_id / correlation_id, and reaches\n // your endpoint beside callback_url. deadline_at = the row's STORED\n // value (computed once \u2014 a value rebuilt per send would make every\n // re-drive a different request, which will answer 422\n // idempotency_key_reused instead of the run that exists). It can be\n // earlier than vxil's real stop time on a re-driven row, never later.\n payload: { generation_id: a.itemId, correlation_id: a.requestKey, deadline_at: a.deadlineAt },\n idempotency_key: a.renderKey,\n }),\n });\n if (enq.status === 402) {\n // not enough credits: the run already ENDED (job.generation.failed,\n // ReserveInsufficient) and nothing was held. This key is spent; a new\n // attempt (after a top-up) uses a new request_key.\n const marked = await patchRow(a, { status: 'failed', error: 'insufficient_credits' });\n // if that write failed the row still says pending with no run; a retry with\n // the same key re-drives, gets the ended run back and marks it failed then\n return Response.json(\n { error: 'insufficient_credits', item_id: a.itemId, ...(marked ? {} : { row_updated: false }) },\n { status: 402 },\n );\n }\n if (enq.status === 422) {\n // idempotency_key_reused naming THIS user's run: a run of this row's key\n // exists under another body (a start that raced a deadline reset, or a row\n // from before the stored deadline) \u2014 it IS this render: link it\n const e = ((await enq.clone().json().catch(() => ({}))) as { error?: { code?: string; run_id?: string } }).error;\n if (e?.code === 'idempotency_key_reused' && e.run_id) {\n const linked = await patchRow(a, { run_id: e.run_id });\n return Response.json({\n duplicate: true, request_key: a.requestKey, item_id: a.itemId, run_id: e.run_id, status: 'pending',\n ...(linked ? {} : { row_updated: false }),\n });\n }\n }\n if (!enq.ok) {\n // 429 (too many in flight) / 503 payments_unavailable (the credits could\n // not be held right now \u2014 nothing was started or held; the same key starts\n // a fresh run later) / 5xx: no run YET \u2014 the row stays pending with\n // no run, and it is QUEUED: redrive-pending (cron) starts it once a slot\n // frees up (within the hour, or the row is failed and the owner told) and\n // holds the credits then. `queued: true` says so. The app must NOT retry\n // with a NEW request_key (that is a second render, charged twice): show\n // \"queued\", watch the row, and retry only with the SAME request_key.\n // 429 / 503 payments_unavailable: jobs holds NO run under this key \u2014 clear\n // the stored deadline so the next start computes a fresh one (any other\n // answer may have left a run behind: keep it, so a re-send matches).\n const code = enq.status === 503\n ? ((await enq.clone().json().catch(() => ({}))) as { error?: { code?: string } }).error?.code\n : undefined;\n if (enq.status === 429 || code === 'payments_unavailable') await patchRow(a, { deadline_at: null });\n return Response.json(\n { error: `generation enqueue: ${enq.status}`, queued: true, item_id: a.itemId, request_key: a.requestKey, retry_after: enq.headers.get('retry-after') },\n { status: enq.status === 429 || enq.status === 503 ? enq.status : 502 },\n );\n }\n const run = ((await enq.json()) as { data: { run_id: string; generation_status?: string; deduplicated?: boolean } }).data;\n if (run.deduplicated && run.generation_status === 'failed') {\n // a re-drive whose run already ENDED (refused for credits, failed or timed\n // out, and the row write that said so was lost): record it and say so \u2014\n // never report a fresh render with credits held\n const marked = await patchRow(a, { run_id: run.run_id, status: 'failed' });\n return Response.json({\n duplicate: true, request_key: a.requestKey, item_id: a.itemId, run_id: run.run_id, status: 'failed',\n ...(marked ? {} : { row_updated: false }),\n });\n }\n const linked = await patchRow(a, { run_id: run.run_id });\n // the run exists either way (and settles the row through the status mirror);\n // an unlinked row is linked by the next same-key call\n return Response.json(\n { item_id: a.itemId, run_id: run.run_id, credits: RENDER_CREDITS, ...(linked ? {} : { row_updated: false }) },\n { status: 202 },\n );\n}\n\n/** PATCH this render's row (optionally only while `when` still holds); true when the write landed. */\nasync function patchRow(a: StartArgs, data: Record<string, unknown>, when?: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${a.base}/v1/cms/items/renders/${encodeURIComponent(a.itemId)}`, {\n method: 'PATCH',\n headers: a.H,\n body: JSON.stringify(when ? { data, if: when } : { data }),\n }).catch(() => undefined);\n return res?.ok ?? false;\n}\n"
18570
19122
  }
18571
19123
  },
18572
19124
  {
@@ -20525,6 +21077,18 @@ async function runApply(apply, sel = "prod", opts = {}) {
20525
21077
  console.error(`vxil: warning \u2014 ${msg}`);
20526
21078
  }
20527
21079
  }
21080
+ {
21081
+ const typed = hookTypePreflight(cfg);
21082
+ for (const w of typed.warnings) console.error(`vxil: warning \u2014 cms ${w}`);
21083
+ if (typed.errors.length) {
21084
+ const msg = `cms hooks that can never work on the declared fields:
21085
+ ${typed.errors.join("\n ")}`;
21086
+ if (apply) fail(`${msg}
21087
+ (nothing was pushed)`);
21088
+ console.error(`vxil: ${msg}`);
21089
+ if (gate) report.errors++;
21090
+ }
21091
+ }
20528
21092
  const notCompared = (u, hint) => {
20529
21093
  console.log(formatNotCompared(u, hint));
20530
21094
  if (gate) report.errors++;
@@ -20921,6 +21485,15 @@ ${formatTriggerWiring(w).join("\n")}`);
20921
21485
  if (hasFlag("skip-functions") && declaredFns && Object.keys(declaredFns).length) {
20922
21486
  say(`functions: skipped (--skip-functions) \u2014 ${Object.keys(declaredFns).length} declared function(s) not deployed`);
20923
21487
  }
21488
+ {
21489
+ const typed = hookTypePreflight(cfg);
21490
+ for (const w of typed.warnings) say(`vxil: warning \u2014 cms ${w}`);
21491
+ if (typed.errors.length) {
21492
+ fail(`cms hooks that can never work on the declared fields:
21493
+ ${typed.errors.join("\n ")}
21494
+ (nothing was applied)`);
21495
+ }
21496
+ }
20924
21497
  if (fnNames.length) {
20925
21498
  const ent = await probeFunctionsEntitlement(api, fnNames[0]);
20926
21499
  if (!ent.entitled) {
@@ -23428,18 +24001,26 @@ async function runFunctionsDev(name) {
23428
24001
  console.error(`vxil: no dev tenant bound \u2014 the local function's feature calls will hit the PRIMARY binding '${t.tenantSlug ?? "(env key)"}' (${t.envLabel}); bind one with \`vxil dev up\` or pass --target <name>`);
23429
24002
  }
23430
24003
  announceTarget(t, `functions dev ${name}`);
23431
- const prodGuard = productionLaneGuard({
24004
+ const liveBanner = liveWritesBanner({
24005
+ devSlot: isDevSlot(t),
24006
+ tenant: t.tenantSlug ?? "(env key)",
24007
+ envLabel: t.envLabel,
24008
+ color: Boolean(process.stderr.isTTY) && !process.env.NO_COLOR
24009
+ });
24010
+ if (liveBanner && (lane !== "http" || oneShot !== void 0)) console.error(liveBanner);
24011
+ const liveGuard = liveLaneGuard({
23432
24012
  lane,
23433
24013
  oneShot: oneShot !== void 0,
23434
24014
  explicitSelector: explicit,
23435
24015
  production: isProductionTarget(t),
24016
+ devSlot: isDevSlot(t),
23436
24017
  tenant: t.tenantSlug ?? "(env key)",
23437
24018
  fnName: name
23438
24019
  });
23439
- if (prodGuard) {
24020
+ if (liveGuard) {
23440
24021
  await confirmTyped(
23441
- prodGuard,
23442
- `vxil: ${prodGuard}.
24022
+ liveGuard,
24023
+ `vxil: ${liveGuard}.
23443
24024
  Type ${t.tenantSlug ? `the tenant slug '${t.tenantSlug}'` : "'yes'"} to run it anyway: `,
23444
24025
  t.tenantSlug ?? "yes"
23445
24026
  );