@vxil/cli 0.14.1 → 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.1";
4525
- var VXIL_CLI_PKG_VERSION = "0.14.1";
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), progress (the latest { progress 0..100, stage, message, at } report, or null) 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.",
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"
@@ -18238,6 +18450,30 @@ export default defineConfig({
18238
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"
18239
18451
  }
18240
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',
18471
+ "functions": {
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"
18475
+ }
18476
+ },
18241
18477
  {
18242
18478
  "id": "research-library",
18243
18479
  "title": "Research Library",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/cli",
3
- "version": "0.14.1",
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,8 +46,8 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "miniflare": "^4.20260611.0",
49
- "@vxil/config": "0.9.0",
50
- "@vxil/feature-configs": "0.8.0",
49
+ "@vxil/config": "0.10.0",
50
+ "@vxil/feature-configs": "0.9.0",
51
51
  "@vxil/runtime": "0.0.1",
52
52
  "@vxil/types": "0.0.1"
53
53
  },