@vxil/cli 0.14.0 → 0.15.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.
@@ -6,9 +6,12 @@ export * from './_vxil-feature-configs-canonicalJson.js';
6
6
  export * from './_vxil-feature-configs-publicAssets.js';
7
7
  export declare const RESERVED_CREDIT_TYPES: ReadonlySet<string>;
8
8
  /** A function binding's `retry.maxAttempts` ceiling, and the
9
- * binding kinds that may carry `retry` — the platform-delivered event lanes
10
- * (an http invoke returns its own status; a cron tick's retry would overlap
11
- * the next tick). Read by the schema, the deploy clamp and the CLI. */
9
+ * binding kinds that may carry `retry` — every lane the platform delivers
10
+ * through jobs. Since 2026-10-03 (JT-9) that includes `cron` (a failed tick is
11
+ * re-delivered on the ladder; pair it with `overlap: 'skip'` so a retrying
12
+ * tick never runs beside the next one) and `http` (the ASYNC lane only — a
13
+ * synchronous invoke always hands its caller the function's own status).
14
+ * Read by the schema, the deploy clamp and the CLI. */
12
15
  export declare const FN_RETRY_MAX_ATTEMPTS = 5;
13
16
  export declare const FN_RETRY_BINDING_KINDS: ReadonlySet<string>;
14
17
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
package/dist/config.d.ts CHANGED
@@ -129,14 +129,22 @@ export type AuthHookEvent = 'user.created' | 'session.created' | 'session.revoke
129
129
  * jobs retry ladder instead (60 s, 120 s, … backoff) and the last failed
130
130
  * attempt dead-letters the run (`job.dead_lettered`, replayable from the
131
131
  * Jobs page). Each attempt is a billed invocation and carries the SAME
132
- * `idempotency_key` — dedupe on it. Effective attempts =
133
- * min(maxAttempts, the project's `jobs.retry.defaultMaxAttempts`, default 5).
132
+ * `idempotency_key` — dedupe on it. Effective attempts on queue / webhook /
133
+ * cmsHook / authHook = min(maxAttempts, the project's
134
+ * `jobs.retry.defaultMaxAttempts`, default 5); on `cron` and the async `http`
135
+ * lane the binding's maxAttempts IS the budget (it becomes the schedule's /
136
+ * the async run's own max_attempts), whatever the project default.
134
137
  * A handler that partially writes and then fails re-fires its downstream
135
138
  * events on EVERY attempt, so the chain is a tree (up to maxAttempts children
136
139
  * per hop): the causal-depth guard bounds its length (32 hops), not its size —
137
140
  * the breaker, the loop guard, the dead-letter quota and the daily share do.
138
- * Write only once you will return 2xx, or dedupe on idempotency_key. Not accepted on `http` (it returns its
139
- * real status to its caller) or `cron` (a retry would overlap the next tick). */
141
+ * Write only once you will return 2xx, or dedupe on idempotency_key.
142
+ * On `cron` (2026-10-03) a failed tick is re-delivered the same way — set
143
+ * `overlap: 'skip'` beside it so a tick that is still retrying never runs
144
+ * next to the following one. On `http` it applies to the ASYNC lane only
145
+ * (`X-Vxil-Async: 1`): a 408 / 429 / 5xx answer is re-delivered up to
146
+ * maxAttempts; a synchronous invoke always returns the function's own status
147
+ * to its caller and is never retried. */
140
148
  export interface FunctionTriggerRetry {
141
149
  /** 1–5 attempts in all (1 = a failed attempt dead-letters at once, no re-delivery) */
142
150
  maxAttempts: number;
@@ -147,6 +155,7 @@ export interface FunctionTriggerRetry {
147
155
  export type FunctionTrigger = {
148
156
  kind: 'http';
149
157
  path?: string;
158
+ retry?: FunctionTriggerRetry;
150
159
  }
151
160
  /** `overlap: 'skip'` (2026-10-01): a due tick fires nothing while the
152
161
  * previous tick's run is still open (queued / running / retrying / waiting /
@@ -156,6 +165,7 @@ export type FunctionTrigger = {
156
165
  kind: 'cron';
157
166
  schedule: string;
158
167
  overlap?: 'allow' | 'skip';
168
+ retry?: FunctionTriggerRetry;
159
169
  } | {
160
170
  kind: 'queue';
161
171
  source: string;
package/dist/vxil.js CHANGED
@@ -3126,7 +3126,25 @@ var JobsConfigSchema = Type.Object({
3126
3126
  { default: {} }
3127
3127
  ),
3128
3128
  concurrency: Type.Object(
3129
- { maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) },
3129
+ /** Per-tenant cap on PLAIN runs (queue / cron / event deliveries,
3130
+ * incl. platform-delivered function triggers) in flight at once; generation
3131
+ * runs have their own `generation.maxConcurrent`. EFFECTIVE value =
3132
+ * min(this, the tenant's fair share of the shared delivery consumer): the
3133
+ * platform runs at most 75 queue-lane deliveries in flight for ALL tenants
3134
+ * together, and one tenant may hold at most 25 of them (jobs-v1 core.ts
3135
+ * TENANT_FAIR_SHARE). Values 26..100 still validate (existing configs keep
3136
+ * working) but buy nothing past the share. Over-cap runs wait (deferred,
3137
+ * never rejected). The schema `description` below is the tenant-facing
3138
+ * copy of this (planner catalog, generated config schema) — keep both in
3139
+ * step. */
3140
+ {
3141
+ maxConcurrent: Type.Integer({
3142
+ default: 10,
3143
+ minimum: 1,
3144
+ maximum: 100,
3145
+ description: "Plain runs (queued jobs, cron fires, event deliveries, platform-delivered function triggers) delivered at once; generation runs have their own generation.maxConcurrent. Effective at most 25 (the per-project share of the shared delivery capacity): 26..100 validate but buy nothing more. Over-cap runs wait, never rejected."
3146
+ })
3147
+ },
3130
3148
  { default: {} }
3131
3149
  ),
3132
3150
  // Generation lifecycle knobs (guide ch. 6, jobs). The defaults and bounds are
@@ -4282,8 +4300,8 @@ var FunctionsConfigSchema = Type.Object({
4282
4300
  event: Type.Optional(Type.String()),
4283
4301
  // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
4284
4302
  // (2026-09-25) the per-binding opt-in to re-delivery on
4285
- // queue / webhook / cmsHook / authHook (the cross-field rule
4286
- // rejects it on http / cron). Absent = the ACK-200 default. The
4303
+ // queue / webhook / cmsHook / authHook, and since 2026-10-03 on
4304
+ // cron and http (the async lane only). Absent = the ACK-200 default. The
4287
4305
  // receiver answers a failed attempt as an enveloped 503 (ladder)
4288
4306
  // and the last one as a terminal 409 (dead + job.dead_lettered);
4289
4307
  // effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
@@ -4520,9 +4538,9 @@ function lowerAllTriggerBindings(def, name) {
4520
4538
  return bindings;
4521
4539
  }
4522
4540
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
4523
- var VXIL_CONFIG_PKG_VERSION = "0.9.0";
4524
- var VXIL_SDK_PKG_VERSION = "0.14.0";
4525
- var VXIL_CLI_PKG_VERSION = "0.14.0";
4541
+ var VXIL_CONFIG_PKG_VERSION = "0.10.0";
4542
+ var VXIL_SDK_PKG_VERSION = "0.15.0";
4543
+ var VXIL_CLI_PKG_VERSION = "0.15.0";
4526
4544
  function ensureScaffoldPackageJson(cwd, opts = {}) {
4527
4545
  const file = resolve(cwd, "package.json");
4528
4546
  const wanted = {
@@ -10642,7 +10660,10 @@ function webhookInner(input) {
10642
10660
  }
10643
10661
  function planLocalDelivery(opts) {
10644
10662
  const { lane, input, bindings, fnName } = opts;
10645
- if (lane === "http") return { ok: true, lane, payload: input };
10663
+ if (lane === "http") {
10664
+ const hb = bindings.find((b3) => b3.kind === "http");
10665
+ return { ok: true, lane, payload: input, ...hb ? { binding: hb } : {} };
10666
+ }
10646
10667
  const refused = laneRefusal(lane, bindings, fnName);
10647
10668
  if (refused) return { ok: false, refusal: refused };
10648
10669
  if (lane === "queue" || lane === "cron") {
@@ -10705,11 +10726,13 @@ function planLocalDelivery(opts) {
10705
10726
  }
10706
10727
  function deployedVerdict(lane, binding, timeoutMs) {
10707
10728
  if (lane === "http") {
10708
- return ["deployed: the sync lane hands your caller this status and body as-is; the async lane records a non-2xx as a dead run"];
10729
+ const r = retryFor(binding, 1);
10730
+ return [r ? `deployed: the sync lane hands your caller this status and body as-is (never retried); the async lane re-delivers a 408/429/5xx up to ${r.maxAttempts} attempts, then records it as a dead run` : "deployed: the sync lane hands your caller this status and body as-is; the async lane records a non-2xx as a dead run"];
10709
10731
  }
10710
10732
  const out = [];
10711
10733
  if (lane === "cron") {
10712
- out.push("deployed: ACKs 200 whatever this returns \u2014 a cron tick is never retried; the first failure after a clean run emits functions.run.failed");
10734
+ const r = retryFor(binding, 1);
10735
+ out.push(r ? `deployed: retry on \u2014 a non-2xx tick is attempt 1 of ${r.maxAttempts}, re-delivered on the jobs ladder with the same idempotency_key (pair it with overlap: 'skip')` : "deployed: ACKs 200 whatever this returns \u2014 the cron binding declares no retry, so a failed tick is not redelivered; the first failure after a clean run emits functions.run.failed");
10713
10736
  } else {
10714
10737
  const r = retryFor(binding, 1);
10715
10738
  out.push(r ? `deployed: retry on \u2014 a non-2xx is attempt 1 of ${r.maxAttempts} (never more than the project's jobs.retry.defaultMaxAttempts); every attempt carries the same idempotency_key` : "deployed: ACKs 200 whatever this returns \u2014 the binding declares no retry, so a non-2xx is not redelivered");
@@ -11433,7 +11456,7 @@ var TOOLS = [
11433
11456
  {
11434
11457
  name: "jobs_enqueue",
11435
11458
  feature: "jobs",
11436
- description: "Enqueue a background job: Vxil POSTs a signed callback to your https target_url with retries/backoff until 2xx. Verify X-Vxil-Jobs-Signature against jobs_get_signing_secret. idempotency_key dedups for 24h. deliver_after (ISO) / delay_seconds (\u2264 30 d, at most one) defer the first delivery; > 12 h returns state 'delayed'. callback: true (or { ttl_seconds }) returns a single-use keyless callback_url (also on every delivery) that an external worker POSTs with { status: completed, result } / { status: failed, error } / { status: processing, progress, stage, message } \u2014 a handler answering 202 hands the run off until that callback arrives; the result is stored on the run (jobs_get_run).",
11459
+ description: "Enqueue a background job: Vxil POSTs a signed callback to your https target_url with retries/backoff until 2xx. Verify X-Vxil-Jobs-Signature against jobs_get_signing_secret. idempotency_key dedups for as long as the original run is kept (open runs always; finished runs for the retention window, 7 d succeeded / 30 d failed by default). deliver_after (ISO) / delay_seconds (\u2264 30 d, at most one) defer the first delivery; > 12 h returns state 'delayed'. callback: true (or { ttl_seconds }) returns a single-use keyless callback_url (also on every delivery) that an external worker POSTs with { status: completed, result } / { status: failed, error } / { status: processing, progress, stage, message } \u2014 a handler answering 202 hands the run off until that callback arrives; the result is stored on the run (jobs_get_run). ttl_seconds (60..2678400): a run not STARTED within that long of being due is dead-lettered (last_error_class Expired) and never delivered. debounce { key, delay_seconds, max_delay_seconds? }: the first enqueue for (job_name, key) makes a run due in delay_seconds; each later one while it has not started replaces its payload and pushes it (never past first enqueue + max_delay_seconds) and returns the SAME run_id with debounced: true (not combinable with idempotency_key / deliver_after / delay_seconds / callback / batch_id). batch_id (+ batch_total for a batch built over several calls): when every run of the batch is terminal ONE job.batch.completed event fires (jobs_get_batch reads it); a completed batch takes no new runs (409 batch_closed); an idempotency_key matching a run outside the batch is 409 idempotency_batch_conflict. concurrency_key (+ concurrency_limit 1..100, default 1): at most that many runs of this job_name sharing the key hold it at once (e.g. the end user id for one import per user); a run holds its key from its first start until it finishes (running, waiting, between retries); the rest stay queued and start oldest first, never rejected.",
11437
11460
  inputSchema: {
11438
11461
  type: "object",
11439
11462
  properties: {
@@ -11450,7 +11473,23 @@ var TOOLS = [
11450
11473
  { type: "boolean" },
11451
11474
  { type: "object", properties: { ttl_seconds: { type: "integer", minimum: 60, maximum: 2678400 } }, additionalProperties: false }
11452
11475
  ]
11453
- }
11476
+ },
11477
+ concurrency_key: { type: "string", minLength: 1, maxLength: 200, description: "per-key concurrency: runs of this job_name sharing the key hold it from their first start until they finish; waiters start oldest first" },
11478
+ concurrency_limit: { type: "integer", minimum: 1, maximum: 100, description: "how many runs may hold concurrency_key at once (default 1); requires concurrency_key" },
11479
+ ttl_seconds: { type: "integer", minimum: 60, maximum: 2678400, description: "start deadline: not started this long after it is due \u2192 dead-lettered Expired, never delivered" },
11480
+ debounce: {
11481
+ type: "object",
11482
+ description: "collapse a burst of enqueues for one key into ONE run that runs once the burst is quiet for delay_seconds",
11483
+ properties: {
11484
+ key: { type: "string", minLength: 1, maxLength: 200 },
11485
+ delay_seconds: { type: "integer", minimum: 1, maximum: 86400 },
11486
+ max_delay_seconds: { type: "integer", minimum: 1, maximum: 2592e3, description: "never push past first enqueue + this (default 10 \xD7 delay_seconds, clamped to 1 d..30 d)" }
11487
+ },
11488
+ required: ["key", "delay_seconds"],
11489
+ additionalProperties: false
11490
+ },
11491
+ batch_id: { type: "string", maxLength: 200, description: "fan-in batch to join (charset a-zA-Z0-9_.:-)" },
11492
+ batch_total: { type: "integer", minimum: 1, maximum: 1e5, description: "the batch's full size when it is built over several calls (needs batch_id)" }
11454
11493
  },
11455
11494
  required: ["job_name", "target_url"]
11456
11495
  },
@@ -11460,7 +11499,7 @@ var TOOLS = [
11460
11499
  {
11461
11500
  name: "jobs_enqueue_batch",
11462
11501
  feature: "jobs",
11463
- description: "Atomically enqueue up to 100 jobs in one call (one item invalid \u2192 422, nothing inserted). Each item = the jobs_enqueue input, incl. per-item idempotency_key and deliver_after/delay_seconds. Results align with input order.",
11502
+ description: "Atomically enqueue up to 100 jobs in one call (one item invalid \u2192 422, nothing inserted). Each item = the jobs_enqueue input, incl. per-item idempotency_key, deliver_after/delay_seconds and ttl_seconds and concurrency_key/concurrency_limit (callback and debounce are single-enqueue only). Results align with input order. FAN-IN: a top-level batch_id puts every item in one batch \u2014 when every run is terminal ONE job.batch.completed { batch_id, total, succeeded, dead_lettered, cancelled } fires (subscribe a function to job.batch. to aggregate; jobs_get_batch reads the counters). For a batch built over several calls send the same batch_id and batch_total = its full size.",
11464
11503
  inputSchema: {
11465
11504
  type: "object",
11466
11505
  properties: {
@@ -11477,11 +11516,16 @@ var TOOLS = [
11477
11516
  idempotency_key: { type: "string" },
11478
11517
  max_attempts: { type: "number", minimum: 1, maximum: 20 },
11479
11518
  deliver_after: { type: "string" },
11480
- delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 }
11519
+ delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 },
11520
+ concurrency_key: { type: "string", minLength: 1, maxLength: 200 },
11521
+ concurrency_limit: { type: "integer", minimum: 1, maximum: 100 },
11522
+ ttl_seconds: { type: "integer", minimum: 60, maximum: 2678400 }
11481
11523
  },
11482
11524
  required: ["job_name", "target_url"]
11483
11525
  }
11484
- }
11526
+ },
11527
+ batch_id: { type: "string", maxLength: 200, description: "fan-in batch every item joins (charset a-zA-Z0-9_.:-)" },
11528
+ batch_total: { type: "integer", minimum: 1, maximum: 1e5, description: "the batch's full size when it spans several calls (needs batch_id)" }
11485
11529
  },
11486
11530
  required: ["jobs"]
11487
11531
  },
@@ -11491,23 +11535,41 @@ var TOOLS = [
11491
11535
  {
11492
11536
  name: "jobs_list_runs",
11493
11537
  feature: "jobs",
11494
- description: "List job runs (state machine: queued\u2192running\u2192succeeded | retrying\u2192dead | cancelled; delayed = scheduled future delivery, waiting = suspended on an event). A platform webhook delivery run (job_name webhooks.deliver) carries webhook_event + audit_id (the event id in the audit log, = the audit_id your handler receives) \u2014 the event a dead delivery lost; its payload stays redacted.",
11538
+ description: "List job runs, newest first (state machine: queued\u2192running\u2192succeeded | retrying\u2192dead | cancelled; delayed = scheduled future delivery, waiting = suspended on an event). A platform webhook delivery run (job_name webhooks.deliver) carries webhook_event + audit_id (the event id in the audit log, = the audit_id your handler receives) \u2014 the event a dead delivery lost; its payload stays redacted. Paging: the answer carries next_cursor (null on the last page) \u2014 pass it back as cursor for the next older page. Filters: job_name, state, batch_id, since / until (creation time, ISO).",
11495
11539
  inputSchema: {
11496
11540
  type: "object",
11497
11541
  properties: {
11498
11542
  job_name: { type: "string" },
11499
11543
  state: { type: "string", enum: ["queued", "running", "retrying", "waiting", "delayed", "succeeded", "failed", "dead", "cancelled"] },
11544
+ batch_id: { type: "string", description: "only the runs of this fan-in batch" },
11545
+ since: { type: "string", description: "ISO: runs created at or after" },
11546
+ until: { type: "string", description: "ISO: runs created before" },
11547
+ cursor: { type: "string", description: "next_cursor from the previous page" },
11500
11548
  limit: { type: "number", default: 20 }
11501
11549
  }
11502
11550
  },
11503
11551
  method: "GET",
11504
11552
  path: "/v1/jobs/runs",
11505
- queryArgs: ["job_name", "state", "limit"]
11553
+ queryArgs: ["job_name", "state", "batch_id", "since", "until", "cursor", "limit"]
11554
+ },
11555
+ {
11556
+ name: "jobs_get_batch",
11557
+ feature: "jobs",
11558
+ description: "Read a fan-in batch (runs enqueued with batch_id): state open | completed, the exact counters total / open / succeeded / dead_lettered / cancelled (+ expected_total when batch_total was declared), the live count of its retained runs by state, and event_emitted_at \u2014 when the one job.batch.completed event was written. vxil runs no next step: aggregate in your own function on job.batch.completed.",
11559
+ inputSchema: {
11560
+ type: "object",
11561
+ properties: {
11562
+ batch_id: { type: "string", description: "The batch_id you enqueued with." }
11563
+ },
11564
+ required: ["batch_id"]
11565
+ },
11566
+ method: "GET",
11567
+ path: (a) => `/v1/jobs/batches/${encodeURIComponent(String(a.batch_id))}`
11506
11568
  },
11507
11569
  {
11508
11570
  name: "jobs_get_run",
11509
11571
  feature: "jobs",
11510
- 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) and progress (the latest { progress 0..100, stage, message, at } report, 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.",
11572
+ 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.",
11511
11573
  inputSchema: {
11512
11574
  type: "object",
11513
11575
  properties: {
@@ -11592,15 +11654,18 @@ var TOOLS = [
11592
11654
  {
11593
11655
  name: "jobs_create_schedule",
11594
11656
  feature: "jobs",
11595
- description: "Create a recurring (5-field cron, UTC) or one-shot (run_at ISO) schedule that enqueues the job on the minute tick. Exactly one of cron/run_at. overlap 'skip' (cron only): no new run while the previous one is still open \u2014 the slot is counted, never caught up.",
11657
+ description: "Create a recurring (5-field cron) or one-shot (run_at ISO) schedule that enqueues the job on the minute tick. Exactly one of cron/run_at. timezone (IANA, default UTC) is the zone the cron is read in \u2014 DST-correct: a fixed-hour local time skipped by a spring-forward fires once at the end of the gap, a repeated one fires once (an every-hour expression keeps its real-time cadence). external_id makes it an UPSERT: a live schedule with the same external_id is updated in place (created:false) instead of adding a second one \u2014 use it for per-user schedules (e.g. reminder:<user_id>). max_attempts (1..20) is each fired run's attempt budget. overlap 'skip' (cron only): no new run while the previous one is still open \u2014 the slot is counted, never caught up.",
11596
11658
  inputSchema: {
11597
11659
  type: "object",
11598
11660
  properties: {
11599
11661
  job_name: { type: "string" },
11600
11662
  target_url: { type: "string" },
11601
11663
  payload: { type: "object", additionalProperties: true },
11602
- cron: { type: "string", description: "e.g. '0 */6 * * *' (UTC)" },
11664
+ cron: { type: "string", description: "e.g. '0 9 * * 1-5' (read in `timezone`)" },
11603
11665
  run_at: { type: "string", description: "future ISO timestamp" },
11666
+ timezone: { type: "string", maxLength: 64, description: "IANA zone, e.g. 'Asia/Amman', 'Europe/London', 'America/New_York' (default 'UTC')" },
11667
+ external_id: { type: "string", maxLength: 200, description: "your key for this schedule (printable ASCII, no spaces): a create naming a live schedule's external_id updates it (200) instead of creating one (201)" },
11668
+ max_attempts: { type: "integer", minimum: 1, maximum: 20, description: "each fired run's attempt budget (default: the project's jobs.retry.defaultMaxAttempts)" },
11604
11669
  overlap: { type: "string", enum: ["allow", "skip"], default: "allow", description: "cron only: 'skip' fires nothing while an earlier run of this schedule is queued / running / retrying / waiting / delayed" }
11605
11670
  },
11606
11671
  required: ["job_name", "target_url"]
@@ -11608,10 +11673,157 @@ var TOOLS = [
11608
11673
  method: "POST",
11609
11674
  path: "/v1/jobs/schedules"
11610
11675
  },
11676
+ {
11677
+ name: "jobs_update_schedule",
11678
+ feature: "jobs",
11679
+ description: "Change a live schedule in place (PATCH): any of job_name, target_url, payload, cron, run_at, timezone, overlap, external_id (null clears), max_attempts (null = the project default). Its id, state (active/paused) and counters are kept. A cron / run_at / timezone change recomputes the next fire from now; any other change keeps the slot. Sending cron turns a one-shot into a cron schedule, run_at the reverse. 409 external_id_taken when another live schedule holds the external_id; 409 reconciler_owned for a platform-managed schedule (fn-cron:* etc. \u2014 change the function trigger and push instead).",
11680
+ inputSchema: {
11681
+ type: "object",
11682
+ properties: {
11683
+ schedule_id: { type: "string" },
11684
+ job_name: { type: "string" },
11685
+ target_url: { type: "string" },
11686
+ payload: { type: "object", additionalProperties: true },
11687
+ cron: { type: "string" },
11688
+ run_at: { type: "string", description: "future ISO timestamp" },
11689
+ timezone: { type: "string", maxLength: 64 },
11690
+ overlap: { type: "string", enum: ["allow", "skip"] },
11691
+ external_id: { type: ["string", "null"], maxLength: 200 },
11692
+ max_attempts: { type: ["integer", "null"], minimum: 1, maximum: 20 }
11693
+ },
11694
+ required: ["schedule_id"]
11695
+ },
11696
+ method: "PATCH",
11697
+ path: (a) => `/v1/jobs/schedules/${encodeURIComponent(String(a.schedule_id))}`,
11698
+ // the PATCH body is strict (unknown fields → 422): schedule_id is the URL segment only
11699
+ pathArgs: ["schedule_id"]
11700
+ },
11701
+ {
11702
+ name: "jobs_delete_schedule",
11703
+ feature: "jobs",
11704
+ description: "Delete a schedule (it stops firing; a run it already fired keeps running). Its external_id becomes free for a new schedule. fn-cron:* schedules belong to deployed functions \u2014 change the function's cron trigger and redeploy instead (a deleted one is recreated on the next deploy).",
11705
+ inputSchema: {
11706
+ type: "object",
11707
+ properties: { schedule_id: { type: "string" } },
11708
+ required: ["schedule_id"]
11709
+ },
11710
+ method: "DELETE",
11711
+ path: (a) => `/v1/jobs/schedules/${encodeURIComponent(String(a.schedule_id))}`
11712
+ },
11713
+ {
11714
+ name: "jobs_cancel_run",
11715
+ feature: "jobs",
11716
+ 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.",
11717
+ inputSchema: {
11718
+ type: "object",
11719
+ properties: { run_id: { type: "string" } },
11720
+ required: ["run_id"]
11721
+ },
11722
+ method: "POST",
11723
+ path: (a) => `/v1/jobs/runs/${encodeURIComponent(String(a.run_id))}/cancel`
11724
+ },
11725
+ {
11726
+ name: "jobs_replay_run",
11727
+ feature: "jobs",
11728
+ description: "Replay a finished plain run (dead / failed / cancelled / succeeded): a NEW run with the same job, target and payload is queued (replayed_from names the original). The target receives it like any delivery \u2014 dedupe on run_id if it must not act twice. A generation run is not replayable (409 not_replayable): enqueue the generation again.",
11729
+ inputSchema: {
11730
+ type: "object",
11731
+ properties: { run_id: { type: "string" } },
11732
+ required: ["run_id"]
11733
+ },
11734
+ method: "POST",
11735
+ path: (a) => `/v1/jobs/runs/${encodeURIComponent(String(a.run_id))}/replay`
11736
+ },
11737
+ {
11738
+ name: "jobs_emit_event",
11739
+ feature: "jobs",
11740
+ description: "Emit a named event: every run WAITING on it (a target that called POST /v1/jobs/runs/{run_id}/wait with this event) resumes, with payload delivered as its wakeup. Answers how many runs it woke (0 is not an error).",
11741
+ inputSchema: {
11742
+ type: "object",
11743
+ properties: {
11744
+ event: { type: "string", maxLength: 200, description: "letters, digits and _ . : -" },
11745
+ payload: { type: "object", additionalProperties: true }
11746
+ },
11747
+ required: ["event"]
11748
+ },
11749
+ method: "POST",
11750
+ path: "/v1/jobs/events"
11751
+ },
11752
+ {
11753
+ name: "jobs_enqueue_generation",
11754
+ feature: "jobs",
11755
+ 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. 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).",
11756
+ inputSchema: {
11757
+ type: "object",
11758
+ properties: {
11759
+ job_name: { type: "string" },
11760
+ provider: {
11761
+ type: "object",
11762
+ properties: {
11763
+ url: { type: "string", description: "https:// start URL" },
11764
+ method: { type: "string" },
11765
+ headers: { type: "object", additionalProperties: { type: "string" } },
11766
+ body: { type: "object", additionalProperties: true }
11767
+ },
11768
+ required: ["url"]
11769
+ },
11770
+ completion: {
11771
+ type: "object",
11772
+ properties: {
11773
+ mode: { type: "string", enum: ["poll", "webhook"] },
11774
+ status_path: { type: "string", default: "status" },
11775
+ poll: {
11776
+ type: "object",
11777
+ properties: {
11778
+ url: { type: "string" },
11779
+ method: { type: "string" },
11780
+ headers: { type: "object", additionalProperties: { type: "string" } },
11781
+ interval_ms: { type: "integer", minimum: 1e3, maximum: 6e5 }
11782
+ },
11783
+ required: ["url"]
11784
+ },
11785
+ callback: { type: "object", properties: { query_param: { type: "string" } }, required: ["query_param"] },
11786
+ status_map: { type: "object", additionalProperties: { type: "string" } },
11787
+ result_path: { type: "string" }
11788
+ },
11789
+ required: ["mode"]
11790
+ },
11791
+ status_mirror: {
11792
+ type: "object",
11793
+ properties: {
11794
+ feature: { type: "string" },
11795
+ collection: { type: "string" },
11796
+ record_id: { type: "string" },
11797
+ column: { type: "string" },
11798
+ progress_fields: { type: "array", items: { type: "string", enum: ["progress", "stage", "message"] } }
11799
+ },
11800
+ required: ["feature", "collection", "record_id"]
11801
+ },
11802
+ timeout: { type: "object", properties: { after_ms: { type: "integer", minimum: 1e3, maximum: 36e5 } }, required: ["after_ms"] },
11803
+ reserve_credits: {
11804
+ type: "object",
11805
+ properties: {
11806
+ amount: { type: "integer", minimum: 1 },
11807
+ user_id: { type: "string" },
11808
+ credit_type: { type: "string" },
11809
+ credit_types: { type: "array", items: { type: "string" } },
11810
+ reason: { type: "string" }
11811
+ },
11812
+ required: ["amount", "user_id"]
11813
+ },
11814
+ payload: { type: "object", additionalProperties: true },
11815
+ idempotency_key: { type: "string", maxLength: 200 },
11816
+ max_attempts: { type: "integer", minimum: 1, maximum: 20 }
11817
+ },
11818
+ required: ["job_name", "provider", "completion"]
11819
+ },
11820
+ method: "POST",
11821
+ path: "/v1/jobs/generation"
11822
+ },
11611
11823
  {
11612
11824
  name: "jobs_list_schedules",
11613
11825
  feature: "jobs",
11614
- description: "List the tenant's schedules (incl. the fn-cron:* function ticks): cron, state, next/last run, overlap, skipped_fires, and `held` \u2014 non-null when the schedule fired on time but its oldest run has not started for 2+ intervals (held downstream).",
11826
+ description: "List the tenant's schedules (incl. the fn-cron:* function ticks): cron, timezone, external_id, max_attempts, state, next/last run, overlap, skipped_fires, and `held` \u2014 non-null when the schedule fired on time but its oldest run has not started for 2+ intervals (held downstream).",
11615
11827
  inputSchema: { type: "object", properties: {} },
11616
11828
  method: "GET",
11617
11829
  path: "/v1/jobs/schedules"
@@ -18212,7 +18424,7 @@ export default defineConfig({
18212
18424
  "id": "render-farm",
18213
18425
  "title": "Render Farm (Trigger.dev \xB7 your containers \xB7 credits)",
18214
18426
  "vertical": "media",
18215
- "summary": "Long renders and transcodes \u2014 minutes of ffmpeg or a headless browser \u2014 on a runtime you rent (Trigger.dev, Modal, your own containers), while vxil holds what must not be lost: the credits reserved for each render, a deadline, the signed completion callback and the status row the app watches. A per-user unique render key makes a double tap start one render, the failure cause lands on the row, and the owner is told once per run.",
18427
+ "summary": "Long renders and transcodes \u2014 minutes of ffmpeg or a headless browser \u2014 on a runtime you rent (Trigger.dev, Modal, your own containers), while vxil holds what must not be lost: the credits reserved for each render, a deadline, the signed completion callback and the status row the app watches. A per-user unique render key makes a double tap start one render, the failure cause lands on the row, the owner is told once per run, and a cron re-driver drains the backlog when the project is at its in-flight cap.",
18216
18428
  "collections": [
18217
18429
  "renders"
18218
18430
  ],
@@ -18221,18 +18433,45 @@ export default defineConfig({
18221
18433
  "payments",
18222
18434
  "cms",
18223
18435
  "notifications",
18224
- "functions"
18436
+ "functions",
18437
+ "files"
18225
18438
  ],
18226
18439
  "hasFunctions": true,
18227
18440
  "byoKeys": [
18228
18441
  "render_url",
18229
- "render_token"
18442
+ "render_token",
18443
+ "vxil_jobs_key"
18230
18444
  ],
18231
- "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, 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//\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\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, and the row would keep\n // saying `processing` while the run has completed).\n output_url: { type: 'text' },\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\n error: { type: 'text' },\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 // 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 },\n});\n",
18232
- "readme": '# Render Farm \u2014 long renders on a runtime you rent, with vxil holding the credits, the deadline and the callback\n\n```bash\nvxil init my-renders --template render-farm\ncd my-renders\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\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\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.\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` and sends nothing.\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>` and JSON `{ render_id, composition, props, payload: { generation_id, correlation_id, deadline_at }, callback_url }`. Check the token, **queue the work and answer `2xx` within 20 seconds** \u2014 never render inline. `408` / `429` / `5xx` / a timeout is retried with backoff; any other `4xx` ends the run and refunds the credits. A start can arrive more than once (a lost answer is retried), so key the work by `render_id`. |\n| **Progress** (optional) | `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`). Keep pings to one every few seconds at most. |\n| **Done** | `POST callback_url` with `{"status": "completed", "output_url": "https://\u2026", "duration_s": 31.2, "progress": 100, "stage": "done"}` \u2014 at most 256 KiB, **every key a declared field of `renders`** (add a field before you send a new key). The credits are committed and every key is written onto the row. |\n| **Failed** | `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`. |\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. Check it before each attempt starts: a render that cannot finish by then should post `failed` and stop. A `completed` posted after it is answered with the settled `failed` state \u2014 the output exists, but the user was refunded and the row says failed. |\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); vxil stores the keys, not the file.\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## 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\';\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/** POST one status to vxil. A 5xx/network error throws, so Trigger.dev retries the attempt;\n * a repeated completed/failed post is a no-op on vxil\'s side. */\nasync function report(callbackUrl: string, body: Record<string, unknown>): Promise<void> {\n const res = await fetch(callbackUrl, {\n method: \'POST\', headers: { \'content-type\': \'application/json\' }, body: JSON.stringify(body),\n });\n if (res.status >= 500) throw new Error(`callback to vxil answered ${res.status}`);\n}\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.callback_url, { 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 await report(p.callback_url, { status: \'processing\', stage: \'rendering\', progress: 0 });\n\n // \u2026your render: drive Chromium for frames, run ffmpeg, 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.callback_url, {\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.callback_url, {\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 json, os, 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 report(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 urllib.request.urlopen(req, timeout=30)\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"})\n return\n try:\n report(cb, {"status": "processing", "stage": "rendering", "progress": 0})\n # \u2026render with ffmpeg / chromium, upload to YOUR bucket keyed by job["render_id"]\u2026\n output_url = f"https://cdn.example.com/renders/{job[\'render_id\']}.mp4"\n report(cb, {"status": "completed", "output_url": output_url, "duration_s": 31.2,\n "progress": 100, "stage": "done"})\n except Exception as e: # tell vxil now, so the credits come back before the deadline\n report(cb, {"status": "failed", "error": "render_failed", "hint": str(e)[:200]})\n raise\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 `render_id`: keep the ids you have\nspawned in a `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 | already `failed`; the late `completed` is answered with that state | `failed` | refunded (your compute was spent \u2014 budget the clocks) |\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 | ended at once (`ReserveInsufficient`), never started | `failed`, `error: insufficient_credits`; that `request_key` is spent | nothing held |\n| too many renders in flight, or a jobs-side fault | not created (the caller gets `429` with `retry_after`, or `502`) | `pending`, no run \u2014 calling again with the **same** `request_key` re-drives it | nothing held |\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; up to 200). Over it, `request-render`\n answers `429` and the row waits for a retry with the same `request_key`.\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`.\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',
18445
+ "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",
18446
+ "readme": "# Render Farm \u2014 long renders on a runtime you rent, with vxil holding the credits, the deadline and the callback\n\n```bash\nvxil init my-renders --template render-farm\ncd my-renders\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 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).\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>` 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, **queue the work and answer `2xx` within 20 seconds** \u2014 never render inline. `408` / `429` / `5xx` / a timeout is retried with backoff; any other `4xx` ends the run and refunds the credits. A start can arrive more than once (a lost answer is retried), so key the work by `render_id`. |\n| **Progress** (optional) | `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`). Keep pings to one every few seconds at most. |\n| **Done** | `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. |\n| **Failed** | `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`. |\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. Check it before each attempt starts: a render that cannot finish by then should post `failed` and stop. A `completed` posted after it is answered with the settled `failed` state \u2014 the output exists, but the user was refunded and the row says failed. |\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## 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).\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 = { 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};\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 const ticket = await sealTicket(env, {\n 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 await launchContainer({ // YOUR container platform's API, keyed by\n render_id: s.render_id, // render_id: a start vxil re-sends launches once\n composition: s.composition, props: s.props ?? {},\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 return Response.json({ accepted: true }, { status: 202 });\n }\n\n // 2. the output: the container POSTs the finished file here, once\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 // 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 { object_id, upload_url } = ((await minted.json()) as { data: { object_id: string; upload_url: string } }).data;\n const put = await fetch(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(object_id)}/complete`);\n if (!done.ok) return new Response(`complete ${done.status}`, { status: 502 });\n\n // 3. settle the render, on the same keyless callback the container uses\n const settled = await fetch(t.callback_url, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ status: 'completed', output_file: object_id, progress: 100, stage: 'done' }),\n });\n return settled.ok ? Response.json({ object_id }) : new Response(`callback ${settled.status}`, { status: 502 });\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```\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. Four notes\non the shape:\n\n- **A retried output post** (the container saw a network error after the coordinator had uploaded)\n mints a second object, and the second `completed` is answered with the settled state and changes\n nothing. Delete the extra object, or keep a `render_id \u2192 object_id` note in the coordinator's own\n store and skip the upload when one exists.\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';\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/** POST one status to vxil. A 5xx/network error throws, so Trigger.dev retries the attempt;\n * a repeated completed/failed post is a no-op on vxil's side. */\nasync function report(callbackUrl: string, body: Record<string, unknown>): Promise<void> {\n const res = await fetch(callbackUrl, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n });\n if (res.status >= 500) throw new Error(`callback to vxil answered ${res.status}`);\n}\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.callback_url, { 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 await report(p.callback_url, { status: 'processing', stage: 'rendering', progress: 0 });\n\n // \u2026your render: drive Chromium for frames, run ffmpeg, 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.callback_url, {\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.callback_url, {\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 json, os, 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 report(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 urllib.request.urlopen(req, timeout=30)\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\"})\n return\n try:\n report(cb, {\"status\": \"processing\", \"stage\": \"rendering\", \"progress\": 0})\n # \u2026render with ffmpeg / chromium, upload to YOUR bucket keyed by job[\"render_id\"]\u2026\n output_url = f\"https://cdn.example.com/renders/{job['render_id']}.mp4\"\n report(cb, {\"status\": \"completed\", \"output_url\": output_url, \"duration_s\": 31.2,\n \"progress\": 100, \"stage\": \"done\"})\n except Exception as e: # tell vxil now, so the credits come back before the deadline\n report(cb, {\"status\": \"failed\", \"error\": \"render_failed\", \"hint\": str(e)[:200]})\n raise\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 `render_id`: keep the ids you have\nspawned in a `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 | already `failed`; the late `completed` is answered with that state | `failed` | refunded (your compute was spent \u2014 budget the clocks) |\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, or a jobs-side fault | not created yet (the caller gets `429` 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.** `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`**, since the rest of the batch would get the same answer, and the next\n 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`.\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",
18447
+ "functions": {
18448
+ "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 = { owner?: string; composition?: string; output_url?: string; error?: string; redrive_attempts?: number };\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 // 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",
18449
+ "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// for the next one (Retry-After\n// is reported)\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 if (enq.status === 429 || (enq.status >= 400 && enq.status < 500)) {\n // 429: at the cap \u2014 the rest of the batch would get the same answer.\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 const code = enq.status === 429 ? null\n : ((await enq.json().catch(() => ({}))) as { error?: { code?: string } }).error?.code ?? null;\n out.stopped = { status: enq.status, 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",
18450
+ "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 / 502 { error, queued: true, item_id, request_key, retry_after }\n// no run YET: 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) / 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 ? 429 : 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"
18451
+ }
18452
+ },
18453
+ {
18454
+ "id": "fan-in",
18455
+ "title": "Fan-out / Fan-in (split \xB7 run in parallel \xB7 aggregate once)",
18456
+ "vertical": "ops",
18457
+ "summary": "Split one request into up to 500 parts, run every part as its own retried background job, and aggregate exactly once when the last part ends \u2014 without a workflow engine. One batch_id ties the parts together; vxil emits a single job.batch.completed when every part is terminal, and a function on that event writes the result onto the parent row. Re-entrant end to end: the same request_key resumes instead of starting twice, every part is idempotent, and a part that never starts within its TTL is counted as failed instead of hanging the batch.",
18458
+ "collections": [
18459
+ "reports",
18460
+ "report_parts"
18461
+ ],
18462
+ "features": [
18463
+ "jobs",
18464
+ "cms",
18465
+ "functions"
18466
+ ],
18467
+ "hasFunctions": true,
18468
+ "byoKeys": [],
18469
+ "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// \"Fan-out / Fan-in\" \u2014 split one request into parts, run every part as its\n// own background job, and aggregate ONCE when the last part has ended.\n//\n// start-report (your function, http)\n// \u2192 creates-or-finds the `reports` row (unique request_key)\n// \u2192 writes one `report_parts` row per part (unique part_key)\n// \u2192 POST /v1/jobs/enqueue-batch with batch_id = 'report:<item_id>' and\n// batch_total = the number of parts \u2014 each part a job that runs\n// compute-part, idempotent per part\n// compute-part (queue trigger, retried up to 3 times)\n// \u2192 computes ITS part and writes the result onto its `report_parts` row\n// vxil\n// \u2192 when EVERY part's run has ended (succeeded, dead-lettered, expired or\n// cancelled) it emits ONE `job.batch.completed` event:\n// { batch_id, total, succeeded, dead_lettered, cancelled }\n// finish-report (webhook trigger on `job.batch.`)\n// \u2192 reads the parts, aggregates, and finishes the `reports` row\n// (status 'done', or 'partial' when some parts failed)\n//\n// vxil runs no \"next step\" for you: the batch is a counter and one event. The\n// aggregation is your own function, so the platform stays a queue, not a\n// workflow engine \u2014 and every step here is safe to run twice.\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: { enabled: true },\n cms: {\n // a row is live the moment it is written\n draftPublish: false,\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n reports: {\n singular: 'report',\n fields: {\n // THE RESUME ANCHOR: a repeated start with the same key is a 409 that\n // start-report reads back and resumes \u2014 never a second report\n request_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n // splitting \u2192 running \u2192 done | partial\n status: { type: 'string', indexSlot: 's2' },\n // the input, kept so a resumed start fans out the SAME parts\n numbers: { type: 'json' },\n part_size: { type: 'int' },\n parts_total: { type: 'int', indexSlot: 'n1' },\n // written by finish-report\n parts_done: { type: 'int' },\n parts_failed: { type: 'int' },\n total: { type: 'float' },\n batch_id: { type: 'text' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n completed_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n report_parts: {\n singular: 'report_part',\n fields: {\n // '<report item_id>:<index>' \u2014 a re-created part is a 409, so a\n // resumed start never doubles a part\n part_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n // the parent `reports` item_id: finish-report reads the parts by it\n report: { type: 'string', required: true, indexSlot: 's2' },\n // pending \u2192 done (a part whose job dead-lettered stays pending)\n status: { type: 'string', indexSlot: 's3' },\n index: { type: 'int', indexSlot: 'n1' },\n numbers: { type: 'json' },\n // written by compute-part\n result: { type: 'float' },\n attempt_key: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // Starts (or resumes) ONE report. Invoke it with a server key:\n // vxil functions invoke start-report --data '{ \"request_key\": \"q3\", \"numbers\": [1,2,3,4,5], \"part_size\": 2 }'\n 'start-report': {\n entry: './functions/start-report.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'jobs:write'],\n egressAllow: [],\n signature: {\n input: { request_key: 'string', numbers: 'number[]', part_size: 'number | undefined' },\n output:\n '{ report_id: string; batch_id: string; parts: number; status: string; resumed?: boolean }',\n },\n },\n\n // ONE part, delivered by the job start-report enqueued for it. A non-2xx\n // answer is retried (up to 3 attempts); a part that still fails ends its\n // run dead-lettered \u2014 counted in job.batch.completed, never a stuck batch.\n 'compute-part': {\n entry: './functions/compute-part.ts',\n trigger: { kind: 'queue', source: 'report-parts', retry: { maxAttempts: 3 } },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // job.batch.completed \u2192 aggregate the parts and finish the report. The\n // event is emitted once per batch; its delivery is at-least-once, so the\n // finishing write is conditional (`if: { status: 'running' }`).\n 'finish-report': {\n entry: './functions/finish-report.ts',\n trigger: { kind: 'webhook', source: 'job.batch.', retry: { maxAttempts: 3 } },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n },\n});\n",
18470
+ "readme": '# Fan-out / Fan-in\n\nSplit one request into many parts, run every part as its own background job\nwith retries, and aggregate **exactly once** when the last part has ended. The\npattern behind "summarise 300 documents, then write the report", "render 40\nthumbnails, then publish the album", or "price 500 items, then total the quote".\n\n```\nstart-report (http) compute-part (queue) \xD7N finish-report (webhook)\n reports row \u2500\u2500\u25BA N report_parts rows \u2500\u2500\u25BA one job per part \u2500\u2500\u25BA each writes its result\n one enqueue-batch with \u2502\n batch_id = report:<id> \u25BC\n every part ended \u2192 job.batch.completed (once)\n \u2502\n \u25BC\n aggregate the rows, finish the report\n```\n\n**What it teaches that no other blueprint does:** the **batch** \u2014 a\n`batch_id` on enqueue that ties many runs together, and the one\n`job.batch.completed` event vxil emits when every run in it is terminal. There\nis no workflow engine and no "next step" run by the platform: the batch is a\ncounter and an event, and the aggregation is your own function.\n\n## Try it\n\n```bash\nvxil init --template fan-in my-fan-in && cd my-fan-in\nvxil push\nvxil functions invoke start-report --data \'{ "request_key": "q3-totals", "numbers": [1,2,3,4,5,6,7,8,9,10], "part_size": 3 }\'\n# 202 { "report_id": "itm_\u2026", "batch_id": "report:itm_\u2026", "parts": 4, "status": "running" }\n```\n\nA few seconds later the `reports` row reads `status: "done"`, `total: 55`,\n`parts_done: 4`. Watch the batch itself:\n\n```bash\nvxil api GET /v1/jobs/batches/report:itm_\u2026\n# { "state": "completed", "total": 4, "succeeded": 4, "dead_lettered": 0, "cancelled": 0,\n# "runs_by_state": { "succeeded": 4 }, "event_emitted_at": "\u2026" }\nvxil api GET \'/v1/jobs/runs?batch_id=report:itm_\u2026\'\n```\n\n## How the pieces fit\n\n| Step | Where | What makes it safe to run twice |\n| --- | --- | --- |\n| Create or find the report | `start-report` | `request_key` is `unique`: a repeat is a `409` that is read back and **resumed** |\n| Write one row per part | `start-report` | `part_key` (`<report id>:<index>`) is `unique`: a repeat is a `409` |\n| Enqueue one job per part | `start-report` \u2192 `POST /v1/jobs/enqueue-batch` | each job\'s `idempotency_key` is `<batch_id>:<index>`: a repeat hands back the same run |\n| Compute one part | `compute-part` | a part already `done` answers at once; the result written is the same every time |\n| Aggregate once | `finish-report` on `job.batch.completed` | the finishing write is conditional (`if: { status: \'running\' }`): a redelivered event\'s write is a `409` |\n\n### The batch\n\n`start-report` enqueues every part with the same `batch_id` (`report:<item id>`)\nand declares `batch_total` \u2014 the number of parts. vxil counts the batch\'s runs\nin the same transaction that ends each one. When the last one ends \u2014\n**succeeded, dead-lettered (retries exhausted), expired or cancelled** \u2014 it\nwrites one `job.batch.completed`:\n\n```json\n{ "batch_id": "report:itm_\u2026", "total": 4, "succeeded": 4, "dead_lettered": 0, "cancelled": 0 }\n```\n\n- **Once per batch.** A completed batch takes no new runs (`409 batch_closed`);\n replaying a part later creates a new run outside the batch, so the event is\n never sent twice. Its *delivery* to your function is at-least-once, which is\n why `finish-report` is re-entrant.\n- **Big fan-outs.** One enqueue-batch call takes 100 jobs; `start-report` sends\n up to five calls (500 parts). `batch_total` keeps the batch open until the\n last call has enqueued, so a fast first part can never complete the batch\n early. A different `batch_total` on a later call is `409 batch_total_mismatch`.\n- **A part that never starts** (the project at its concurrency cap, a long\n backlog) is ended after `ttl_seconds` (`PART_TTL_SECONDS`, one hour) as\n `Expired` and counted in `dead_lettered` \u2014 the report finishes `partial`\n instead of waiting forever.\n\n### Failure\n\n`compute-part` declares `retry: { maxAttempts: 3 }`: a non-2xx answer is tried\nagain with backoff, and a part that still fails ends its run dead-lettered. The\nbatch still completes; `finish-report` marks the report **`partial`** with\n`parts_failed`. To retry a failed part, replay its run (`vxil api POST\n/v1/jobs/runs/<run_id>/replay`) \u2014 the part row updates, but the report keeps\nthe result it was finished with; start a new report (a new `request_key`) for a\nfresh total.\n\n### Resuming\n\nCall `start-report` again with the same `request_key` after a lost answer or a\ncrash: it reads the **stored** input (a different `numbers` on the repeat is\nignored), writes only the missing part rows, re-sends the enqueue (a no-op for\nparts that already have a run), and answers `resumed: true`. A finished report\nanswers its final `status`.\n\n## Make it yours\n\n- **The work** is the one marked line in `functions/compute-part.ts`. Anything a\n function can do fits: call an API, run a model, transform a file.\n- **The aggregate** is in `functions/finish-report.ts`; it reads results from the\n part rows, never from events, so nothing is lost when a delivery repeats.\n- **Throughput:** the project\'s jobs concurrency setting and a\n [flow rule](../../docs/guide/06-feature-catalog.md) bound how many parts run at\n once \u2014 the fan-out queues, it never overloads a downstream API.\n- **Watching progress:** `GET /v1/jobs/batches/{batch_id}` gives open / succeeded\n / dead-lettered counts while the batch runs; the dashboard\'s Jobs page lists\n its runs (`?batch_id=`).\n\n## Limits\n\n| | |\n| --- | --- |\n| Parts per report (this blueprint) | 500 (`MAX_PARTS`) |\n| Jobs per enqueue-batch call | 100 |\n| Runs per batch | 100,000 |\n| `batch_id` | 1\u2013200 characters, `a-z A-Z 0-9 _ . : -` |\n| Part start deadline | 1 hour (`PART_TTL_SECONDS`, 60 s \u2013 31 days) |\n',
18233
18471
  "functions": {
18234
- "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 = { owner?: string; composition?: string; output_url?: string; error?: string };\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 // Not enough credits: request-render already answered the user (402)\n // and wrote error: 'insufficient_credits' \u2014 keep that code on the row\n // (write it only if that write was lost), and send no e-mail for a\n // render that never started.\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 return Response.json({ ok: true, notified: false });\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 const sent = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notifications}`,\n 'content-type': 'application/json',\n 'idempotency-key': `render-ready:${d.run_id}`,\n },\n body: JSON.stringify({\n user_id: row.owner,\n template: 'transactional',\n data: 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 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};\n",
18235
- "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//\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, 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 body: { render_id: a.itemId, 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) / 5xx: NO run is promised \u2014 the row stays\n // pending with no run, so a retry with the SAME request_key re-drives it\n return Response.json(\n { error: `generation enqueue: ${enq.status}`, item_id: a.itemId, retry_after: enq.headers.get('retry-after') },\n { status: enq.status === 429 ? 429 : 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"
18472
+ "compute-part.ts": "// compute-part.ts \u2014 ONE part of the fan-out (a vxil function, queue trigger).\n//\n// Delivered by the job start-report enqueued for this part; the envelope's\n// `payload` is { report_id, part_key }. The \"work\" here is a sum \u2014 replace it\n// with yours (an API call, a model call, a file to process). Keep the result\n// on the part's own row: finish-report aggregates from the rows, not from\n// events, so nothing is lost if a delivery is repeated.\n//\n// AT-LEAST-ONCE: the same part can be delivered again (a retry, a redelivery).\n// A part already `done` answers at once; otherwise the result is computed and\n// written \u2014 the same value every time, so a second write changes nothing.\n// A non-2xx answer is retried (the trigger declares `retry: { maxAttempts: 3 }`);\n// a part that keeps failing ends its run dead-lettered, and the batch still\n// completes \u2014 finish-report then marks the report `partial`.\n\nimport type { QueueFunctionEnvelope } from '@vxil/sdk';\n\ntype Payload = { report_id?: string; part_key?: string };\ntype PartRow = { item_id: string; data: { status?: string; numbers?: unknown } };\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as QueueFunctionEnvelope<Payload>;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const partKey = typeof env.payload?.part_key === 'string' ? env.payload.part_key : '';\n // a malformed delivery can never succeed: acknowledge it (2xx) so it is not retried\n if (!partKey) return Response.json({ skipped: 'no part_key' });\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n const filter = encodeURIComponent(JSON.stringify({ part_key: partKey }));\n const found = await fetch(`${base}/v1/cms/items/report_parts?filter=${filter}&limit=1`, { headers: H });\n if (!found.ok) return Response.json({ error: `part read: ${found.status}` }, { status: 502 }); // another attempt (header note)\n const part = ((await found.json()) as { data?: { items?: PartRow[] } }).data?.items?.[0];\n if (!part) return Response.json({ skipped: 'part not found' });\n if (part.data.status === 'done') return Response.json({ part_key: partKey, already: true });\n\n const nums = Array.isArray(part.data.numbers) ? part.data.numbers.filter((n): n is number => typeof n === 'number') : [];\n const result = nums.reduce((a, b) => a + b, 0); // \u2190 your work goes here\n\n const patched = await fetch(`${base}/v1/cms/items/report_parts/${encodeURIComponent(part.item_id)}`, {\n method: 'PATCH', headers: H,\n body: JSON.stringify({ data: { status: 'done', result, attempt_key: env.idempotency_key } }),\n });\n if (!patched.ok) return Response.json({ error: `part write: ${patched.status}` }, { status: 502 }); // another attempt (header note)\n return Response.json({ part_key: partKey, result });\n },\n};\n",
18473
+ "finish-report.ts": "// finish-report.ts \u2014 FAN-IN: job.batch.completed \u2192 aggregate the parts and\n// finish the report (a vxil function, webhook trigger on `job.batch.`).\n//\n// vxil emits job.batch.completed ONCE per batch, when every run in it has\n// ended: { batch_id, total, succeeded, dead_lettered, cancelled }. Its\n// DELIVERY is at-least-once (a retry after a non-2xx answer, a redelivery), so\n// this function is re-entrant: it reads the parts, computes the same answer\n// every time, and finishes the report with a CONDITIONAL write\n// (`if: { status: 'running' }`) \u2014 the second delivery's write is a 409 and\n// changes nothing.\n//\n// Batches that are not reports (another feature of your app using batch_id)\n// are skipped by their prefix.\n\nimport type { JobBatchCompletedEventPayload, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype ReportRow = { data: { status?: string; parts_total?: number } };\ntype PartRow = { data: { status?: string; result?: number } };\n\n/** The parts read per page while aggregating. */\nconst PAGE = 100;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as WebhookFunctionEnvelope<JobBatchCompletedEventPayload>;\n const d = env.payload?.data;\n if (!d || 'truncated' in d || typeof d.batch_id !== 'string') return Response.json({ skipped: true });\n if (!d.batch_id.startsWith('report:')) return Response.json({ skipped: 'not a report batch' });\n const reportId = d.batch_id.slice('report:'.length);\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const base = env.vxil_base;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const reportUrl = `${base}/v1/cms/items/reports/${encodeURIComponent(reportId)}`;\n\n const rep = await fetch(reportUrl, { headers: H });\n if (rep.status === 404) return Response.json({ skipped: 'report gone' });\n if (!rep.ok) return Response.json({ error: `report read: ${rep.status}` }, { status: 502 }); // another attempt (header note)\n const report = ((await rep.json()) as { data: ReportRow }).data;\n if (report.data.status !== 'running') return Response.json({ skipped: `report is ${report.data.status ?? 'unknown'}` });\n\n // aggregate from the rows (the source of truth), page by page\n let total = 0;\n let done = 0;\n let cursor: string | undefined;\n const filter = encodeURIComponent(JSON.stringify({ report: reportId }));\n for (;;) {\n const page = await fetch(\n `${base}/v1/cms/items/report_parts?filter=${filter}&limit=${PAGE}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,\n { headers: H },\n );\n if (!page.ok) return Response.json({ error: `parts read: ${page.status}` }, { status: 502 }); // another attempt (header note)\n const body = (await page.json()) as { data?: { items?: PartRow[]; next_cursor?: string | null } };\n for (const p of body.data?.items ?? []) {\n if (p.data.status === 'done' && typeof p.data.result === 'number') {\n total += p.data.result;\n done += 1;\n }\n }\n cursor = body.data?.next_cursor ?? undefined;\n if (!cursor) break;\n }\n const partsTotal = report.data.parts_total ?? d.total;\n const failed = Math.max(0, partsTotal - done);\n\n const finished = await fetch(reportUrl, {\n method: 'PATCH', headers: H,\n body: JSON.stringify({\n data: {\n status: failed === 0 ? 'done' : 'partial',\n total, parts_done: done, parts_failed: failed, completed_at: new Date().toISOString(),\n },\n if: { status: 'running' },\n }),\n });\n if (finished.status === 409) return Response.json({ skipped: 'already finished' });\n if (!finished.ok) return Response.json({ error: `report write: ${finished.status}` }, { status: 502 }); // another attempt (header note)\n return Response.json({\n report_id: reportId, status: failed === 0 ? 'done' : 'partial', total, parts_done: done, parts_failed: failed,\n runs: { succeeded: d.succeeded, dead_lettered: d.dead_lettered, cancelled: d.cancelled },\n });\n },\n};\n",
18474
+ "start-report.ts": "// start-report.ts \u2014 FAN-OUT: split one request into parts and enqueue one job\n// per part, all in ONE batch (a vxil function, http trigger).\n//\n// POST /v1/fn/start-report (a server key, or `vxil functions invoke`)\n// { \"request_key\": \"q3-totals\", \"numbers\": [1, 2, 3, \u2026], \"part_size\": 50 }\n// \u2192 202 { report_id, batch_id, parts, status: 'running' }\n// \u2192 200 { \u2026, resumed: true } the same request_key again: the SAME report,\n// resumed (missing parts written, their jobs\n// enqueued again \u2014 a no-op for the ones that exist)\n// \u2192 200 { \u2026, status: 'done' | 'partial' } that report already finished\n//\n// Every step is safe to run twice (DELIVERY IS AT-LEAST-ONCE, and a caller\n// may retry after a lost answer):\n// \u2022 the report row is unique on request_key \u2192 a repeat is a 409 we read back;\n// \u2022 each part row is unique on part_key ('<report id>:<index>') \u2192 409 = exists;\n// \u2022 each part's job carries idempotency_key '<batch_id>:<index>' \u2192 a repeat\n// enqueue hands back the same run instead of starting a second one;\n// \u2022 the batch declares batch_total = the number of parts, so it cannot\n// complete before the last call of a 500-part fan-out has enqueued;\n// \u2022 a resumed start re-reads the STORED input, so a repeat with different\n// numbers can never change a report that is already running.\n// The report is marked `running` BEFORE the first enqueue, so finish-report\n// (which only finishes a running report) can never see the batch end first.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Input = { request_key?: string; numbers?: unknown; part_size?: number };\ntype ReportRow = {\n item_id: string;\n data: { status?: string; numbers?: number[]; part_size?: number; parts_total?: number };\n};\n\n/** Parts per enqueue-batch call (the platform's per-call cap). */\nconst ENQUEUE_CHUNK = 100;\n/** The most parts one report may have (5 enqueue-batch calls). */\nconst MAX_PARTS = 500;\n/** The most numbers one request may carry. */\nconst MAX_NUMBERS = 5_000;\n/** A part not STARTED within this long is dead-lettered as Expired \u2014 counted\n * as failed in the batch instead of holding the report open forever. */\nconst PART_TTL_SECONDS = 3_600;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as HttpFunctionEnvelope<Input>;\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 H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n const requestKey = typeof env.payload?.request_key === 'string' ? env.payload.request_key.trim().slice(0, 120) : '';\n const numbers = env.payload?.numbers;\n const partSize = Number.isInteger(env.payload?.part_size) ? Number(env.payload!.part_size) : 50;\n if (!requestKey || !Array.isArray(numbers) || numbers.length === 0 || numbers.length > MAX_NUMBERS\n || !numbers.every((n) => typeof n === 'number' && Number.isFinite(n))) {\n return Response.json({ error: `need { request_key, numbers: number[1..${MAX_NUMBERS}], part_size? }` }, { status: 422 });\n }\n if (partSize < 1 || Math.ceil(numbers.length / partSize) > MAX_PARTS) {\n return Response.json({ error: `part_size must be >= 1 and give at most ${MAX_PARTS} parts` }, { status: 422 });\n }\n\n // 1. create-or-find the report\n const created = await fetch(`${base}/v1/cms/items/reports`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n data: {\n request_key: requestKey, status: 'splitting', numbers, part_size: partSize,\n parts_total: Math.ceil(numbers.length / partSize), created_at: new Date().toISOString(),\n },\n }),\n });\n let report: ReportRow;\n let resumed = false;\n if (created.status === 409) {\n const filter = encodeURIComponent(JSON.stringify({ request_key: requestKey }));\n const found = await fetch(`${base}/v1/cms/items/reports?filter=${filter}&limit=1`, { headers: H });\n const row = found.ok ? ((await found.json()) as { data?: { items?: ReportRow[] } }).data?.items?.[0] : undefined;\n if (!row) return Response.json({ error: `report lookup: ${found.status}` }, { status: 502 });\n if (row.data.status === 'done' || row.data.status === 'partial') {\n return Response.json({\n report_id: row.item_id, batch_id: batchIdOf(row.item_id), parts: row.data.parts_total ?? 0, status: row.data.status,\n });\n }\n report = row;\n resumed = true;\n } else if (created.ok) {\n report = { item_id: ((await created.json()) as { data: { item_id: string } }).data.item_id, data: { numbers, part_size: partSize } };\n } else {\n return Response.json({ error: `report row: ${created.status}` }, { status: 502 });\n }\n\n // the STORED input decides the parts (a resumed start never re-splits differently)\n const input = Array.isArray(report.data.numbers) ? report.data.numbers : numbers;\n const size = Number.isInteger(report.data.part_size) ? Number(report.data.part_size) : partSize;\n const parts: number[][] = [];\n for (let i = 0; i < input.length; i += size) parts.push(input.slice(i, i + size));\n const batchId = batchIdOf(report.item_id);\n\n // 2. one part row per part (409 = written by an earlier attempt)\n for (let i = 0; i < parts.length; i += 10) {\n const results = await Promise.all(parts.slice(i, i + 10).map((nums, j) => fetch(`${base}/v1/cms/items/report_parts`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n data: { part_key: `${report.item_id}:${i + j}`, report: report.item_id, index: i + j, numbers: nums, status: 'pending' },\n }),\n })));\n const bad = results.find((r) => !r.ok && r.status !== 409);\n if (bad) return Response.json({ error: `part row: ${bad.status}` }, { status: 502 });\n }\n\n // 3. mark the report running BEFORE any part can run (see the header note)\n const mark = await fetch(`${base}/v1/cms/items/reports/${encodeURIComponent(report.item_id)}`, {\n method: 'PATCH', headers: H,\n body: JSON.stringify({ data: { status: 'running', batch_id: batchId }, if: { status: 'splitting' } }),\n });\n if (!mark.ok && mark.status !== 409) return Response.json({ error: `report mark: ${mark.status}` }, { status: 502 });\n\n // 4. the fan-out: one job per part, all in ONE batch of a declared size.\n // The job is delivered into compute-part through the signed\n // function-trigger lane (no server of yours involved).\n const target = `${base}/v1/internal/fn/trigger/compute-part?tenant=${encodeURIComponent(env.tenant_id)}`;\n for (let i = 0; i < parts.length; i += ENQUEUE_CHUNK) {\n const jobsBody = parts.slice(i, i + ENQUEUE_CHUNK).map((_, j) => ({\n job_name: 'report.part',\n target_url: target,\n payload: { trigger: 'queue', payload: { report_id: report.item_id, part_key: `${report.item_id}:${i + j}` } },\n idempotency_key: `${batchId}:${i + j}`,\n ttl_seconds: PART_TTL_SECONDS,\n }));\n const enq = await fetch(`${base}/v1/jobs/enqueue-batch`, {\n method: 'POST',\n headers: { authorization: `Bearer ${jobs}`, 'content-type': 'application/json' },\n body: JSON.stringify({ jobs: jobsBody, batch_id: batchId, batch_total: parts.length }),\n });\n if (enq.status === 409) {\n // batch_closed: every part already ended (a resumed start after the\n // fan-in) \u2014 finish-report owns the result from here\n const code = ((await enq.json().catch(() => ({}))) as { error?: { code?: string } }).error?.code;\n if (code === 'batch_closed') break;\n return Response.json({ error: `enqueue: 409 ${code ?? ''}`.trim() }, { status: 502 });\n }\n if (!enq.ok) return Response.json({ error: `enqueue: ${enq.status}` }, { status: 502 });\n }\n\n return Response.json(\n { report_id: report.item_id, batch_id: batchId, parts: parts.length, status: 'running', ...(resumed ? { resumed: true } : {}) },\n { status: resumed ? 200 : 202 },\n );\n },\n};\n\n/** One batch per report \u2014 finish-report recognises its events by this prefix. */\nfunction batchIdOf(reportId: string): string {\n return `report:${reportId}`;\n}\n"
18236
18475
  }
18237
18476
  },
18238
18477
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "private": false,
5
5
  "description": "The vxil CLI — init, quickstart, push, gen, secrets, keys, migrate, doctor, diff, functions, payments; installs the `vxil` command (npm i -g @vxil/cli).",
6
6
  "license": "MIT",
@@ -46,10 +46,10 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "miniflare": "^4.20260611.0",
49
- "@vxil/config": "0.9.0",
49
+ "@vxil/config": "0.10.0",
50
+ "@vxil/feature-configs": "0.9.0",
50
51
  "@vxil/runtime": "0.0.1",
51
- "@vxil/types": "0.0.1",
52
- "@vxil/feature-configs": "0.8.0"
52
+ "@vxil/types": "0.0.1"
53
53
  },
54
54
  "scripts": {
55
55
  "build": "pnpm --filter @vxil/feature-configs run build && pnpm --filter @vxil/config run build && esbuild bin/vxil.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/vxil.js --banner:js='#!/usr/bin/env node' --external:esbuild --external:miniflare && esbuild src/config-entry.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/config.js && pnpm exec tsx scripts/build-config-dts.ts",