diff --git a/.github/workflows/release-provenance.yml b/.github/workflows/release-provenance.yml index 260b10c..1b20797 100644 --- a/.github/workflows/release-provenance.yml +++ b/.github/workflows/release-provenance.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e @@ -58,6 +60,15 @@ jobs: - name: Verify coverage run: npm run test:coverage + - name: Verify release tag + if: inputs.dry_run == 'false' + shell: bash + run: | + test "$GITHUB_REF_TYPE" = "tag" + package_version="$(node -p "require('./packages/core/package.json').version")" + test "$GITHUB_REF_NAME" = "v$package_version" + git tag -v "$GITHUB_REF_NAME" + - name: Publish dry run if: inputs.dry_run == 'true' run: npm publish --workspace @workit/core --provenance --access public --dry-run diff --git a/.gitignore b/.gitignore index 6cef8d7..dae4a2d 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,9 @@ tmp-tests/ *.tmp.* *.scratch.* +# Local internal distribution inspection archives. +/dist-cjs.zip + # OS and editor files. .DS_Store Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md index 3630841..22f1400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ SPDX-License-Identifier: Apache-2.0 ## Unreleased +## 0.5.0 + +Add runtime deadline introspection, shared retry admission limits, and bounded +attempt evidence to the core execution contract. + +- Add `TaskContext.deadlineAt`, reporting the earliest effective absolute + deadline inherited through the owning scope tree and active timeout/deadline + wrappers. Task bodies must still cooperate through `ctx.signal`. +- Add `RetryOpts.retryBudget`, charged through the existing atomic scope budget + mechanism before each additional retry is admitted. The initial attempt is + not charged. +- Add the `task:attempt` lifecycle event and derive generic receipt attempt + evidence from the outer task retry boundary. Exhaustive `TaskEvent` consumers + must add a `case "task:attempt"` branch when upgrading from `0.4.x`. +- Add `createAttemptRecorder()` to `@workit/core/replay` for bounded, + secret-redacted caller metadata and validated reason codes at explicit + provider or activity boundaries. +- Extend `@workit/core/time-policy` with retry budget snapshots and conservative + aggregate demand checks for nested retry policies. +- Isolate event-context and observer failures so telemetry cannot interrupt + cancellation, deadline timers, or scope close transitions. +- Clarify that `TaskOpts.idempotencyKey` provides in-flight coalescing inside + one live scope, not durable idempotency or restart replay. +- Expand ESM, CommonJS, strict TypeScript, framework, evidence, and lifecycle + coverage for the new contracts. +- Require non-dry-run provenance publishing from a signed tag matching the + package version. +- Update the release build/test toolchain to patched `esbuild` and `wrangler` + versions; the published package still has zero runtime dependencies. +- Keep the root bundle below its ratcheted size limits. + ## 0.4.0 Add runtime contract and evidence hardening behind explicit subpaths. The root diff --git a/README.md b/README.md index fc90a20..adc5935 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ cite the software release you used: title = {WorkIt: A TypeScript Structured Concurrency Runtime for Node.js Server Runtimes}, year = {2026}, url = {https://github.com/WorkRuntime/workit}, - version = {0.4.0}, + version = {0.5.0}, license = {Apache-2.0} } ``` diff --git a/articles/08-receipts-redaction-and-attempt-evidence.md b/articles/08-receipts-redaction-and-attempt-evidence.md new file mode 100644 index 0000000..efe0784 --- /dev/null +++ b/articles/08-receipts-redaction-and-attempt-evidence.md @@ -0,0 +1,215 @@ + + +# A Receipt For Async Work + +An async operation can return successfully while leaving an awkward question +behind: what actually happened inside it? + +For a small function, the return value may be enough. For a provider call with +retries, cancellation and cleanup, it is not. Operators need to know which +attempts ran, why the operation stopped, whether cleanup timed out and whether +owned work remained pending. + +WorkIt receipts preserve those lifecycle facts as data. + +```ts +import { run } from "@workit/core"; +import { createReceiptRecorder } from "@workit/core/replay"; + +// Application-owned provider boundary. +declare function callProvider(ctx: { + signal: AbortSignal; +}): Promise<{ answer: string }>; + +let observedScope; +let recorder; + +await run.scope(async (scope) => { + observedScope = scope; + recorder = createReceiptRecorder(scope, { + receiptId: "answer:request-42", + }); + + await scope.spawn(run.retry(callProvider, { + times: 3, + initialDelay: "100ms", + }), { + name: "provider.answer", + kind: "llm", + }); +}); + +const receipt = recorder.build(observedScope.status()); +recorder.unsubscribe(); +``` + +The receipt includes the terminal outcome, normalized lifecycle events, a final +scope snapshot and a summary of cleanup or leaked-task evidence. In WorkIt +0.5.0 it can also derive one record for every retry attempt admitted by the +outer task boundary. + +## Evidence, not deterministic replay + +The word “replay” is overloaded. Deterministic replay records enough scheduling +and nondeterministic input to execute a program again with the same decisions. +That requires control over clocks, random values, I/O, scheduling and usually +the runtime itself. + +WorkIt does something narrower. It records what the scope observed: + +```txt +typed task and scope events +final scope snapshot +terminal outcome +cancellation reason +cleanup failures and timeouts +retry attempt outcomes +telemetry drop and truncation counts +``` + +You can store and inspect that evidence later. You cannot use it to rerun an +arbitrary JavaScript program. The distinction is useful because it keeps the +receipt contract small enough to verify. + +## Attempts belong to the task lifecycle + +Before 0.5.0, receipts could include retrying events. Those events explained +that another attempt was planned, but they did not provide a terminal outcome +for every invocation. + +The `task:attempt` event closes that gap: + +```ts +scope.onEvent((event) => { + if (event.type !== "task:attempt") return; + + process.stdout.write(JSON.stringify({ + taskId: event.taskId, + attempt: event.attempt, + outcome: event.outcome, + durationMs: event.durationMs, + }) + "\n"); +}); +``` + +An attempt ends as `succeeded`, `failed` or `cancelled`. Nested retry wrappers +do not produce competing generic histories for the same task. The outer retry +boundary owns the task-level attempt sequence, while an application can add +more specific provider or activity evidence when it needs it. + +That ownership rule prevents a receipt from presenting two incompatible +answers to “how many task attempts ran?” + +## Add metadata at the boundary that knows it + +The runtime knows the task id, attempt number, timing and outcome. It does not +know whether a particular invocation targeted a primary provider, a regional +replica or a billing-sensitive activity. + +`createAttemptRecorder()` lets the caller add that context explicitly: + +```ts +import { createAttemptRecorder } from "@workit/core/replay"; + +const attemptRecorder = createAttemptRecorder({ + maxAttempts: 20, + maxMetadataBytes: 1_024, +}); + +// `callProvider` is the application-owned provider function from the previous +// example. +const callPrimary = attemptRecorder.wrap(callProvider, { + metadata: { + provider: "primary", + operation: "answer", + }, + reasonCode: (error) => + error instanceof TypeError ? "transport_error" : "provider_error", +}); + +const operation = run.retry(callPrimary, { + times: 3, + initialDelay: "100ms", +}); +``` + +Reason codes must be bounded slugs. Metadata must be a JSON object and must fit +the configured byte limit. Common secret fields are redacted before the record +is retained. + +This is deliberately caller-owned enrichment. Inferring provider policy from an +arbitrary error object would turn a lifecycle recorder into a second routing +engine. + +## Redaction belongs before storage + +Progress data is often valuable during an incident, although it may contain +fields that should never reach a durable ledger. + +Receipt redaction has conservative defaults for common secret names, and the +caller can add its own policy: + +```ts +import { redactReceipt } from "@workit/core/replay"; + +const publicReceipt = redactReceipt(receipt, { + removeFields: ["privateNote"], + redactFields: ["authorization", "tenantToken"], +}); +``` + +Redaction is not a substitute for data minimization. The safest private payload +is still the one that was never attached to an event. It does, however, provide +a clear boundary between local lifecycle evidence and a receipt intended for +storage or publication. + +## What a receipt can establish + +A captured receipt can support claims about the WorkIt lifecycle it observed: + +- the scope reached a terminal state; +- no owned tasks were pending in the final snapshot; +- cancellation carried a typed reason; +- cleanup failure or timeout events were present; +- admitted retry invocations had terminal outcomes; +- event truncation or telemetry drops were counted. + +It cannot establish that a remote provider stopped billing, that an external +transaction was semantically correct or that an uncaptured event occurred. + +Those limitations are not footnotes. They define where runtime evidence ends +and application or provider evidence begins. + +## Executable evidence + +The relevant release proofs are: + +```txt +LIFE-004 completed scope receipt +LIFE-005 typed cancellation reason +LIFE-012 admitted attempt evidence and default secret redaction +``` + +They run through: + +```sh +npm run test:evidence +npm run test:coverage +npm run verify +``` + +The tests cover completed and cancelled receipts, cleanup evidence, bounded +event windows, redaction, attempt outcomes, metadata limits and installed +package consumers. + +The practical result is modest but important: when WorkIt owns an async +lifecycle, it can leave behind a typed account of what it observed. + +## Sources + +- [`@workit/core/replay`](../packages/core/src/replay/index.ts) +- [`replay-receipts.mjs`](../packages/core/tests/evidence/lifecycle/replay-receipts.mjs) +- [`claims.json`](../packages/core/evidence/claims.json) diff --git a/articles/09-plan-retries-before-they-run.md b/articles/09-plan-retries-before-they-run.md new file mode 100644 index 0000000..5c652ca --- /dev/null +++ b/articles/09-plan-retries-before-they-run.md @@ -0,0 +1,243 @@ + + +# Plan Retries Before They Run + +A retry policy can look reasonable on its own and still be impossible inside +the request that owns it. + +Suppose one provider attempt may take 800 milliseconds. The call allows four +attempts with increasing backoff, while the request has two seconds left. The +individual numbers are valid, yet the composition cannot fit. + +`@workit/core/time-policy` evaluates that shape before task execution: + +```ts +import { planTimePolicy } from "@workit/core/time-policy"; + +const plan = planTimePolicy({ + type: "timeout", + timeout: "2s", + policy: { + type: "retry", + attempt: { + type: "attempt", + duration: "800ms", + }, + retry: { + times: 4, + initialDelay: "100ms", + factor: 2, + jitter: false, + }, + }, +}); + +if (!plan.valid) { + console.error(plan.warnings); +} +``` + +The planner does not call the provider. It computes a conservative upper bound +from the declared policy and reports typed warnings when the composition cannot +fit. + +## Runtime policy and planning policy have different jobs + +`run.retry()` owns execution. It invokes the task, observes cancellation, +sleeps with the task signal and decides whether another attempt may start. + +`planTimePolicy()` owns pre-execution analysis. It works with declared attempt +costs and composition rules: + +```txt +attempt +retry +hedge +timeout +deadline +series +parallel +``` + +Keeping those responsibilities separate matters. A planner should not produce +side effects, while a runtime should not pretend it can predict provider +latency that the caller never declared. + +## A deadline is part of the task context + +WorkIt 0.5.0 exposes the earliest effective absolute deadline as +`ctx.deadlineAt`. + +```ts +import { run } from "@workit/core"; + +const deadlineAt = Date.now() + 2_000; + +const result = await run.group(async (task) => { + return task(run.deadline(async (ctx) => { + return { + deadlineAt: ctx.deadlineAt, + remainingMs: Math.max(0, ctx.deadlineAt! - Date.now()), + }; + }, deadlineAt)); +}); +``` + +If a task has both an inherited scope deadline and a wrapper deadline, it sees +the earlier value. Retry, fallback and hedge compositions preserve that +effective deadline for their task bodies. + +The value is introspection, not preemption. The task must still cooperate with +`ctx.signal`, and external I/O must receive that signal when the client supports +abort. + +## A retry count is not a shared admission policy + +Per-operation retry limits prevent one wrapper from running forever. They do +not stop several sibling operations from consuming too many retries together. + +Version 0.5.0 adds a shared retry budget: + +```ts +import { createBudget, run } from "@workit/core"; + +const ProviderRetries = createBudget("ProviderRetries", { + unit: "retries", +}); + +// Application-owned provider boundary. +declare function callProvider(ctx: { + signal: AbortSignal; +}): Promise<{ answer: string }>; + +const operation = run.retry(callProvider, { + times: 4, + initialDelay: "100ms", + retryBudget: ProviderRetries, +}); + +const result = await run.context.with( + ProviderRetries, + { spent: 0, limit: 5, unit: "retries" }, + async () => run.group(async (task) => { + const first = task(operation); + const second = task(operation); + return Promise.all([first, second]); + }), +); +``` + +The initial invocation is not a retry, so it is not charged. Before each +additional attempt is admitted, the wrapper atomically consumes one unit from +the scope-visible budget. When the budget is exhausted, the next retry body +does not start. + +This turns “three retries per call” into a policy that can also say “no more +than five additional provider invocations across this request.” + +## Check aggregate retry demand + +The planner can evaluate that shared policy when the caller supplies a runtime +snapshot: + +```ts +import { + planTimePolicy, + type RetryBudgetSnapshot, +} from "@workit/core/time-policy"; + +const retryBudgets: RetryBudgetSnapshot[] = [{ + key: ProviderRetries, + state: { + spent: 1, + limit: 5, + unit: "retries", + }, +}]; + +const providerPolicy = { + type: "retry" as const, + attempt: { + type: "attempt" as const, + duration: "800ms" as const, + }, + retry: { + times: 3, + initialDelay: "100ms" as const, + retryBudget: ProviderRetries, + }, +}; + +const plan = planTimePolicy({ + type: "parallel", + policies: [ + providerPolicy, + providerPolicy, + ], +}, { + retryBudgets, +}); +``` + +For every referenced budget, the result reports required retries, remaining +capacity and one of three statuses: + +```txt +admissible +exceeded +unverified +``` + +An absent snapshot produces `retry_budget_snapshot_missing`. Insufficient +capacity produces `retry_budget_exceeded`. Both make the plan invalid because +the declared composition cannot be admitted from the supplied state. + +## What the upper bound means + +For fixed retry delays, the planner includes attempt duration and the waits +between failed attempts. For parallel policies it distinguishes critical-path +time from aggregate parallel work. Timeout and deadline nodes can truncate the +outer bound while retaining a warning that inner work exceeds it. + +Jitter, dynamic backoff, event-loop stalls and provider latency add uncertainty. +The planner reports its bounded contract instead of presenting the result as an +exact wall-clock forecast. + +## Executable evidence + +The release connects these claims to: + +```txt +CORR-009 retry upper bounds +CORR-010 infeasible deadline warning +CORR-016 bounded time-policy cost model +CORR-021 nested composition model +CORR-024 effective runtime deadline +CORR-025 shared retry admission budget +CORR-027 aggregate retry budget planning +``` + +Run the proofs with: + +```sh +npm run test:evidence +npm run test:coverage +npm run verify +``` + +The bounded model currently checks 640 generated policies, while the nested +composition evidence checks 1,516 generated policy trees. These are executable +finite models, not theorems over arbitrary TypeScript or real provider timing. + +Planning does not remove runtime uncertainty. It catches policies that are +already impossible before that uncertainty begins. + +## Sources + +- [`@workit/core/time-policy`](../packages/core/src/time-policy/index.ts) +- [`time-policy-planner.mjs`](../packages/core/tests/evidence/correctness/time-policy-planner.mjs) +- [`runtime-contracts.mjs`](../packages/core/tests/evidence/correctness/runtime-contracts.mjs) +- [`claims.json`](../packages/core/evidence/claims.json) diff --git a/articles/10-terminal-activity-boundaries.md b/articles/10-terminal-activity-boundaries.md new file mode 100644 index 0000000..e31fbab --- /dev/null +++ b/articles/10-terminal-activity-boundaries.md @@ -0,0 +1,200 @@ + + +# A Terminal Activity Boundary After Restart + +A process uploads a batch and crashes after chunk 417 completes. When the job +starts again, it must decide whether chunk 417 should run. + +Running it twice may duplicate a side effect. Skipping it without durable +evidence is not much better. + +An activity boundary gives the operation an explicit identity, hashes its input +and stores its terminal result: + +```ts +import { run } from "@workit/core"; +import { + createFileActivityStore, + runActivity, +} from "@workit/core/activity"; + +const store = createFileActivityStore({ + dir: ".workit-activities", +}); + +// Application-owned provider boundary. The provider idempotency key remains +// separate from WorkIt's local activity record. +declare function uploadToProvider( + chunk: number, + opts: { signal: AbortSignal; idempotencyKey: string }, +): Promise<{ etag: string }>; + +const uploadChunk = runActivity( + store, + { + activityId: "upload:batch-42:chunk-417", + input: { + batchId: "batch-42", + chunk: 417, + checksum: "6d7fce9f", + }, + name: "upload chunk 417", + version: "v1", + }, + async (ctx) => { + return uploadToProvider(417, { + signal: ctx.signal, + idempotencyKey: "batch-42:chunk-417", + }); + }, +); + +const result = await run.scope(async (scope) => { + return scope.spawn(uploadChunk); +}); +``` + +After completion, a new process using the same store can submit the same +activity id and input. WorkIt returns the stored result without invoking the +body again. + +That is terminal activity replay. It is not transparent workflow replay. + +## The application chooses the durable boundary + +WorkIt does not persist arbitrary closures, JavaScript stacks or scheduler +state. The caller decides which segment has a durable identity. + +The activity contract records: + +```txt +activity id +activity version +canonical input hash +started timestamp +terminal status +result or bounded error evidence +typed cancellation reason when applicable +``` + +This explicit boundary is useful because external side effects rarely have +universal retry semantics. Uploading a chunk, charging a card and sending an +email need different idempotency and repair policies. + +## The same id with different input is a conflict + +An activity id cannot safely mean two things. + +```ts +import { + ActivityConflictError, + createMemoryActivityStore, + runActivity, +} from "@workit/core/activity"; + +const memoryStore = createMemoryActivityStore(); + +const first = runActivity( + memoryStore, + { activityId: "export:42", input: { page: 1 } }, + async () => "page one", +); + +await run.scope((scope) => scope.spawn(first)); + +const changed = runActivity( + memoryStore, + { activityId: "export:42", input: { page: 2 } }, + async () => "page two", +); + +try { + await run.scope((scope) => scope.spawn(changed)); +} catch (error) { + if (!(error instanceof ActivityConflictError)) throw error; +} +``` + +The second body does not run. WorkIt compares canonical input hashes and fails +at the boundary. + +Inputs that cannot produce a stable JSON representation are rejected. This +includes cyclic objects, functions, symbols, `bigint`, non-finite numbers and +`undefined`. + +## Completed records replay; uncertain records do not + +An activity record can be started, completed, failed or cancelled. Only a +completed record returns its stored result on a later invocation. + +Started, failed and cancelled records fail closed. WorkIt does not silently +rerun them because their external effects may be uncertain. A provider could +have accepted a request just before the process stopped, or a cancellation +could have arrived after a remote commit. + +The application can inspect the record and choose a repair policy. That may +mean querying the provider by an idempotency key, compensating a partial effect +or authorizing a new activity id. + +The runtime preserves evidence instead of guessing. + +## What restart evidence proves + +The release evidence runs the activity, discards the first store instance and +opens a fresh file-store instance over the same directory. A second execution +receives the saved result while the activity body remains at one invocation. + +This proves persistence across a store-shaped restart after a completed +terminal write. The current evidence does not claim multi-process coordination, +recovery after process termination or recovery of arbitrary in-flight work. + +## The application still owns external correctness + +Activity records do not replace provider idempotency keys, database +transactions, distributed locks or compensation logic. + +The reliable composition is: + +```txt +application chooses activity identity +provider receives its own idempotency key +WorkIt records terminal activity evidence +restart reuses completed evidence +uncertain states go through an explicit repair policy +``` + +This division keeps local lifecycle ownership separate from remote side-effect +authority. + +## Executable evidence + +The relevant proofs are: + +```txt +CORR-012 explicit activity boundary and conflict detection +LIFE-008 file activity store restart replay +``` + +Run them with: + +```sh +npm run test:evidence +npm run test:coverage +npm run verify +``` + +The unit suite also covers input canonicalization, corrupt records, safe file +names, terminal error evidence and cancellation reason persistence. + +The useful promise is intentionally bounded: for an explicit activity id and +matching input, WorkIt can reuse a completed terminal result after restart. + +## Sources + +- [`@workit/core/activity`](../packages/core/src/activity/index.ts) +- [`activity-boundary.mjs`](../packages/core/tests/evidence/correctness/activity-boundary.mjs) +- [`activity-restart.mjs`](../packages/core/tests/evidence/lifecycle/activity-restart.mjs) +- [`claims.json`](../packages/core/evidence/claims.json) diff --git a/articles/README.md b/articles/README.md index a57e3ea..37953f4 100644 --- a/articles/README.md +++ b/articles/README.md @@ -5,10 +5,11 @@ SPDX-License-Identifier: Apache-2.0 # WorkIt Article Series -Seven articles. Each opens with code and a concrete problem, then links the +Ten articles. Each opens with code and a concrete problem, then links the claim to executable evidence. The argument builds from plain `async` / `await` ownership to worker boundaries, streaming backpressure, budgeted cleanup, -observability, and finally agent tool lifecycles. +observability, agent tool lifecycles, lifecycle receipts, time-policy planning, +and explicit terminal activity boundaries. The sequence focuses on practical high-pressure workloads: AI agents, provider racing, streaming STT, 100K-document budget caps, 1-billion-row pipelines, @@ -24,6 +25,9 @@ worker hard-kill against a CPU spinner, local-first observability, and the 5. [`05-resource-safety-and-budgeted-work.md`](05-resource-safety-and-budgeted-work.md) -- bracketed cleanup, uncancellable sections, and request budgets. 6. [`06-observability-without-core-bloat.md`](06-observability-without-core-bloat.md) -- diagnostics, telemetry, sampling, and exporter isolation. 7. [`07-agent-scope-and-tool-lifecycles.md`](07-agent-scope-and-tool-lifecycles.md) -- agent tools, budgets, events, and replayable execution logs. +8. [`08-receipts-redaction-and-attempt-evidence.md`](08-receipts-redaction-and-attempt-evidence.md) -- lifecycle receipts, redaction, and terminal retry-attempt evidence. +9. [`09-plan-retries-before-they-run.md`](09-plan-retries-before-they-run.md) -- deadline introspection, conservative time planning, and shared retry admission. +10. [`10-terminal-activity-boundaries.md`](10-terminal-activity-boundaries.md) -- explicit activity identity and completed terminal replay after restart. ## Editorial Rules @@ -40,17 +44,17 @@ These numbers are reproducible from the gates and captured benchmark result. Use representative timing language unless a value is asserted by a gate. ```txt -214 unit tests, 100% line/branch/function coverage +375 unit tests, 100% statement/line/branch/function coverage 0 production dependencies, 0 install scripts, 0 networking imports in core dist -14,175 B core-group-import minified / 4,835 B gzip -29,255 B public-api minified / 9,694 B gzip -126,136 B max heap growth in 100k task soak @ concurrency 128 +13,807 B core-group-import minified / 4,842 B gzip +28,608 B public-api minified / 9,688 B gzip +100,000 logical tasks in the bounded runtime soak @ concurrency 128 1,000,000 logical items in stream memory gate, bounded heap 1,000,000,000 logical items in 1B claim sample, <= TAKE+CONCURRENCY produced well under the 10 ms gate for 100 .with() calls over 5,000 keys 200ms timeout vs CPU spinner: late-marker file does not exist 19 article-series benchmarks, all green -tracked claim evidence suite classified by lifecycle/correctness/security/release/performance +22 executable evidence files, all green ``` ## Reproducing The Receipts diff --git a/package-lock.json b/package-lock.json index 0efdd94..5646cd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "workit", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "workit", - "version": "0.4.0", + "version": "0.5.0", "license": "Apache-2.0", "workspaces": [ "packages/core" @@ -99,9 +99,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260507.1.tgz", - "integrity": "sha512-S85aMwcaPJUjKWDiG6iMMnioKWtPLACa6m0j/EhHR1GYfVpnxb974cBc6d25L+sf7jHWHJI2u5hGp0UTJ7MtXQ==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260722.1.tgz", + "integrity": "sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ==", "cpu": [ "x64" ], @@ -116,9 +116,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260507.1.tgz", - "integrity": "sha512-GMEBu8Zp9Q97HLnf7bWJN4KjWpN5MxpeqdvHjBGWNl8UYprJI0k+Jkp89+Wh5S8vIon+HoVbDfOzPa7VwgL6Eg==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260722.1.tgz", + "integrity": "sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw==", "cpu": [ "arm64" ], @@ -133,9 +133,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260507.1.tgz", - "integrity": "sha512-QlrKEBdgA3uVc0Ok0Q3+0/CW0CTjgj5ySir1i1YY5FXVv0X6GpwtnB5umjunjF2MFprss+L+iFGZzxcSvMC1nA==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260722.1.tgz", + "integrity": "sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ==", "cpu": [ "x64" ], @@ -150,9 +150,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260507.1.tgz", - "integrity": "sha512-eGbbupEtK2nh9V9Dhcx3vv3GTKeXqSVNgAEYVCCN0NGS9tl9HbMoHRX/4JL181FKXROMigWBCQVL//qPhsAzBQ==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260722.1.tgz", + "integrity": "sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg==", "cpu": [ "arm64" ], @@ -167,9 +167,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260507.1.tgz", - "integrity": "sha512-dmClJ/E0BAcuDetQIZFqbeAXejWrG5pysGRMQ6T83Y0IW/7IAamY2zFEkAJ10I5xwZsdHuYsZtzlOxpEXpJs7A==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260722.1.tgz", + "integrity": "sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA==", "cpu": [ "x64" ], @@ -207,10 +207,21 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -225,9 +236,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -242,9 +253,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -259,9 +270,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -276,9 +287,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -293,9 +304,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -310,9 +321,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -327,9 +338,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -344,9 +355,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -361,9 +372,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -378,9 +389,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -395,9 +406,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -412,9 +423,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -429,9 +440,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -446,9 +457,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -463,9 +474,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -480,9 +491,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -497,9 +508,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -514,9 +525,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -531,9 +542,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -548,9 +559,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -565,9 +576,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -582,9 +593,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -599,9 +610,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -616,9 +627,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -633,9 +644,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -660,9 +671,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", "cpu": [ "arm64" ], @@ -673,19 +684,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.1" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", "cpu": [ "x64" ], @@ -696,19 +707,39 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", "cpu": [ "arm64" ], @@ -723,9 +754,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", "cpu": [ "x64" ], @@ -740,9 +771,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", "cpu": [ "arm" ], @@ -757,9 +788,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", "cpu": [ "arm64" ], @@ -774,9 +805,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", "cpu": [ "ppc64" ], @@ -791,9 +822,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", "cpu": [ "riscv64" ], @@ -808,9 +839,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", "cpu": [ "s390x" ], @@ -825,9 +856,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", "cpu": [ "x64" ], @@ -842,9 +873,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", "cpu": [ "arm64" ], @@ -859,9 +890,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", "cpu": [ "x64" ], @@ -876,9 +907,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", "cpu": [ "arm" ], @@ -889,19 +920,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.1" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", "cpu": [ "arm64" ], @@ -912,19 +943,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.1" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", "cpu": [ "ppc64" ], @@ -935,19 +966,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.1" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", "cpu": [ "riscv64" ], @@ -958,19 +989,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.1" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", "cpu": [ "s390x" ], @@ -981,19 +1012,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.1" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", "cpu": [ "x64" ], @@ -1004,19 +1035,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.1" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", "cpu": [ "arm64" ], @@ -1027,19 +1058,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", "cpu": [ "x64" ], @@ -1050,39 +1081,56 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", "cpu": [ "wasm32" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.2" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", "cpu": [ "arm64" ], @@ -1093,16 +1141,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", "cpu": [ "ia32" ], @@ -1113,16 +1161,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", "cpu": [ "x64" ], @@ -1133,7 +1181,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1168,14 +1216,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -1198,9 +1246,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -1250,9 +1298,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -1267,9 +1315,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -1284,9 +1332,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -1301,9 +1349,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -1318,9 +1366,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -1335,9 +1383,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -1352,9 +1400,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -1369,9 +1417,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -1386,9 +1434,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -1403,9 +1451,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -1420,9 +1468,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -1437,9 +1485,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -1454,9 +1502,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -1464,18 +1512,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -1490,9 +1538,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -1507,9 +1555,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -1527,9 +1575,9 @@ } }, "node_modules/@speed-highlight/core": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", - "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", "dev": true, "license": "CC0-1.0" }, @@ -1541,9 +1589,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -1824,9 +1872,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1838,32 +1886,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/estree-walker": { @@ -2315,17 +2363,17 @@ } }, "node_modules/miniflare": { - "version": "4.20260507.1", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260507.1.tgz", - "integrity": "sha512-PSXBiLExTdZ4UGO/raKCHQauUpYL7F880ZRB7j0+78Rv8h7TsdN2E/iEDK9sK2Y+SPQ5wJSeAa+rDeVKoZZoEw==", + "version": "4.20260722.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260722.0.tgz", + "integrity": "sha512-LW6ABMhCx/yIEFBLC/DO4yAhdm2T/G7jp7pr5T2kj895+CCIaHZqpMXdW9O6YE48LcYcCJChwWc8aEs1vpbTXw==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", - "undici": "7.24.8", - "workerd": "1.20260507.1", - "ws": "8.18.0", + "sharp": "0.35.2", + "undici": "7.28.0", + "workerd": "1.20260722.1", + "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { @@ -2336,9 +2384,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -2387,9 +2435,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "peer": true, @@ -2401,9 +2449,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -2421,7 +2469,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2447,14 +2495,14 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -2463,27 +2511,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -2494,48 +2542,48 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", "dev": true, - "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.4" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" } }, "node_modules/siginfo": { @@ -2600,9 +2648,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -2649,9 +2697,9 @@ } }, "node_modules/undici": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", - "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -2677,18 +2725,18 @@ } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -2704,7 +2752,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -2864,9 +2912,9 @@ } }, "node_modules/workerd": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260507.1.tgz", - "integrity": "sha512-z7JhsFSe6+X1b5fUHaVpo15VM1IRMJiLofEkq8iKdCo+Veqc+FUg5lIsuz8NwePxuSKrXtO4ZQpGkQLbPVXFhg==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260722.1.tgz", + "integrity": "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -2878,30 +2926,31 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260507.1", - "@cloudflare/workerd-darwin-arm64": "1.20260507.1", - "@cloudflare/workerd-linux-64": "1.20260507.1", - "@cloudflare/workerd-linux-arm64": "1.20260507.1", - "@cloudflare/workerd-windows-64": "1.20260507.1" + "@cloudflare/workerd-darwin-64": "1.20260722.1", + "@cloudflare/workerd-darwin-arm64": "1.20260722.1", + "@cloudflare/workerd-linux-64": "1.20260722.1", + "@cloudflare/workerd-linux-arm64": "1.20260722.1", + "@cloudflare/workerd-windows-64": "1.20260722.1" } }, "node_modules/wrangler": { - "version": "4.89.1", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.89.1.tgz", - "integrity": "sha512-g0uA/F+lH/B6DgUcwe7tmkm3YKN6ITSMV73H0ouOYaaerS1ZVfX05lyoq/9a7UEMjlq0dlrnfFQDneWUINE4gw==", + "version": "4.114.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.114.0.tgz", + "integrity": "sha512-M65P25t5UHA1TIJfgZXDcj+YzVobgKdRguM2QPz0xnxLFuOcuE3ErgllDht0iaho7MS4o0g/Bb4YK2+GT+bibg==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", - "esbuild": "0.27.3", - "miniflare": "4.20260507.1", + "esbuild": "0.28.1", + "miniflare": "4.20260722.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260507.1" + "workerd": "1.20260722.1" }, "bin": { + "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" }, @@ -2909,10 +2958,10 @@ "node": ">=22.0.0" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260507.1" + "@cloudflare/workers-types": "^5.20260722.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -2920,494 +2969,10 @@ } } }, - "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -3453,17 +3018,17 @@ }, "packages/core": { "name": "@workit/core", - "version": "0.4.0", + "version": "0.5.0", "license": "Apache-2.0", "devDependencies": { "@opentelemetry/api": "1.9.1", "@types/node": "25.6.1", "@vitest/coverage-v8": "4.1.5", - "esbuild": "0.28.0", + "esbuild": "0.28.1", "fast-check": "4.7.0", "typescript": "6.0.3", "vitest": "4.1.5", - "wrangler": "4.89.1" + "wrangler": "4.114.0" }, "engines": { "node": ">=20.11" diff --git a/package.json b/package.json index 1aae669..e223289 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "workit", - "version": "0.4.0", + "version": "0.5.0", "private": true, "description": "WorkIt monorepo.", "type": "module", diff --git a/packages/core/README.md b/packages/core/README.md index 84f5fc0..aea5038 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -263,6 +263,36 @@ const chargeWithRetry = run.retry( const receipt = await group(async (task) => task(chargeWithRetry)); ``` +### Runtime Deadlines And Retry Budgets + +`ctx.deadlineAt` reports the earliest absolute deadline inherited from the +owning scope tree and active timeout/deadline wrappers. It is introspection over +the runtime contract; it does not make a task cooperative. Task bodies and +providers must still observe `ctx.signal`. + +`RetryOpts.retryBudget` accepts an existing scope budget key. WorkIt charges one +unit before admitting each additional attempt; the initial attempt is not +charged. Sharing the same key across retry wrappers creates one aggregate retry +limit without introducing a second budget system. + +```ts +import { createBudget, group, run } from "@workit/core"; + +const RetryBudget = createBudget("CheckoutRetryBudget", { unit: "retries" }); + +await run.context.with( + RetryBudget, + { limit: 4, spent: 0, unit: "retries" }, + () => group(async (task) => task(run.retry( + async (ctx) => callProvider({ signal: ctx.signal, deadlineAt: ctx.deadlineAt }), + { times: 3, retryBudget: RetryBudget } + ))) +); +``` + +The budget must be installed in the visible scope context. Exceeding it uses the +existing atomic budget charge and typed budget cancellation path. + `renderTree()` takes a scope snapshot, not a live scope. Use `renderTree(scope.status())`. @@ -294,6 +324,88 @@ ownership helpers live behind explicit subpaths. | Declare cancellable and shielded task intent | `@workit/core/contracts` | compile-time composition contract, not proof of task-body cooperation | | Run bounded lifecycle fault scenarios | `@workit/core/fault` | in-process evidence harness, not OS/process/network fault injection | +### Idempotency Boundaries + +`TaskOpts.idempotencyKey` coalesces concurrent tasks with the same key inside +one live scope. The entry is removed when that task settles; it is not a durable +deduplication record. Use `@workit/core/activity` with a caller-owned store for +terminal replay across process restarts, and `@workit/core/ledger` when lifecycle +receipts must be persisted independently. + +### Attempt Evidence + +For a scheduled task wrapped by `run.retry()`, the outer retry boundary emits +one terminal `task:attempt` event for every attempt it admits. Nested retry +wrappers remain internal to that task attempt. Receipts built from the scope +event stream derive attempt number, start/end timing, duration, and outcome +automatically. + +When upgrading from `0.4.x`, exhaustive `TaskEvent` switches must add the new +variant. Keep the exhaustive `never` check so future event additions continue +to fail at compile time: + +```ts +import type { TaskEvent } from "@workit/core"; + +const attempts: Array<{ + attempt: number; + outcome: "succeeded" | "failed" | "cancelled"; + durationMs: number; +}> = []; + +function handleEvent(event: TaskEvent): void { + switch (event.type) { + case "task:attempt": + attempts.push({ + attempt: event.attempt, + outcome: event.outcome, + durationMs: event.durationMs, + }); + return; + // Keep existing TaskEvent cases here. + default: { + const unhandled: never = event; + throw new Error(`Unhandled WorkIt event: ${JSON.stringify(unhandled)}`); + } + } +} +``` + +`createAttemptRecorder()` is the explicit enrichment layer for caller-owned +metadata, stable reason codes, and provider/activity-level attempts. Place it +inside the relevant `run.retry()` boundary so each recorded invocation receives +that boundary's actual `ctx.attempt` value. + +```ts +import { run } from "@workit/core"; +import { createAttemptRecorder, createReceiptRecorder } from "@workit/core/replay"; + +const attempts = createAttemptRecorder({ maxAttempts: 20 }); +const operation = run.retry(attempts.wrap(callProvider, { + metadata: { provider: "primary" }, + reasonCode: classifyProviderFailure, +}), { times: 3 }); + +let observedScope; +let receiptRecorder; +await run.scope(async (scope) => { + observedScope = scope; + receiptRecorder = createReceiptRecorder(scope); + await scope.spawn(operation); +}); + +const receipt = receiptRecorder.build(observedScope.status(), { + attempts: attempts.attempts, +}); +receiptRecorder.unsubscribe(); +``` + +Attempt metadata must be JSON-serializable, is bounded by UTF-8 byte length, +and applies default secret-field redaction. Reason classifiers are +caller-owned, isolated from task failure, and accepted only when they return a +bounded lowercase code. This records observed attempts; it does not infer +provider policy or provide deterministic replay. + ### Time Policy Planning `@workit/core/time-policy` lets callers inspect declared time policies before @@ -319,6 +431,36 @@ if (plan.warnings.some((warning) => warning.code === "retry_exceeds_timeout")) { } ``` +Retry policies may reference the same budget key used at runtime. Supply an +explicit point-in-time snapshot to verify aggregate admission across series, +parallel, hedge, and nested retry compositions: + +```ts +import { createBudget } from "@workit/core"; +import { planTimePolicy } from "@workit/core/time-policy"; + +const RetryBudget = createBudget("ProviderRetryBudget", { unit: "retries" }); +const plan = planTimePolicy({ + type: "retry", + attempt: { type: "attempt", duration: "100ms" }, + retry: { times: 4, retryBudget: RetryBudget }, +}, { + retryBudgets: [{ + key: RetryBudget, + state: { limit: 5, spent: 2, unit: "retries" }, + }], +}); + +if (plan.retryBudgets[0]?.status !== "admissible") { + rejectAdmission(plan); +} +``` + +Budget demand is conservative: retries nested under retry or hedge policies are +multiplied by the maximum declared invocations. A missing snapshot is reported +as unverified, and a snapshot with insufficient remaining capacity makes the +plan invalid. Runtime consumption after planning can still change availability. + The planner does not execute JavaScript, inspect provider latency, or guarantee operating-system timer precision. Runtime cancellation still depends on task bodies and providers observing `ctx.signal`. @@ -529,13 +671,13 @@ thresholds, not exact milliseconds. | Evidence | Current result | |---|---:| -| Unit tests | 354 passing | +| Unit tests | 375 passing | | Coverage gate | 100% statements, branches, functions, lines | | Evidence proof files | 22 passing | | Runtime dependencies | 0 | | Article benchmark suite | 19/19 passing | -| Core group import | 14,175 B minified / 4,835 B gzip | -| Public bundle | 29,255 B minified / 9,694 B gzip | +| Core group import | 13,807 B minified / 4,842 B gzip | +| Public bundle | 28,608 B minified / 9,688 B gzip | | Stream gate | 1,000,000 logical items with bounded producer growth | | Soak gate | 100,000 logical tasks with bounded concurrency | | Exporter stress | 100,000 events with bounded queue | @@ -710,7 +852,7 @@ cite the software release you used: title = {WorkIt: A TypeScript Structured Concurrency Runtime for Node.js Server Runtimes}, year = {2026}, url = {https://github.com/WorkRuntime/workit}, - version = {0.4.0}, + version = {0.5.0}, license = {Apache-2.0} } ``` diff --git a/packages/core/evidence/claims.json b/packages/core/evidence/claims.json index 7b06ca0..a727195 100644 --- a/packages/core/evidence/claims.json +++ b/packages/core/evidence/claims.json @@ -97,6 +97,16 @@ "expectedInvariant": "resource audit instrumentation records acquired/released/pending resources while WorkIt emits cleanup timeout and failure events", "limitations": "Successful resource acquisition and release entries are explicit audit instrumentation in the evidence harness. WorkIt's current first-class event surface exposes cleanup failures and timeouts, not every successful cleanup start/completion." }, + { + "id": "LIFE-012", + "title": "attempt evidence records admitted retry invocations", + "class": "lifecycle", + "status": "proven", + "proof": "tests/evidence/lifecycle/replay-receipts.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "each admitted invocation records its attempt number and outcome while bounded metadata applies default secret redaction", + "limitations": "Attempt recording wraps explicit task bodies. It does not infer provider policy, prove remote cancellation, or provide deterministic scheduler replay." + }, { "id": "CORR-001", "title": "budget inputs are immutable boundary values", @@ -257,6 +267,46 @@ "expectedInvariant": "tsc accepts declared cancellable/shielded composition and rejects plain or misrouted tasks", "limitations": "This proves an optional compile-time intent contract at the contracts subpath. It does not prove that arbitrary task bodies observe ctx.signal or change the root WorkIt API." }, + { + "id": "CORR-024", + "title": "task context exposes the effective composed deadline", + "class": "correctness", + "status": "proven", + "proof": "tests/evidence/correctness/runtime-contracts.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "nested retry and fallback attempts observe the absolute deadline declared by the owning wrapper", + "limitations": "Deadline introspection reports runtime policy. Timer precision and task termination still depend on the event loop and cooperative AbortSignal handling." + }, + { + "id": "CORR-025", + "title": "shared retry budget blocks excess attempts before execution", + "class": "correctness", + "status": "proven", + "proof": "tests/evidence/correctness/runtime-contracts.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "retry wrappers sharing one scope budget cannot execute a retry after the aggregate budget is exhausted", + "limitations": "The budget counts retries admitted by WorkIt wrappers. External provider attempts outside the wrapped task remain application responsibility." + }, + { + "id": "CORR-026", + "title": "deadline retry fallback cancellation and receipt evidence compose", + "class": "correctness", + "status": "proven", + "proof": "tests/evidence/correctness/runtime-contracts.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "a cancelled retry/fallback composition preserves its effective deadline and records each admitted attempt in its terminal receipt", + "limitations": "The proof exercises cooperative in-process cancellation. It does not prove provider cancellation or wall-clock scheduler precision." + }, + { + "id": "CORR-027", + "title": "time-policy planner aggregates shared retry budget demand", + "class": "correctness", + "status": "proven", + "proof": "tests/evidence/correctness/time-policy-planner.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "nested retry policies referencing one budget key are inadmissible when aggregate demand exceeds the supplied runtime budget snapshot", + "limitations": "Planning uses an explicit point-in-time budget snapshot. Concurrent runtime consumption after planning can reduce the remaining budget before execution." + }, { "id": "SEC-001", "title": "worker offload rejects remote and executable URL schemes", diff --git a/packages/core/package.json b/packages/core/package.json index d19ccc3..4f5c3f3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@workit/core", - "version": "0.4.0", + "version": "0.5.0", "description": "Structured concurrency runtime for TypeScript: owned async work, cancellation, budgets, retries, timeouts, worker offload, scopes.", "keywords": [ "structured-concurrency", @@ -207,8 +207,8 @@ "sample:otel": "npm run build && node samples/otel-adapter.sample.js", "sample:logging": "npm run build && node samples/logging-otel-bridge.sample.js", "soak:24h": "npm run build && node --expose-gc scripts/soak-24h.mjs", - "test": "npm run build && vitest run", - "test:coverage": "npm run build && vitest run --coverage", + "test": "npm run build && vitest run --maxWorkers=1", + "test:coverage": "npm run build && vitest run --coverage --maxWorkers=1", "verify": "npm run typecheck && npm run check:no-network && npm run check:headers && npm run check:tests && npm test && npm run check:security && npm run check:vulnerabilities && npm run check:sbom && npm run check:api && npm run check:size && npm run check:benchmark && npm run check:context-performance && npm run check:1b && npm run check:leak && npm run check:stream-memory && npm run check:soak && npm run check:exporter-stress && npm run check:package-consumer && npm run check:claims && npm run check:public-proof && npm run check:worker-contract && npm run check:release-policy && npm run pack:dry" }, "engines": { @@ -226,10 +226,10 @@ "@opentelemetry/api": "1.9.1", "@types/node": "25.6.1", "@vitest/coverage-v8": "4.1.5", - "esbuild": "0.28.0", + "esbuild": "0.28.1", "fast-check": "4.7.0", "typescript": "6.0.3", "vitest": "4.1.5", - "wrangler": "4.89.1" + "wrangler": "4.114.0" } } diff --git a/packages/core/scripts/check-api-surface.mjs b/packages/core/scripts/check-api-surface.mjs index 9f019dc..a939a03 100644 --- a/packages/core/scripts/check-api-surface.mjs +++ b/packages/core/scripts/check-api-surface.mjs @@ -120,6 +120,7 @@ const EXPECTED_RUNTIME_EXPORTS = { ], "./replay": [ "buildReceipt", + "createAttemptRecorder", "createReceiptRecorder", "redactReceipt", ], diff --git a/packages/core/scripts/check-bundle-size.mjs b/packages/core/scripts/check-bundle-size.mjs index 4688b34..3bfefe5 100644 --- a/packages/core/scripts/check-bundle-size.mjs +++ b/packages/core/scripts/check-bundle-size.mjs @@ -19,14 +19,14 @@ const BUDGETS = [ { name: "public-api", source: `export * from "${DIST_ENTRY}";`, - maxMinifiedBytes: 29_500, - maxGzipBytes: 9_700, + maxMinifiedBytes: 29_000, + maxGzipBytes: 9_690, }, { name: "core-group-import", source: `export { group } from "${DIST_ENTRY}";`, - maxMinifiedBytes: 15_000, - maxGzipBytes: 5_000, + maxMinifiedBytes: 14_000, + maxGzipBytes: 4_900, }, ]; diff --git a/packages/core/scripts/check-package-consumer.mjs b/packages/core/scripts/check-package-consumer.mjs index e67f33a..879029a 100644 --- a/packages/core/scripts/check-package-consumer.mjs +++ b/packages/core/scripts/check-package-consumer.mjs @@ -21,14 +21,19 @@ const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url); const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); const tscCli = require.resolve("typescript/bin/tsc"); -const wranglerJsCli = require.resolve("wrangler/bin/wrangler.js"); +const wranglerPackagePath = require.resolve("wrangler/package.json"); +const wranglerPackage = JSON.parse(await readFile(wranglerPackagePath, "utf8")); +const wranglerBin = typeof wranglerPackage.bin === "string" + ? wranglerPackage.bin + : wranglerPackage.bin?.wrangler; +if (typeof wranglerBin !== "string") throw new Error("Wrangler package must declare its CLI in package.json#bin."); +const wranglerJsCli = resolve(dirname(wranglerPackagePath), wranglerBin); const bunCli = await findExecutable(["bun.exe", "bun"], [join(homedir(), ".bun", "bin", "bun.exe")]); const denoCli = await findExecutable(["deno.exe", "deno"], [join(homedir(), ".deno", "bin", "deno.exe")]); const wranglerCli = await findExecutable( ["wrangler.cmd", "wrangler"], [ wranglerJsCli, - join(ROOT, "node_modules", "wrangler", "bin", "wrangler.js"), join(ROOT, "node_modules", ".bin", "wrangler.cmd"), join(homedir(), "node_modules", ".bin", "wrangler.cmd"), ] @@ -108,7 +113,7 @@ try { }); await writeFile(join(temp, "smoke.mjs"), ` - import { run, work, group } from "@workit/core"; + import { createBudget, run, work, group } from "@workit/core"; import { ActivitySerializationError, createFileActivityStore, createMemoryActivityStore, runActivity } from "@workit/core/activity"; import { analyzeReceipt, verifyReceipt, verifySourceProtocol } from "@workit/core/analysis"; import { AgentCapabilityError, embedAll, runAgent, streamWithBackpressure } from "@workit/core/ai"; @@ -117,12 +122,35 @@ try { import { createMemoryReceiptLedger, createPostgresReceiptLedger, createSqliteReceiptLedger } from "@workit/core/ledger"; import { attachTelemetryExporter } from "@workit/core/observability"; import { attachOpenTelemetry } from "@workit/core/otel"; - import { buildReceipt } from "@workit/core/replay"; + import { buildReceipt, createAttemptRecorder } from "@workit/core/replay"; import { bracketLazy } from "@workit/core/resources"; import { planTimePolicy } from "@workit/core/time-policy"; import { offload } from "@workit/core/worker"; const result = await run.all([async () => "sdk", async () => "ok"]); + const deadlineAt = Date.now() + 1_000; + const observedDeadline = await group(async (task) => + task(run.deadline(async (ctx) => ctx.deadlineAt, deadlineAt)) + ); + const RetryBudget = createBudget("ConsumerRetryBudget", { unit: "retries" }); + const attemptEvidence = createAttemptRecorder({ maxAttempts: 2 }); + let retryAttempts = 0; + const retried = await run.context.with( + RetryBudget, + { limit: 1, spent: 0, unit: "retries" }, + async () => group(async (task) => task(run.retry(attemptEvidence.wrap(async () => { + retryAttempts++; + if (retryAttempts === 1) throw new Error("retry once"); + return "retried"; + }), { times: 2, retryBudget: RetryBudget }))), + ); + const retryPlan = planTimePolicy({ + type: "retry", + attempt: { type: "attempt", duration: 1 }, + retry: { times: 2, retryBudget: RetryBudget }, + }, { + retryBudgets: [{ key: RetryBudget, state: { limit: 1, spent: 0 } }], + }); const batch = await work([1, 2]).inParallel(2).do(async (item) => item * 2); const embedded = await embedAll(["a"], { embed: async (text) => [text.length] }, { concurrency: 1 }); let denied = false; @@ -169,6 +197,10 @@ try { }); if (result.join(":") !== "sdk:ok") throw new Error("root import failed"); + if (observedDeadline !== deadlineAt) throw new Error("deadline introspection failed"); + if (retried !== "retried" || retryAttempts !== 2) throw new Error("retry budget failed"); + if (retryPlan.retryBudgets[0]?.status !== "admissible") throw new Error("retry budget planning failed"); + if (attemptEvidence.attempts.length !== 2) throw new Error("attempt evidence failed"); if (batch.results.join(":") !== "2:4") throw new Error("work import failed"); if (embedded.results[0][0] !== 1) throw new Error("ai import failed"); if (!denied) throw new Error("AI authority import failed"); @@ -265,14 +297,40 @@ try { }); await writeFile(join(temp, "cjs-smoke.cjs"), ` - const { run, work } = require("@workit/core"); + const { createBudget, run, work } = require("@workit/core"); const { cancellable, typedGroup } = require("@workit/core/contracts"); + const { planTimePolicy } = require("@workit/core/time-policy"); (async () => { const values = await run.all([async () => "cjs", async () => "ok"]); + const deadlineAt = Date.now() + 1_000; + const observedDeadline = await run.group(async (task) => + task(run.deadline(async (ctx) => ctx.deadlineAt, deadlineAt)) + ); + const RetryBudget = createBudget("CjsRetryBudget", { unit: "retries" }); + let retryAttempts = 0; + const retried = await run.context.with( + RetryBudget, + { limit: 1, spent: 0, unit: "retries" }, + async () => run.group(async (task) => task(run.retry(async () => { + retryAttempts++; + if (retryAttempts === 1) throw new Error("retry once"); + return "retried"; + }, { times: 2, retryBudget: RetryBudget }))), + ); + const retryPlan = planTimePolicy({ + type: "retry", + attempt: { type: "attempt", duration: 1 }, + retry: { times: 2, retryBudget: RetryBudget }, + }, { + retryBudgets: [{ key: RetryBudget, state: { limit: 1, spent: 0 } }], + }); const output = await work([1, 2, 3]).inParallel(2).do(async (item) => item + 1); const typed = await typedGroup(async (spawn) => await spawn(cancellable(async () => "contracts"))); if (values.join(":") !== "cjs:ok") throw new Error("CommonJS root import failed"); + if (observedDeadline !== deadlineAt) throw new Error("CommonJS deadline introspection failed"); + if (retried !== "retried" || retryAttempts !== 2) throw new Error("CommonJS retry budget failed"); + if (retryPlan.retryBudgets[0]?.status !== "admissible") throw new Error("CommonJS retry budget planning failed"); if (output.results.join(":") !== "2:3:4") throw new Error("CommonJS work import failed"); if (typed !== "contracts") throw new Error("CommonJS contracts import failed"); })().catch((err) => { @@ -304,6 +362,7 @@ try { import { ContextBagImpl, CostBudget, + createBudget, createContextKey, group, run, @@ -315,6 +374,7 @@ try { type Scope, type ScopeSnapshot, type TaskContext, + type TaskEvent, } from "@workit/core"; import { ActivitySerializationError, @@ -351,11 +411,18 @@ try { type ReceiptLedgerRecord, type SqliteReceiptLedgerClient, } from "@workit/core/ledger"; - import { buildReceipt, type WorkItReceipt } from "@workit/core/replay"; + import { buildReceipt, createAttemptRecorder, type WorkItReceipt } from "@workit/core/replay"; import { bracketLazy, type LazyResource } from "@workit/core/resources"; - import { planTimePolicy, type TimePlan } from "@workit/core/time-policy"; + import { + planTimePolicy, + type RetryBudgetSnapshot, + type TimePlan, + type TimePlanRetryBudget, + } from "@workit/core/time-policy"; const RequestKey = createContextKey<{ requestId: string }>("request"); + const RetryBudget = createBudget("StrictRetryBudget", { unit: "retries" }); + const attemptEvidence = createAttemptRecorder({ maxAttempts: 2 }); const tuple: readonly [number, string] = await run.all([ async () => 1, @@ -370,6 +437,18 @@ try { }, { context: new ContextBagImpl().with(RequestKey, { requestId: "strict" }), }); + const deadlineAt = Date.now() + 1_000; + const observedDeadline: number | undefined = await group(async (task) => + task(run.deadline(async (ctx: TaskContext) => ctx.deadlineAt, deadlineAt)) + ); + const retried: string = await run.context.with( + RetryBudget, + { limit: 1, spent: 0, unit: "retries" }, + async () => group(async (task) => task(run.retry(async () => "strict-retry", { + times: 2, + retryBudget: RetryBudget, + }))), + ); const embedded = await embedAll(["abc"], { async embed(input: string) { @@ -381,6 +460,20 @@ try { if (tuple[0] !== 1 || tuple[1] !== "typed") throw new Error("tuple inference failed"); if (value !== "strict") throw new Error("context inference failed"); + if (observedDeadline !== deadlineAt) throw new Error("deadline inference failed"); + if (retried !== "strict-retry") throw new Error("retry budget inference failed"); + if (attemptEvidence.attempts.length !== 0) throw new Error("attempt recorder inference failed"); + const attemptEvent: TaskEvent = { + type: "task:attempt", + taskId: "strict-task" as TaskContext["id"], + attempt: 1, + durationMs: 1, + outcome: "succeeded", + at: 2, + }; + if (attemptEvent.type !== "task:attempt" || attemptEvent.outcome !== "succeeded") { + throw new Error("attempt event inference failed"); + } if (embedded.mode !== "fail") throw new Error("unexpected embedAll mode"); if (embedded.results[0]?.[0] !== 3) throw new Error("AI helper inference failed"); if (streamed[0] !== "TYPED") throw new Error("AI stream helper inference failed"); @@ -409,8 +502,20 @@ try { void fileActivityStore; void ActivitySerializationError; - const timePlan: TimePlan = planTimePolicy({ type: "attempt", duration: "1s" }); - if (timePlan.upperBoundMs !== 1_000) throw new Error("time-policy planner inference failed"); + const retryBudgetSnapshot: RetryBudgetSnapshot = { + key: RetryBudget, + state: { limit: 1, spent: 0, unit: "retries" }, + }; + const timePlan: TimePlan = planTimePolicy({ + type: "retry", + attempt: { type: "attempt", duration: "1s" }, + retry: { times: 2, initialDelay: 0, jitter: false, retryBudget: RetryBudget }, + }, { + retryBudgets: [retryBudgetSnapshot], + }); + const plannedRetryBudget: TimePlanRetryBudget | undefined = timePlan.retryBudgets[0]; + if (timePlan.upperBoundMs !== 2_000) throw new Error("time-policy planner inference failed"); + if (plannedRetryBudget?.status !== "admissible") throw new Error("retry budget planner inference failed"); const plainTask = async () => "plain"; const typedTask: CancellableTask = cancellable(async () => "typed-contract"); diff --git a/packages/core/scripts/check-release-provenance.mjs b/packages/core/scripts/check-release-provenance.mjs index 70956c7..3f3a930 100644 --- a/packages/core/scripts/check-release-provenance.mjs +++ b/packages/core/scripts/check-release-provenance.mjs @@ -56,6 +56,19 @@ assert.match(workflow, /npm publish --workspace @workit\/core --provenance --acc assert.match(workflow, /npm run verify/u, "release workflow must run full verification before publish"); assert.match(workflow, /npm run test:coverage/u, "release workflow must run coverage before publish"); assert.match(workflow, /gpg\.ssh\.allowedSignersFile/u, "release workflow must configure SSH allowed signers before tag verification"); +assert.match(workflow, /fetch-depth:\s*0/u, "release workflow must fetch signed tag objects and release history"); +assert.ok( + workflow.includes('test "$GITHUB_REF_TYPE" = "tag"'), + "non-dry-run publishing must require a tag ref" +); +assert.ok( + workflow.includes('test "$GITHUB_REF_NAME" = "v$package_version"'), + "release tag must match the package version" +); +assert.ok( + workflow.includes('git tag -v "$GITHUB_REF_NAME"'), + "release workflow must verify the selected signed tag" +); assert.match(workflow, /oven-sh\/setup-bun@[a-f0-9]{40}/u, "release workflow must provision Bun for package-consumer verification"); assert.match(workflow, /denoland\/setup-deno@[a-f0-9]{40}/u, "release workflow must provision Deno for package-consumer verification"); assert.match(workflow, /bun-version:\s*"1\.3\.13"/u, "release workflow must pin the Bun fixture version"); diff --git a/packages/core/src/engine/duration.ts b/packages/core/src/engine/duration.ts index fe868ad..9bcce26 100644 --- a/packages/core/src/engine/duration.ts +++ b/packages/core/src/engine/duration.ts @@ -43,7 +43,7 @@ export function parseDuration(d: Duration): number { } const m = PATTERN.exec(d); if (!m) { - throw new RangeError(`Invalid duration string: "${d}". Use e.g. "500ms", "3s", "5m", "2h".`); + throw new RangeError(`Invalid duration string: "${d}"; use 500ms or 3s`); } const n = Number(m[1]); const unit = m[2] as keyof typeof MULTIPLIERS; diff --git a/packages/core/src/engine/event-bus.ts b/packages/core/src/engine/event-bus.ts index d51baf5..31a5fd3 100644 --- a/packages/core/src/engine/event-bus.ts +++ b/packages/core/src/engine/event-bus.ts @@ -59,29 +59,41 @@ export class EventBus { * Telemetry budget exhaustion never affects task execution. */ emit(event: TaskEvent, ctx: ContextBag | null = null): void { - if (!this.hasAnyHandler()) return; + try { + let target: EventBus | null = this; + while (target !== null && target.handlers.size === 0) target = target.parent; + if (target === null) return; - if (ctx) { - const budget = getTelemetryBudget(ctx); - if (budget && budget.spent >= budget.limit) { - if (event.type === "scope:closed") { - this.dispatch({ - ...event, - droppedTelemetryEvents: Math.max(event.droppedTelemetryEvents ?? 0, this.droppedCount), - }); + if (ctx) { + const budget = getTelemetryBudget(ctx); + if (budget && budget.spent >= budget.limit) { + if (event.type === "scope:closed") { + this.dispatch({ + ...event, + droppedTelemetryEvents: Math.max(event.droppedTelemetryEvents ?? 0, this.droppedCount), + }); + return; + } + if (!this.overrunWarned) { + this.overrunWarned = true; + this.dispatch({ + type: "task:progress", + taskId: "telemetry-bus" as unknown as import("../types/index.js").TaskId, + message: "telemetry budget exceeded", + data: { telemetry_budget_exceeded: true, limit: budget.limit, spent: budget.spent }, + at: Date.now(), + }); + } + if (this.droppedCount < Number.MAX_SAFE_INTEGER) this.droppedCount++; return; } - if (!this.overrunWarned) { - this.overrunWarned = true; - this.emitOverrunWarning(budget); - } - this.recordDrop(); - return; + if (budget) budget.spent++; } - if (budget) budget.spent++; - } - this.dispatch(event); + this.dispatch(event); + } catch { + // Telemetry context and observers are caller-owned and cannot affect task lifecycle. + } } private dispatch(event: TaskEvent): void { @@ -92,33 +104,10 @@ export class EventBus { } } - private hasAnyHandler(): boolean { - for (let bus: EventBus | null = this; bus !== null; bus = bus.parent) { - if (bus.handlers.size > 0) return true; - } - return false; - } - - private emitOverrunWarning(budget: BudgetState): void { - // Synthesize a single progress event flagging the overrun. - // We dispatch directly (bypass budget gate to avoid recursion). - this.dispatch({ - type: "task:progress", - taskId: "telemetry-bus" as unknown as import("../types/index.js").TaskId, - message: "telemetry budget exceeded", - data: { telemetry_budget_exceeded: true, limit: budget.limit, spent: budget.spent }, - at: Date.now(), - }); - } - /** Returns events dropped by this bus because the telemetry budget was exhausted. */ droppedEventCount(): number { return this.droppedCount; } - - private recordDrop(): void { - if (this.droppedCount < Number.MAX_SAFE_INTEGER) this.droppedCount++; - } } function getTelemetryBudget(context: ContextBag): MutableBudgetState | undefined { diff --git a/packages/core/src/engine/scope.ts b/packages/core/src/engine/scope.ts index 48b73cb..5614275 100644 --- a/packages/core/src/engine/scope.ts +++ b/packages/core/src/engine/scope.ts @@ -143,10 +143,7 @@ export class ScopeImpl implements Scope { if (parent) parent.childScopes.add(this); if (opts.deadline) this.deadline(opts.deadline); - this.bus.emit( - { type: "scope:opened", scopeId: this.id, parentId: parent?.id ?? null, at: Date.now() }, - this.context - ); + this.emit({ type: "scope:opened", scopeId: this.id, parentId: parent?.id ?? null, at: Date.now() }); } // -- R6: spawn a scoped child task ------------------------------------ @@ -162,11 +159,10 @@ export class ScopeImpl implements Scope { throw new Error(`Cannot spawn on a ${this.state} scope`); } if (opts.name !== undefined) assertBoundedString("task name", opts.name, MAX_TASK_NAME_LENGTH); - if (opts.idempotencyKey !== undefined) { - assertBoundedString("idempotency key", opts.idempotencyKey, MAX_IDEMPOTENCY_KEY_LENGTH); - } - if (opts.idempotencyKey !== undefined) { - const existing = this.idempotencyHandles.get(opts.idempotencyKey); + const idempotencyKey = opts.idempotencyKey; + if (idempotencyKey !== undefined) { + assertBoundedString("idempotency key", idempotencyKey, MAX_IDEMPOTENCY_KEY_LENGTH); + const existing = this.idempotencyHandles.get(idempotencyKey); if (existing !== undefined) return existing as TaskHandle; } assertNoTaskPolicyShortcuts(opts); @@ -188,7 +184,7 @@ export class ScopeImpl implements Scope { const getTaskSignal = () => taskSignal ||= AbortSignal.any([this.signal, (taskAbort ||= new AbortController()).signal]); - const ctx = this.makeTaskContext(id, name, kind, getTaskSignal, defers, cleanupTimeoutMs); + const ctx = this.taskContext(id, name, kind, getTaskSignal, defers, cleanupTimeoutMs); const record: TaskRecord = { id, name, kind, @@ -205,10 +201,7 @@ export class ScopeImpl implements Scope { }; this.tasks.set(id, record as TaskRecord); - this.bus.emit( - { type: "task:started", taskId: id, scopeId: this.id, name, kind, at: startedAt }, - this.context - ); + this.emit({ type: "task:started", taskId: id, scopeId: this.id, name, kind, at: startedAt }); const promise = (async () => { record.status = "running"; @@ -222,20 +215,7 @@ export class ScopeImpl implements Scope { outcome = { ok: true, value }; } catch (err) { record.endedAt = Date.now(); - if (err instanceof TimeoutError) { - record.status = "failed"; - if (!record.background && this.firstChildFailure === undefined) { - this.firstChildFailure = err; - this.cancel({ kind: "sibling_failed", siblingId: id, error: err }); - } - terminalEvent = { - type: "task:failed", - taskId: id, - error: err, - durationMs: record.endedAt - startedAt, - at: record.endedAt, - }; - } else if (err instanceof CancellationError) { + if (err instanceof CancellationError && !(err instanceof TimeoutError)) { record.status = "cancelled"; terminalEvent = { type: "task:cancelled", @@ -267,27 +247,18 @@ export class ScopeImpl implements Scope { const record = defers.pop()!; try { if (await runCleanup(record) === "timed_out") { - this.bus.emit( - { type: "task:cleanup_timeout", taskId: id, timeoutMs: record.timeoutMs, at: Date.now() }, - this.context - ); + this.emit({ type: "task:cleanup_timeout", taskId: id, timeoutMs: record.timeoutMs, at: Date.now() }); } } catch (cleanupErr) { - this.bus.emit( - { type: "task:cleanup_failed", taskId: id, error: cleanupErr, at: Date.now() }, - this.context - ); + this.emit({ type: "task:cleanup_failed", taskId: id, error: cleanupErr, at: Date.now() }); } } } finally { - if (opts.idempotencyKey !== undefined) this.idempotencyHandles.delete(opts.idempotencyKey); + if (idempotencyKey !== undefined) this.idempotencyHandles.delete(idempotencyKey); } - /* v8 ignore next -- each task execution path assigns a terminal event. */ - if (terminalEvent !== undefined) this.bus.emit(terminalEvent, this.context); - /* v8 ignore next -- each task execution path assigns an outcome. */ - if (outcome === undefined) throw new Error("Task outcome missing"); - if (outcome.ok) return outcome.value; + this.emit(terminalEvent!); + if (outcome!.ok) return outcome.value; throw outcome.error; })(); @@ -307,7 +278,7 @@ export class ScopeImpl implements Scope { }, }); - if (opts.idempotencyKey !== undefined) this.idempotencyHandles.set(opts.idempotencyKey, handle); + if (idempotencyKey !== undefined) this.idempotencyHandles.set(idempotencyKey, handle); return handle; } @@ -326,18 +297,20 @@ export class ScopeImpl implements Scope { if (typeof reason === "string") assertBoundedString("manual cancel tag", reason, MAX_TASK_NAME_LENGTH); const r: CancelReason = typeof reason === "string" ? { kind: "manual", tag: reason } : reason; this.state = "cancelling"; - if (this.deadlineTimer) clearTimeout(this.deadlineTimer); try { this.ownAbort.abort(new CancellationError(r)); } catch { /* already aborted */ } for (const handler of this.cancelHandlers) { try { handler(r); } catch { /* cancel handler errors must not propagate */ } } - this.emitClosing(classifyClosingReason(r)); + this.closeEvent(classifyClosingReason(r)); } /** Installs a relative deadline that cancels this scope when elapsed. */ deadline(d: Duration): void { const ms = parseDuration(d); - this.deadlineAt = Date.now() + ms; + const deadlineAt = Date.now() + ms; + if (this.deadlineAt !== undefined && this.deadlineAt <= deadlineAt) return; + clearTimeout(this.deadlineTimer); + this.deadlineAt = deadlineAt; this.deadlineTimer = setTimeout(() => { this.cancel({ kind: "deadline", @@ -397,32 +370,23 @@ export class ScopeImpl implements Scope { const record = this.defers.pop()!; try { if (await runCleanup(record) === "timed_out") { - this.bus.emit( - { type: "scope:cleanup_timeout", scopeId: this.id, timeoutMs: record.timeoutMs, at: Date.now() }, - this.context - ); + this.emit({ type: "scope:cleanup_timeout", scopeId: this.id, timeoutMs: record.timeoutMs, at: Date.now() }); } } catch (err) { - this.bus.emit( - { type: "scope:cleanup_failed", scopeId: this.id, error: err, at: Date.now() }, - this.context - ); + this.emit({ type: "scope:cleanup_failed", scopeId: this.id, error: err, at: Date.now() }); } } if (this.parent) this.parent.childScopes.delete(this); - this.emitClosing(this.firstChildFailure === undefined ? "completed" : "errored"); + this.closeEvent(this.firstChildFailure === undefined ? "completed" : "errored"); this.state = "closed"; - this.bus.emit( - { - type: "scope:closed", - scopeId: this.id, - durationMs: Date.now() - this.startedAt, - droppedTelemetryEvents: this.bus.droppedEventCount(), - at: Date.now(), - }, - this.context - ); + this.emit({ + type: "scope:closed", + scopeId: this.id, + durationMs: Date.now() - this.startedAt, + droppedTelemetryEvents: this.bus.droppedEventCount(), + at: Date.now(), + }); this.resolveClosed(); } @@ -479,7 +443,7 @@ export class ScopeImpl implements Scope { // -- Build a TaskContext for a spawned task body --------------------- /** Builds the task-facing context object for one spawned task body. */ - private makeTaskContext( + private taskContext( id: TaskId, name: string, kind: TaskKind, @@ -491,6 +455,15 @@ export class ScopeImpl implements Scope { const log = makeTaskLogger(scope, id); return { get signal() { return getSignal(); }, + get deadlineAt() { + let earliest: number | undefined; + for (let current: ScopeImpl | null = scope; current !== null; current = current.parent) { + if (current.deadlineAt !== undefined && (earliest === undefined || current.deadlineAt < earliest)) { + earliest = current.deadlineAt; + } + } + return earliest; + }, scope, attempt: 1, id, name, kind, @@ -520,31 +493,53 @@ export class ScopeImpl implements Scope { } /** Returns the first non-background child failure observed by this scope. */ - childFailure(): unknown { + failure(): unknown { return this.firstChildFailure; } /** Updates the visible attempt for a running task snapshot. */ - updateTaskAttempt(taskId: TaskId, attempt: number): void { + setAttempt(taskId: TaskId, attempt: number): void { const record = this.tasks.get(taskId); /* v8 ignore next -- retry wrappers update only task ids owned by this scope. */ if (record !== undefined) record.attempt = attempt; } /** Emits a typed retry event for wrappers executing inside this scope. */ - emitTaskRetry(taskId: TaskId, attempt: number, error: unknown, nextDelayMs: number): void { - this.bus.emit({ type: "task:retrying", taskId, attempt, error, nextDelayMs, at: Date.now() }, this.context); + emitRetry(taskId: TaskId, attempt: number, error: unknown, nextDelayMs: number): void { + this.emit({ type: "task:retrying", taskId, attempt, error, nextDelayMs, at: Date.now() }); + } + + /** Emits terminal evidence for one admitted retry task-body invocation. */ + emitAttempt( + taskId: TaskId, + attempt: number, + startedAt: number, + outcome: "succeeded" | "failed" | "cancelled", + ): void { + const at = Date.now(); + this.emit({ + type: "task:attempt", + taskId, + attempt, + durationMs: at - startedAt, + outcome, + at, + }); } /** Emits exactly one scope closing transition event. */ - private emitClosing(reason: "completed" | "errored" | "cancelled"): void { + private closeEvent(reason: "completed" | "errored" | "cancelled"): void { if (this.closingEmitted) return; this.closingEmitted = true; - this.bus.emit( - { type: "scope:closing", scopeId: this.id, reason, at: Date.now() }, - this.context - ); + clearTimeout(this.deadlineTimer); + this.emit({ type: "scope:closing", scopeId: this.id, reason, at: Date.now() }); } + + /** Emits lifecycle telemetry without allowing observers to affect ownership. */ + private emit(event: TaskEvent): void { + this.bus.emit(event, this.context); + } + } /** Creates a task-local logger backed by typed progress events. */ @@ -773,7 +768,7 @@ export async function group( await scope.close(); if (bodyError !== undefined) throw bodyError; - const childFailure = scope.childFailure(); + const childFailure = scope.failure(); if (childFailure !== undefined) throw childFailure; return result as R; } diff --git a/packages/core/src/engine/tree.ts b/packages/core/src/engine/tree.ts index fe7e758..7edea83 100644 --- a/packages/core/src/engine/tree.ts +++ b/packages/core/src/engine/tree.ts @@ -11,62 +11,52 @@ import type { ScopeSnapshot, TaskSnapshot, TreeOpts } from "../types/index.js"; -interface Glyphs { - branch: string; - last: string; - pipe: string; - space: string; - pending: string; - running: string; - succeeded: string; - failed: string; - cancelled: string; +type Glyphs = readonly [string, string, string, string, string, string, string, string, string]; + +const enum Glyph { + Branch, + Last, + Pipe, + Space, + Pending, + Running, + Succeeded, + Failed, + Cancelled, } -const ASCII: Glyphs = { - branch: "+-- ", - last: "\\-- ", - pipe: "| ", - space: " ", - pending: "[ ]", - running: "[..]", - succeeded: "[OK]", - failed: "[X]", - cancelled: "[!]", -}; - -const UNICODE: Glyphs = { - branch: "├─ ", - last: "└─ ", - pipe: "│ ", - space: " ", - pending: "⏸", - running: "⏳", - succeeded: "✓", - failed: "✗", - cancelled: "⊘", -}; +const STATUS_GLYPH = { + pending: Glyph.Pending, + running: Glyph.Running, + succeeded: Glyph.Succeeded, + failed: Glyph.Failed, + cancelled: Glyph.Cancelled, +} as const; + +const ASCII: Glyphs = [ + "+-- ", "\\-- ", "| ", " ", "[ ]", "[..]", "[OK]", "[X]", "[!]", +]; + +const UNICODE: Glyphs = [ + "├─ ", "└─ ", "│ ", " ", "⏸", "⏳", "✓", "✗", "⊘", +]; /** Renders a scope snapshot as a status tree plus aggregate summary. */ export function renderTree(snapshot: ScopeSnapshot, opts: TreeOpts = {}): string { - const ascii = opts.ascii ?? defaultAscii(); + const process = (globalThis as typeof globalThis & { + process?: { env?: { NO_UNICODE?: string }; stdout?: { isTTY?: boolean } }; + }).process; + const ascii = opts.ascii + ?? (process?.env?.NO_UNICODE === "1" || process?.stdout?.isTTY === false); const glyphs = ascii ? ASCII : UNICODE; const maxDepth = opts.maxDepth ?? Number.POSITIVE_INFINITY; const lines = [snapshot.name ?? snapshot.id]; renderChildren(snapshot, "", glyphs, lines, opts, 0, maxDepth); - lines.push(""); - lines.push(renderSummary(snapshot, ascii)); + lines.push("", renderSummary(snapshot, glyphs, ascii)); return lines.join("\n"); } -function defaultAscii(): boolean { - const runtime = globalThis as typeof globalThis & { - process?: { env?: { NO_UNICODE?: string }; stdout?: { isTTY?: boolean } }; - }; - return runtime.process?.env?.NO_UNICODE === "1" || runtime.process?.stdout?.isTTY === false; -} - function renderChildren( snapshot: ScopeSnapshot, prefix: string, @@ -77,77 +67,65 @@ function renderChildren( maxDepth: number ): void { if (depth >= maxDepth) return; - const children: Array<{ kind: "task"; value: TaskSnapshot } | { kind: "scope"; value: ScopeSnapshot }> = [ - ...snapshot.tasks.map((value) => ({ kind: "task" as const, value })), - ...snapshot.scopes.map((value) => ({ kind: "scope" as const, value })), - ]; + const children: Array = [...snapshot.tasks, ...snapshot.scopes]; children.forEach((child, index) => { const isLast = index === children.length - 1; - const marker = isLast ? glyphs.last : glyphs.branch; - const nextPrefix = prefix + (isLast ? glyphs.space : glyphs.pipe); - if (child.kind === "task") { - lines.push(`${prefix}${marker}${renderTask(child.value, glyphs, opts)}`); + const marker = isLast ? glyphs[Glyph.Last] : glyphs[Glyph.Branch]; + const nextPrefix = prefix + (isLast ? glyphs[Glyph.Space] : glyphs[Glyph.Pipe]); + if ("tasks" in child) { + lines.push(`${prefix}${marker}${child.name ?? child.id} (${child.status})`); + renderChildren(child, nextPrefix, glyphs, lines, opts, depth + 1, maxDepth); } else { - lines.push(`${prefix}${marker}${child.value.name ?? child.value.id} (${child.value.status})`); - renderChildren(child.value, nextPrefix, glyphs, lines, opts, depth + 1, maxDepth); + lines.push(`${prefix}${marker}${renderTask(child, glyphs, opts)}`); } }); } function renderTask(task: TaskSnapshot, glyphs: Glyphs, opts: TreeOpts): string { - const icon = task.status === "succeeded" - ? glyphs.succeeded - : task.status === "failed" - ? glyphs.failed - : task.status === "cancelled" - ? glyphs.cancelled - : task.status === "running" - ? glyphs.running - : glyphs.pending; - const details: string[] = [task.status]; - if ((opts.showDurations ?? true) && task.durationMs !== undefined) { + if (opts.showDurations !== false && task.durationMs !== undefined) { details.push(`${task.durationMs}ms`); } - if ((opts.showProgress ?? true) && task.progress?.pct !== undefined) { + if (opts.showProgress !== false && task.progress?.pct !== undefined) { details.push(`${Math.round(task.progress.pct * 100)}%`); } - return `${icon} ${task.name} (${details.join(", ")})`; + return `${glyphs[STATUS_GLYPH[task.status]]} ${task.name} (${details.join(", ")})`; } -function renderSummary(snapshot: ScopeSnapshot, ascii: boolean): string { +function renderSummary(snapshot: ScopeSnapshot, glyphs: Glyphs, ascii: boolean): string { const totals = countSnapshot(snapshot); - if (ascii) { - return `${totals.total} tasks | ${totals.succeeded} [OK] | ${totals.failed} [X] | ${totals.cancelled} [!] | ${totals.pending} [..]`; - } - return `${totals.total} tasks · ${totals.succeeded} ✓ · ${totals.failed} ✗ · ${totals.cancelled} ⊘ · ${totals.pending} ⏳`; + const separator = ascii ? " | " : " · "; + return `${totals[TotalCount.All]} tasks${separator}${totals[TotalCount.Succeeded]} ${glyphs[Glyph.Succeeded]}` + + `${separator}${totals[TotalCount.Failed]} ${glyphs[Glyph.Failed]}` + + `${separator}${totals[TotalCount.Cancelled]} ${glyphs[Glyph.Cancelled]}` + + `${separator}${totals[TotalCount.Pending]} ${glyphs[Glyph.Running]}`; +} + +const enum TotalCount { + All, + Succeeded, + Failed, + Cancelled, + Pending, } -function countSnapshot(snapshot: ScopeSnapshot): { - total: number; - succeeded: number; - failed: number; - cancelled: number; - pending: number; -} { - const own = { - total: snapshot.tasks.length, - succeeded: snapshot.completedCount, - failed: snapshot.failedCount, - cancelled: snapshot.cancelledCount, - pending: snapshot.pendingCount, - }; +type SnapshotTotals = [number, number, number, number, number]; + +function countSnapshot( + snapshot: ScopeSnapshot, + totals: SnapshotTotals = [0, 0, 0, 0, 0] +): SnapshotTotals { + totals[TotalCount.All] += snapshot.tasks.length; + totals[TotalCount.Succeeded] += snapshot.completedCount; + totals[TotalCount.Failed] += snapshot.failedCount; + totals[TotalCount.Cancelled] += snapshot.cancelledCount; + totals[TotalCount.Pending] += snapshot.pendingCount; for (const child of snapshot.scopes) { - const next = countSnapshot(child); - own.total += next.total; - own.succeeded += next.succeeded; - own.failed += next.failed; - own.cancelled += next.cancelled; - own.pending += next.pending; + countSnapshot(child, totals); } - return own; + return totals; } diff --git a/packages/core/src/replay/index.ts b/packages/core/src/replay/index.ts index 3b6b085..6c70253 100644 --- a/packages/core/src/replay/index.ts +++ b/packages/core/src/replay/index.ts @@ -8,15 +8,18 @@ * events. They intentionally do not claim deterministic scheduler replay. */ -import type { - CancelReason, - Scope, - ScopeId, - ScopeSnapshot, - TaskEvent, - TaskId, - TaskKind, - Unsubscribe, +import { + CancellationError, + type TaskFn, + type TaskContext, + type CancelReason, + type Scope, + type ScopeId, + type ScopeSnapshot, + type TaskEvent, + type TaskId, + type TaskKind, + type Unsubscribe, } from "../types/index.js"; /** Receipt schema version emitted by this subpath. */ @@ -41,6 +44,8 @@ export interface WorkItReceiptEvent { readonly name?: string; readonly kind?: TaskKind; readonly attempt?: number; + readonly startedAt?: number; + readonly outcome?: WorkItAttemptOutcome; readonly nextDelayMs?: number; readonly timeoutMs?: number; readonly durationMs?: number; @@ -78,6 +83,22 @@ export interface WorkItReceiptTerminal { readonly error?: WorkItReceiptError; } +/** Terminal outcome of one explicitly recorded task attempt. */ +export type WorkItAttemptOutcome = "succeeded" | "failed" | "cancelled"; + +/** Bounded evidence for one invocation of a retryable task body. */ +export interface WorkItAttemptEvidence { + readonly taskId: TaskId; + readonly attempt: number; + readonly startedAt: number; + readonly completedAt: number; + readonly durationMs: number; + readonly outcome: WorkItAttemptOutcome; + readonly reasonCode?: string; + readonly metadata?: Readonly>; + readonly error?: WorkItReceiptError; +} + /** Complete audit receipt for one observed scope tree. */ export interface WorkItReceipt { readonly version: WorkItReceiptVersion; @@ -88,6 +109,7 @@ export interface WorkItReceipt { readonly terminal: WorkItReceiptTerminal; readonly summary: WorkItReceiptSummary; readonly events: readonly WorkItReceiptEvent[]; + readonly attempts?: readonly WorkItAttemptEvidence[]; readonly snapshot: ScopeSnapshot; readonly limitations: readonly string[]; } @@ -105,6 +127,7 @@ export interface ReceiptBuildOptions { readonly clock?: () => number; readonly redaction?: ReceiptRedactionPolicy; readonly limitations?: readonly string[]; + readonly attempts?: readonly WorkItAttemptEvidence[]; } /** Recorder options for live scope observation. */ @@ -120,6 +143,26 @@ export interface ReceiptRecorder { unsubscribe(): void; } +/** Options shared by all attempts captured by one recorder. */ +export interface AttemptRecorderOptions { + readonly clock?: () => number; + readonly maxAttempts?: number; + readonly maxMetadataBytes?: number; +} + +/** Per-task evidence policy applied by an attempt recorder. */ +export interface AttemptEvidenceOptions { + readonly metadata?: Readonly>; + readonly reasonCode?: (error: unknown, ctx: TaskContext) => string | undefined; +} + +/** Bounded recorder for explicit task-attempt evidence. */ +export interface AttemptRecorder { + readonly attempts: readonly WorkItAttemptEvidence[]; + readonly droppedAttempts: number; + wrap(task: TaskFn, opts?: AttemptEvidenceOptions): TaskFn; +} + interface InternalReceiptBuildOptions extends ReceiptBuildOptions { readonly droppedEvents?: number; } @@ -142,6 +185,9 @@ const DEFAULT_REDACT_FIELDS = [ "refreshToken", ] as const; const DEFAULT_MAX_REDACTION_DEPTH = 8; +const DEFAULT_MAX_ATTEMPTS = 10_000; +const DEFAULT_MAX_ATTEMPT_METADATA_BYTES = 4_096; +const REASON_CODE_PATTERN = /^[a-z][a-z0-9_]{0,127}$/; /** Attaches a bounded receipt recorder to a live scope event stream. */ export function createReceiptRecorder(scope: Scope, opts: ReceiptRecorderOptions = {}): ReceiptRecorder { @@ -173,6 +219,65 @@ export function createReceiptRecorder(scope: Scope, opts: ReceiptRecorderOptions }; } +/** Creates a bounded recorder that captures actual invocations of wrapped task bodies. */ +export function createAttemptRecorder(opts: AttemptRecorderOptions = {}): AttemptRecorder { + const clock = opts.clock ?? Date.now; + const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + const maxMetadataBytes = opts.maxMetadataBytes ?? DEFAULT_MAX_ATTEMPT_METADATA_BYTES; + if (!Number.isInteger(maxAttempts) || maxAttempts < 1) throw new RangeError("maxAttempts must be a positive integer"); + if (!Number.isInteger(maxMetadataBytes) || maxMetadataBytes < 1) { + throw new RangeError("maxMetadataBytes must be a positive integer"); + } + + const attempts: WorkItAttemptEvidence[] = []; + let droppedAttempts = 0; + return { + get attempts() { return attempts.map((attempt) => structuredClone(attempt)); }, + get droppedAttempts() { return droppedAttempts; }, + wrap(task: TaskFn, attemptOpts: AttemptEvidenceOptions = {}): TaskFn { + const metadata = normalizeAttemptMetadata(attemptOpts.metadata, maxMetadataBytes); + return async (ctx) => { + const startedAt = clock(); + try { + const value = await task(ctx); + record("succeeded", ctx, startedAt); + return value; + } catch (error) { + record(error instanceof CancellationError ? "cancelled" : "failed", ctx, startedAt, error); + throw error; + } + }; + + function record( + outcome: WorkItAttemptOutcome, + ctx: TaskContext, + startedAt: number, + error?: unknown, + ): void { + if (attempts.length >= maxAttempts) { + droppedAttempts++; + return; + } + const completedAt = clock(); + const reasonCode = error === undefined + ? undefined + : readAttemptReasonCode(attemptOpts.reasonCode, error, ctx); + attempts.push({ + taskId: ctx.id, + attempt: ctx.attempt, + startedAt, + completedAt, + durationMs: Math.max(0, completedAt - startedAt), + outcome, + ...(reasonCode !== undefined ? { reasonCode } : {}), + ...(metadata !== undefined ? { metadata } : {}), + ...(error !== undefined ? { error: normalizeError(error) } : {}), + }); + } + }, + }; +} + /** Builds a replayable audit receipt from existing typed events and a snapshot. */ export function buildReceipt( events: readonly TaskEvent[], @@ -183,6 +288,7 @@ export function buildReceipt( const createdAt = opts.clock?.() ?? Date.now(); const receiptId = opts.receiptId ?? `receipt:${snapshot.id}:${createdAt}`; const normalizedEvents = events.map(normalizeEvent); + const attempts = opts.attempts ?? attemptsFromEvents(normalizedEvents); const summary = summarizeReceipt(snapshot, normalizedEvents, internalOpts.droppedEvents ?? 0); const terminal = inferTerminal(snapshot, normalizedEvents); const limitations = [ @@ -198,6 +304,9 @@ export function buildReceipt( terminal, summary, events: normalizedEvents, + ...(opts.attempts !== undefined || attempts.length > 0 + ? { attempts: attempts.map((attempt) => ({ ...attempt })) } + : {}), snapshot, limitations, ...(snapshot.name !== undefined ? { rootScopeName: snapshot.name } : {}), @@ -206,6 +315,48 @@ export function buildReceipt( return redactReceipt(receipt, opts.redaction); } +function readAttemptReasonCode( + classify: AttemptEvidenceOptions["reasonCode"], + error: unknown, + ctx: TaskContext, +): string | undefined { + try { + const reasonCode = classify?.(error, ctx); + return typeof reasonCode === "string" + && REASON_CODE_PATTERN.test(reasonCode) + ? reasonCode + : undefined; + } catch { + return undefined; + } +} + +function normalizeAttemptMetadata( + metadata: Readonly> | undefined, + maxBytes: number, +): Readonly> | undefined { + if (metadata === undefined) return undefined; + let serialized: string; + try { + serialized = JSON.stringify(metadata); + } catch { + throw new TypeError("attempt metadata must be JSON serializable"); + } + if (serialized === undefined) throw new TypeError("attempt metadata must serialize to a JSON object"); + if (new TextEncoder().encode(serialized).byteLength > maxBytes) { + throw new RangeError("attempt metadata exceeds maxMetadataBytes"); + } + const value = JSON.parse(serialized) as unknown; + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("attempt metadata must serialize to a JSON object"); + } + return redactValue(value, { + removeFields: new Set(), + redactFields: new Set(DEFAULT_REDACT_FIELDS.map((field) => field.toLowerCase())), + maxDepth: DEFAULT_MAX_REDACTION_DEPTH, + }, 0) as Readonly>; +} + /** Applies field-level redaction to an existing receipt. */ export function redactReceipt(receipt: WorkItReceipt, policy: ReceiptRedactionPolicy = {}): WorkItReceipt { const removeFields = new Set((policy.removeFields ?? []).map((field) => field.toLowerCase())); @@ -229,6 +380,16 @@ function normalizeEvent(event: TaskEvent): WorkItReceiptEvent { kind: event.kind, at: event.at, }; + case "task:attempt": + return { + type: event.type, + taskId: event.taskId, + attempt: event.attempt, + startedAt: event.at - event.durationMs, + durationMs: event.durationMs, + outcome: event.outcome, + at: event.at, + }; case "task:retrying": return { type: event.type, @@ -325,6 +486,24 @@ function normalizeEvent(event: TaskEvent): WorkItReceiptEvent { } } +function attemptsFromEvents(events: readonly WorkItReceiptEvent[]): WorkItAttemptEvidence[] { + return events.flatMap((event) => event.type === "task:attempt" + && event.taskId !== undefined + && event.attempt !== undefined + && event.startedAt !== undefined + && event.durationMs !== undefined + && event.outcome !== undefined + ? [{ + taskId: event.taskId, + attempt: event.attempt, + startedAt: event.startedAt, + completedAt: event.at, + durationMs: event.durationMs, + outcome: event.outcome, + }] + : []); +} + function summarizeReceipt( snapshot: ScopeSnapshot, events: readonly WorkItReceiptEvent[], diff --git a/packages/core/src/run/index.ts b/packages/core/src/run/index.ts index 58142f3..e840afa 100644 --- a/packages/core/src/run/index.ts +++ b/packages/core/src/run/index.ts @@ -37,6 +37,8 @@ import { ScopeImpl, getCurrentScope, group } from "../engine/scope.js"; import { parseDuration } from "../engine/duration.js"; import { computeBackoffDelay, computeRetryDelay, normalizeRetry, sleep } from "../engine/retry.js"; +type RetryContext = TaskContext & { __wr?: true }; + /** Runs all tasks concurrently and preserves input-order results. */ async function all[]>(tasks: T): Promise> { return group(async (task) => { @@ -168,9 +170,14 @@ async function pool(concurrency: number, tasks: TaskFn[]): Promise { } /** Wraps a task with a timeout that rejects with `TimeoutError`. */ -function timeout(task: TaskFn, duration: Duration): TaskFn { - const timeoutMs = parseDuration(duration); +function timeout(task: TaskFn, duration: Duration, absoluteDeadline?: number): TaskFn { + const configuredMs = parseDuration(duration); return async (ctx) => { + const startedAt = Date.now(); + const deadlineAt = absoluteDeadline ?? startedAt + configuredMs; + const timeoutMs = absoluteDeadline === undefined + ? configuredMs + : Math.max(0, absoluteDeadline - startedAt); const ctrl = new AbortController(); const signal = AbortSignal.any([ctx.signal, ctrl.signal]); @@ -185,7 +192,13 @@ function timeout(task: TaskFn, duration: Duration): TaskFn { try { return await Promise.race([ - task({ ...ctx, signal }), + task({ + ...ctx, + signal, + get deadlineAt() { + return ctx.deadlineAt === undefined ? deadlineAt : Math.min(ctx.deadlineAt, deadlineAt); + }, + }), timeoutPromise, ]); } finally { @@ -220,26 +233,43 @@ function uncancellable(task: TaskFn, opts?: { timeout?: Duration }): TaskF /** Wraps a task with a deadline timestamp. */ function deadline(task: TaskFn, at: number | Date): TaskFn { const deadlineAt = typeof at === "number" ? at : at.getTime(); - return timeout(task, Math.max(0, deadlineAt - Date.now())); + return timeout(task, 0, deadlineAt); } /** Retries a task according to a cancel-aware retry policy. */ function retry(task: TaskFn, opts: number | RetryOpts): TaskFn { const policy = normalizeRetry(opts); + const retryBudget = typeof opts === "number" ? undefined : opts.retryBudget; return async (ctx) => { let lastErr: unknown; + const scope = ctx.scope instanceof ScopeImpl ? ctx.scope : undefined; + const ownsTaskLifecycle = !(ctx as RetryContext).__wr; for (let attempt = 1; attempt <= policy.times; attempt++) { - if (ctx.scope instanceof ScopeImpl) ctx.scope.updateTaskAttempt(ctx.id, attempt); + if (ownsTaskLifecycle) scope?.setAttempt(ctx.id, attempt); + const startedAt = Date.now(); try { - return await task({ ...ctx, attempt }); + const value = await task({ ...ctx, attempt, __wr: true } as RetryContext); + if (ownsTaskLifecycle) scope?.emitAttempt(ctx.id, attempt, startedAt, "succeeded"); + return value; } catch (err) { + if (ownsTaskLifecycle) { + scope?.emitAttempt( + ctx.id, + attempt, + startedAt, + err instanceof CancellationError ? "cancelled" : "failed", + ); + } lastErr = err; if (err instanceof CancellationError) throw err; if (attempt >= policy.times || !policy.retryIf(err, attempt)) throw err; + if (retryBudget !== undefined) ctx.consume(retryBudget, 1); const delayMs = computeRetryDelay(attempt, policy); - if (ctx.scope instanceof ScopeImpl) ctx.scope.emitTaskRetry(ctx.id, attempt + 1, err, delayMs); - else ctx.report({ data: { retrying: true, attempt: attempt + 1, delayMs } }); + if (ownsTaskLifecycle) { + if (scope) scope.emitRetry(ctx.id, attempt + 1, err, delayMs); + else ctx.report({ data: { retrying: true, attempt: attempt + 1, delayMs } }); + } await sleep(delayMs, ctx.signal); } } diff --git a/packages/core/src/time-policy/index.ts b/packages/core/src/time-policy/index.ts index cf62542..1f523bb 100644 --- a/packages/core/src/time-policy/index.ts +++ b/packages/core/src/time-policy/index.ts @@ -8,7 +8,7 @@ * hedge, series, and parallel compositions. It never runs task bodies. */ -import type { Duration, RetryOpts } from "../types/index.js"; +import type { BudgetState, ContextKey, Duration, RetryOpts } from "../types/index.js"; import { parseDuration } from "../engine/duration.js"; import { normalizeRetry } from "../engine/retry.js"; @@ -78,6 +78,8 @@ export type TimePlanWarningCode = | "empty_composition" | "hedge_exceeds_timeout" | "jitter_upper_bound" + | "retry_budget_exceeded" + | "retry_budget_snapshot_missing" | "retry_exceeds_timeout" | "time_exceeds_timeout"; @@ -87,6 +89,30 @@ export interface TimePlanWarning { readonly message: string; readonly estimatedMs?: number; readonly limitMs?: number; + readonly budgetKey?: string; + readonly requiredRetries?: number; + readonly remainingRetries?: number; +} + +/** Runtime budget snapshot used to assess aggregate retry admission. */ +export interface RetryBudgetSnapshot { + readonly key: ContextKey; + readonly state: Readonly; +} + +/** Optional runtime state supplied to a pure time-policy plan. */ +export interface TimePlanOptions { + readonly retryBudgets?: readonly RetryBudgetSnapshot[]; +} + +/** Aggregate retry demand for one budget key referenced by a policy tree. */ +export interface TimePlanRetryBudget { + readonly key: string; + readonly required: number; + readonly limit?: number; + readonly spent?: number; + readonly remaining?: number; + readonly status: "admissible" | "exceeded" | "unverified"; } /** Planning result for one policy tree. */ @@ -96,24 +122,39 @@ export interface TimePlan { readonly criticalPathMs: number; readonly parallelWorkMs: number; readonly attempts: number; + readonly retryBudgets: readonly TimePlanRetryBudget[]; readonly warnings: readonly TimePlanWarning[]; } const MAX_POLICY_DEPTH = 64; /** Plans a declarative time policy without executing user task bodies. */ -export function planTimePolicy(policy: TimePolicy): TimePlan { - return plan(policy, 0); +export function planTimePolicy(policy: TimePolicy, opts: TimePlanOptions = {}): TimePlan { + return applyRetryBudgetPlan(plan(policy, 0), policy, opts); } /** Computes retry upper bounds using WorkIt's runtime retry semantics. */ -export function estimateRetry(policy: Pick): TimePlan { - return estimateRetryAtDepth(policy, 1); +export function estimateRetry( + policy: Pick, + opts: TimePlanOptions = {}, +): TimePlan { + return applyRetryBudgetPlan( + estimateRetryAtDepth(policy, 1), + { type: "retry", ...policy }, + opts, + ); } /** Computes hedge upper bounds using WorkIt's staggered duplicate attempts. */ -export function estimateHedge(policy: Pick): TimePlan { - return estimateHedgeAtDepth(policy, 1); +export function estimateHedge( + policy: Pick, + opts: TimePlanOptions = {}, +): TimePlan { + return applyRetryBudgetPlan( + estimateHedgeAtDepth(policy, 1), + { type: "hedge", ...policy }, + opts, + ); } function estimateRetryAtDepth(policy: Pick, depth: number): TimePlan { @@ -142,6 +183,7 @@ function estimateRetryAtDepth(policy: Pick criticalPathMs: upperBoundMs, parallelWorkMs: attemptPlan.parallelWorkMs * retry.times, attempts: attemptPlan.attempts * retry.times, + retryBudgets: [], warnings, }; } @@ -159,6 +201,7 @@ function estimateHedgeAtDepth(policy: Pick total + child.criticalPathMs, 0), parallelWorkMs: children.reduce((total, child) => total + child.parallelWorkMs, 0), attempts: children.reduce((total, child) => total + child.attempts, 0), + retryBudgets: [], warnings: children.flatMap((child) => child.warnings), }; } @@ -217,6 +262,7 @@ function combineParallel(policies: readonly TimePolicy[], depth: number): TimePl criticalPathMs: Math.max(...children.map((child) => child.criticalPathMs)), parallelWorkMs: children.reduce((total, child) => total + child.parallelWorkMs, 0), attempts: children.reduce((total, child) => total + child.attempts, 0), + retryBudgets: [], warnings: children.flatMap((child) => child.warnings), }; } @@ -228,6 +274,7 @@ function emptyPlan(): TimePlan { criticalPathMs: 0, parallelWorkMs: 0, attempts: 0, + retryBudgets: [], warnings: [{ code: "empty_composition", message: "empty time composition has zero cost", @@ -235,6 +282,110 @@ function emptyPlan(): TimePlan { }; } +function applyRetryBudgetPlan(base: TimePlan, policy: TimePolicy, opts: TimePlanOptions): TimePlan { + const demand = collectRetryBudgetDemand(policy); + if (demand.size === 0) return base; + + const snapshots = readRetryBudgetSnapshots(opts.retryBudgets ?? []); + const retryBudgets: TimePlanRetryBudget[] = []; + const warnings = [...base.warnings]; + let valid = base.valid; + + for (const [key, required] of demand) { + const state = snapshots.get(key); + if (state === undefined) { + valid = false; + retryBudgets.push({ key, required, status: "unverified" }); + warnings.push({ + code: "retry_budget_snapshot_missing", + message: "retry budget admission cannot be verified without a matching runtime snapshot", + budgetKey: key, + requiredRetries: required, + }); + continue; + } + + const remaining = state.limit - state.spent; + const admissible = required <= remaining; + if (!admissible) valid = false; + retryBudgets.push({ + key, + required, + limit: state.limit, + spent: state.spent, + remaining, + status: admissible ? "admissible" : "exceeded", + }); + if (!admissible) { + warnings.push({ + code: "retry_budget_exceeded", + message: "declared retry demand exceeds the remaining shared retry budget", + budgetKey: key, + requiredRetries: required, + remainingRetries: remaining, + }); + } + } + + return { ...base, valid, retryBudgets, warnings }; +} + +function collectRetryBudgetDemand( + policy: TimePolicy, + multiplier = 1, + demand = new Map(), +): Map { + switch (policy.type) { + case "series": + case "parallel": + for (const child of policy.policies) collectRetryBudgetDemand(child, multiplier, demand); + break; + case "retry": { + const retry = normalizeRetry(policy.retry); + if (typeof policy.retry !== "number" && policy.retry.retryBudget !== undefined) { + addRetryDemand(demand, policy.retry.retryBudget.name, boundedProduct(retry.times - 1, multiplier)); + } + collectRetryBudgetDemand(policy.attempt, boundedProduct(multiplier, retry.times), demand); + break; + } + case "hedge": + collectRetryBudgetDemand(policy.attempt, boundedProduct(multiplier, policy.max), demand); + break; + case "timeout": + case "deadline": + collectRetryBudgetDemand(policy.policy, multiplier, demand); + break; + case "attempt": + break; + } + return demand; +} + +function addRetryDemand(demand: Map, key: string, amount: number): void { + demand.set(key, Math.min(Number.MAX_SAFE_INTEGER, (demand.get(key) ?? 0) + amount)); +} + +function boundedProduct(left: number, right: number): number { + return Math.min(Number.MAX_SAFE_INTEGER, left * right); +} + +function readRetryBudgetSnapshots( + snapshots: readonly RetryBudgetSnapshot[], +): Map> { + const result = new Map>(); + for (const snapshot of snapshots) { + const { limit, spent } = snapshot.state; + if (!Number.isFinite(limit) || !Number.isFinite(spent) || limit < 0 || spent < 0 || spent > limit) { + throw new RangeError("retry budget snapshot must have finite non-negative spent <= limit"); + } + if (result.has(snapshot.key.name)) { + throw new RangeError(`duplicate retry budget snapshot: ${snapshot.key.name}`); + } + result.set(snapshot.key.name, snapshot.state); + } + return result; +} + function planTimeout(policy: TimeoutTimePolicy, depth: number): TimePlan { const inner = plan(policy.policy, depth + 1); const timeoutMs = parseDuration(policy.timeout); diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index e04dd53..c0acc8a 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -240,6 +240,14 @@ export interface TaskLogger { /** Typed event stream emitted by the engine at task and scope boundaries. */ export type TaskEvent = | { type: "task:started"; taskId: TaskId; scopeId: ScopeId; name: string; kind: TaskKind; at: number } + | { + type: "task:attempt"; + taskId: TaskId; + attempt: number; + durationMs: number; + outcome: "succeeded" | "failed" | "cancelled"; + at: number; + } | { type: "task:retrying"; taskId: TaskId; attempt: number; error: unknown; nextDelayMs: number; at: number } | { type: "task:progress"; taskId: TaskId; pct?: number; message?: string; data?: unknown; at: number } | { type: "task:cleanup_failed"; taskId: TaskId; error: unknown; at: number } @@ -306,6 +314,8 @@ export interface RetryOpts { maxDelay?: Duration; jitter?: boolean; retryIf?: (err: unknown, attempt: number) => boolean; + /** Shared scope budget charged once before each additional attempt is admitted. */ + retryBudget?: ContextKey; } /** Hedging policy that starts duplicate attempts after a delay. */ @@ -345,10 +355,13 @@ export interface TaskContext { /** Abort signal linked to the owning scope and this task handle. */ readonly signal: AbortSignal; + /** Earliest absolute deadline currently inherited through the task's scope and wrappers. */ + readonly deadlineAt?: number | undefined; + /** Scope that owns the task. */ readonly scope: Scope; - /** Current attempt number, one-indexed. The current engine sets this to 1. */ + /** Current task-body attempt number, one-indexed. Retry wrappers increment it before invocation. */ readonly attempt: number; /** Stable task identifier for snapshots and trace records. */ @@ -439,6 +452,12 @@ export interface TaskOpts { name?: string; kind?: TaskKind; meta?: Record; + /** + * Coalesces concurrent tasks with the same key inside one scope. + * + * The key is removed when the shared task settles. Durable idempotency and + * restart replay require an explicit `@workit/core/activity` store. + */ idempotencyKey?: string; cleanupTimeout?: Duration; } diff --git a/packages/core/tests/evidence/correctness/runtime-contracts.mjs b/packages/core/tests/evidence/correctness/runtime-contracts.mjs index d766b35..348278b 100644 --- a/packages/core/tests/evidence/correctness/runtime-contracts.mjs +++ b/packages/core/tests/evidence/correctness/runtime-contracts.mjs @@ -7,7 +7,14 @@ import { createChannel } from "../../../dist/channel/index.js"; import { diagnoseSnapshot } from "../../../dist/diagnostics/index.js"; -import { CostBudget, run } from "../../../dist/index.js"; +import { + BudgetExceededError, + CancellationError, + CostBudget, + createBudget, + run, +} from "../../../dist/index.js"; +import { createReceiptRecorder } from "../../../dist/replay/index.js"; import { assert, createSuite } from "../harness.mjs"; const suite = createSuite("correctness"); @@ -124,6 +131,156 @@ await suite.proof( }, ); +await suite.proof( + "CORR-024", + "task context exposes the effective composed deadline", + "nested retry and fallback attempts observe the absolute deadline declared by the owning wrapper", + async () => { + const deadlineAt = Date.now() + 2_000; + const observed = []; + let attempts = 0; + + await run.group(async (task) => task(run.deadline(run.retry(run.fallback( + async (ctx) => { + observed.push(ctx.deadlineAt); + attempts++; + throw new Error("primary unavailable"); + }, + async (ctx) => { + observed.push(ctx.deadlineAt); + if (attempts === 1) throw new Error("retry fallback"); + return "ok"; + }, + ), { times: 2, initialDelay: 0 }), deadlineAt))); + + return { + ok: observed.length === 4 && observed.every((value) => value === deadlineAt), + deadlineAt, + observed, + }; + }, +); + +await suite.proof( + "CORR-025", + "shared retry budget blocks excess attempts before execution", + "two retry wrappers share one scope budget and the exhausted wrapper does not start its retry body", + async () => { + const RetryBudget = createBudget("EvidenceRetryBudget", { unit: "retries" }); + let firstAttempts = 0; + let secondAttempts = 0; + let error; + + try { + await run.context.with(RetryBudget, { limit: 1, spent: 0, unit: "retries" }, async () => { + await run.group(async (task) => { + await task(run.retry(async () => { + firstAttempts++; + if (firstAttempts === 1) throw new Error("first retry"); + return "ok"; + }, { times: 2, initialDelay: 0, retryBudget: RetryBudget })); + + await task(run.retry(async () => { + secondAttempts++; + throw new Error("second retry"); + }, { times: 2, initialDelay: 0, retryBudget: RetryBudget })); + }); + }); + } catch (caught) { + error = caught; + } + + return { + ok: error instanceof BudgetExceededError + && firstAttempts === 2 + && secondAttempts === 1, + errorClass: error?.constructor?.name, + firstAttempts, + secondAttempts, + }; + }, +); + +await suite.proof( + "CORR-026", + "deadline retry fallback cancellation and receipt evidence compose", + "one cancelled retry composition preserves its effective deadline and records failed and cancelled attempts in the terminal receipt", + async () => { + const RetryBudget = createBudget("ComposedRetryBudget", { unit: "retries" }); + const deadlineAt = Date.now() + 2_000; + const observedDeadlines = []; + let scopeRef; + let recorder; + let releaseSecondAttempt; + const secondAttemptStarted = new Promise((resolve) => { + releaseSecondAttempt = resolve; + }); + let error; + + try { + await run.context.with(RetryBudget, { limit: 1, spent: 0, unit: "retries" }, () => + run.scope(async (scope) => { + scopeRef = scope; + recorder = createReceiptRecorder(scope, { receiptId: "composed-runtime-contract" }); + const handle = scope.spawn(run.deadline(run.retry(run.fallback( + async (ctx) => { + observedDeadlines.push(ctx.deadlineAt); + if (ctx.attempt === 1) throw new Error("primary unavailable"); + releaseSecondAttempt(); + await waitForAbort(ctx.signal); + return "unreachable"; + }, + async (ctx) => { + observedDeadlines.push(ctx.deadlineAt); + throw new Error("fallback unavailable"); + }, + ), { + times: 3, + initialDelay: 0, + retryBudget: RetryBudget, + }), deadlineAt), { name: "composed-runtime-contract" }); + + await secondAttemptStarted; + scope.cancel({ kind: "manual", tag: "composed_stop" }); + await handle; + }, { name: "composed-runtime-contract" }) + ); + } catch (caught) { + error = caught; + } + + const receipt = recorder.build(scopeRef.status()); + recorder.unsubscribe(); + const attempts = receipt.attempts ?? []; + + return { + ok: error instanceof CancellationError + && receipt.terminal.outcome === "cancelled" + && receipt.terminal.cancelReason?.kind === "manual" + && receipt.terminal.cancelReason.tag === "composed_stop" + && observedDeadlines.length === 3 + && observedDeadlines.every((value) => value === deadlineAt) + && attempts.length === 2 + && attempts[0]?.outcome === "failed" + && attempts[1]?.outcome === "cancelled", + errorClass: error?.constructor?.name, + terminal: receipt.terminal, + observedDeadlines, + attempts: attempts.map(({ attempt, outcome }) => ({ attempt, outcome })), + }; + }, +); + const summary = suite.summary(); process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); process.exit(summary.failed > 0 ? 1 : 0); + +function waitForAbort(signal) { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); +} diff --git a/packages/core/tests/evidence/correctness/time-policy-planner.mjs b/packages/core/tests/evidence/correctness/time-policy-planner.mjs index bf2da25..f56aa3d 100644 --- a/packages/core/tests/evidence/correctness/time-policy-planner.mjs +++ b/packages/core/tests/evidence/correctness/time-policy-planner.mjs @@ -5,6 +5,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { createBudget } from "../../../dist/index.js"; import { estimateRetry, planTimePolicy } from "../../../dist/time-policy/index.js"; import { createSuite } from "../harness.mjs"; @@ -52,6 +53,52 @@ await suite.proof( }, ); +await suite.proof( + "CORR-027", + "time-policy planner aggregates shared retry budget demand", + "nested retry policies using one budget key are rejected before execution when aggregate retry demand exceeds the supplied runtime snapshot", + async () => { + const RetryBudget = createBudget("PlannedSharedRetryBudget", { unit: "retries" }); + const plan = planTimePolicy({ + type: "series", + policies: [ + { + type: "retry", + attempt: { type: "attempt", duration: 10 }, + retry: { times: 2, retryBudget: RetryBudget }, + }, + { + type: "retry", + attempt: { + type: "retry", + attempt: { type: "attempt", duration: 10 }, + retry: { times: 2, retryBudget: RetryBudget }, + }, + retry: { times: 3, retryBudget: RetryBudget }, + }, + ], + }, { + retryBudgets: [{ + key: RetryBudget, + state: { limit: 6, spent: 1, unit: "retries" }, + }], + }); + + return { + ok: !plan.valid + && plan.retryBudgets.length === 1 + && plan.retryBudgets[0]?.key === RetryBudget.name + && plan.retryBudgets[0]?.required === 6 + && plan.retryBudgets[0]?.remaining === 5 + && plan.retryBudgets[0]?.status === "exceeded" + && plan.warnings.some((warning) => warning.code === "retry_budget_exceeded"), + valid: plan.valid, + retryBudgets: plan.retryBudgets, + warnings: plan.warnings.map((warning) => warning.code), + }; + }, +); + const summary = suite.summary(); process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); process.exit(summary.failed > 0 ? 1 : 0); diff --git a/packages/core/tests/evidence/lifecycle/replay-receipts.mjs b/packages/core/tests/evidence/lifecycle/replay-receipts.mjs index addb02d..0e8117b 100644 --- a/packages/core/tests/evidence/lifecycle/replay-receipts.mjs +++ b/packages/core/tests/evidence/lifecycle/replay-receipts.mjs @@ -6,7 +6,7 @@ */ import { CancellationError, run } from "../../../dist/index.js"; -import { createReceiptRecorder } from "../../../dist/replay/index.js"; +import { createAttemptRecorder, createReceiptRecorder } from "../../../dist/replay/index.js"; import { createSuite, sleep } from "../harness.mjs"; const suite = createSuite("lifecycle"); @@ -75,6 +75,38 @@ await suite.proof( }, ); +await suite.proof( + "LIFE-012", + "attempt evidence records actual retry invocations", + "each admitted retry invocation records its attempt number, bounded reason code, outcome, and redacted metadata", + async () => { + const recorder = createAttemptRecorder(); + let calls = 0; + + await run.group(async (task) => task(run.retry(recorder.wrap(async () => { + calls++; + if (calls === 1) throw new Error("provider unavailable"); + return "ok"; + }, { + metadata: { provider: "primary", token: "secret" }, + reasonCode: () => "provider_unavailable", + }), { times: 2, initialDelay: 0 }))); + + const attempts = recorder.attempts; + return { + ok: attempts.length === 2 + && attempts[0]?.attempt === 1 + && attempts[0]?.outcome === "failed" + && attempts[0]?.reasonCode === "provider_unavailable" + && attempts[0]?.metadata?.token === "[redacted]" + && attempts[1]?.attempt === 2 + && attempts[1]?.outcome === "succeeded", + attempts, + droppedAttempts: recorder.droppedAttempts, + }; + }, +); + const summary = suite.summary(); process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); process.exit(summary.failed > 0 ? 1 : 0); diff --git a/packages/core/tests/unit/invariants.test.js b/packages/core/tests/unit/invariants.test.js index 4c67ed6..230e3c3 100644 --- a/packages/core/tests/unit/invariants.test.js +++ b/packages/core/tests/unit/invariants.test.js @@ -11,6 +11,7 @@ import { test } from "vitest"; import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; import { CancellationError, ContextBagImpl, @@ -119,6 +120,41 @@ test("invariant: first cancellation reason remains authoritative", async () => { assert.deepEqual(reasons, [{ kind: "manual", tag: "first" }]); }); +test("invariant: deadline cancellation isolates closing-event failures", () => { + const runtimeUrl = new URL("../../dist/index.js", import.meta.url).href; + const script = ` + import { run } from ${JSON.stringify(runtimeUrl)}; + + const context = { + get() { + throw new Error("context read failed"); + }, + getOrThrow() { + throw new Error("context read failed"); + }, + with() { + return this; + }, + has() { + return false; + }, + }; + + await run.scope(async (scope) => { + const unsubscribe = scope.onEvent(() => undefined); + scope.deadline(1); + await new Promise((resolveTimer) => setTimeout(resolveTimer, 20)); + unsubscribe(); + }, { context, name: "deadline-event-isolation" }); + `; + + assert.doesNotThrow(() => { + execFileSync(process.execPath, ["--input-type=module", "--eval", script], { + stdio: "pipe", + }); + }); +}); + test("invariant: concurrent budget charges are exact and failed charges do not mutate stored budget", async () => { const Budget = createBudget("InvariantBudget", { unit: "ops" }); const context = new ContextBagImpl().with(Budget, { limit: 200, spent: 0, unit: "ops" }); diff --git a/packages/core/tests/unit/replay.test.js b/packages/core/tests/unit/replay.test.js index d25636f..d50fcf0 100644 --- a/packages/core/tests/unit/replay.test.js +++ b/packages/core/tests/unit/replay.test.js @@ -8,7 +8,12 @@ import { test } from "vitest"; import assert from "node:assert/strict"; import { CancellationError, run } from "../../dist/index.js"; -import { buildReceipt, createReceiptRecorder, redactReceipt } from "../../dist/replay/index.js"; +import { + buildReceipt, + createAttemptRecorder, + createReceiptRecorder, + redactReceipt, +} from "../../dist/replay/index.js"; const sleep = (ms, signal) => new Promise((resolve, reject) => { @@ -444,10 +449,204 @@ test("Given direct Error values, redactReceipt normalizes safe error evidence", assert.equal(redacted.events[0].data.message, "direct error"); }); +test("Given retry execution, attempt recorder captures bounded redacted evidence", async () => { + let receiptRecorder; + let now = 10; + const attempts = createAttemptRecorder({ + clock: () => now++, + maxAttempts: 2, + }); + let calls = 0; + + await run.scope(async (scope) => { + receiptRecorder = createReceiptRecorder(scope, { receiptId: "receipt-attempts" }); + await scope.spawn(run.retry(attempts.wrap(async () => { + calls++; + if (calls === 1) throw new Error("provider unavailable"); + return "ok"; + }, { + metadata: { provider: "primary", token: "secret" }, + reasonCode: () => "provider_unavailable", + }), { times: 2, initialDelay: 0 }), { name: "attempted-provider" }); + }); + + const receipt = receiptRecorder.build(undefined, { attempts: attempts.attempts }); + receiptRecorder.unsubscribe(); + + assert.deepEqual(receipt.attempts.map((attempt) => ({ + attempt: attempt.attempt, + outcome: attempt.outcome, + reasonCode: attempt.reasonCode, + metadata: attempt.metadata, + })), [ + { + attempt: 1, + outcome: "failed", + reasonCode: "provider_unavailable", + metadata: { provider: "primary", token: "[redacted]" }, + }, + { + attempt: 2, + outcome: "succeeded", + reasonCode: undefined, + metadata: { provider: "primary", token: "[redacted]" }, + }, + ]); + assert.equal(attempts.droppedAttempts, 0); +}); + +test("Given retry execution without an explicit attempt recorder, receipt derives generic attempt evidence", async () => { + let receiptRecorder; + let observedScope; + let calls = 0; + + await run.scope(async (scope) => { + observedScope = scope; + receiptRecorder = createReceiptRecorder(scope, { receiptId: "receipt-runtime-attempts" }); + await scope.spawn(run.retry(async () => { + calls++; + if (calls === 1) throw new Error("retry once"); + return "ok"; + }, { times: 2, initialDelay: 0 }), { name: "runtime-attempts" }); + }); + + const receipt = receiptRecorder.build(observedScope.status()); + receiptRecorder.unsubscribe(); + + assert.deepEqual(receipt.attempts.map((attempt) => ({ + attempt: attempt.attempt, + outcome: attempt.outcome, + })), [ + { attempt: 1, outcome: "failed" }, + { attempt: 2, outcome: "succeeded" }, + ]); + assert.equal(receipt.events.filter((event) => event.type === "task:attempt").length, 2); + assert.ok(receipt.attempts.every((attempt) => + attempt.completedAt >= attempt.startedAt + && attempt.durationMs === attempt.completedAt - attempt.startedAt + )); +}); + +test("Given cancellation, attempt recorder records the typed terminal outcome", async () => { + const attempts = createAttemptRecorder({ maxAttempts: 2 }); + let calls = 0; + + await assert.rejects( + run.group(async (task) => task(run.retry(attempts.wrap(async () => { + calls++; + if (calls === 1) throw new Error("retry"); + throw new CancellationError({ kind: "manual", tag: "stop" }); + }, { + reasonCode: (error) => error instanceof CancellationError ? "manual_stop" : "retryable", + }), { times: 2, initialDelay: 0 }))), + CancellationError, + ); + + assert.equal(calls, 2); + assert.equal(attempts.attempts.length, 2); + assert.equal(attempts.attempts[0].outcome, "failed"); + assert.equal(attempts.attempts[1].outcome, "cancelled"); + assert.equal(attempts.attempts[1].reasonCode, "manual_stop"); + assert.equal(attempts.droppedAttempts, 0); +}); + +test("Given a full attempt window, attempt recorder counts dropped evidence", async () => { + const attempts = createAttemptRecorder({ maxAttempts: 1 }); + let calls = 0; + + const result = await run.group(async (task) => task(run.retry(attempts.wrap(async () => { + calls++; + if (calls === 1) throw new Error("retry"); + return "ok"; + }), { times: 2, initialDelay: 0 }))); + + assert.equal(result, "ok"); + assert.equal(attempts.attempts.length, 1); + assert.equal(attempts.droppedAttempts, 1); +}); + +test("Given a failing reason classifier, attempt recorder preserves the task failure", async () => { + const attempts = createAttemptRecorder(); + const original = new Error("original failure"); + + await assert.rejects( + run.group(async (task) => task(attempts.wrap(async () => { + throw original; + }, { + reasonCode: () => { + throw new Error("classifier failure"); + }, + }))), + (error) => error === original, + ); + + assert.equal(attempts.attempts[0].outcome, "failed"); + assert.equal(attempts.attempts[0].reasonCode, undefined); + assert.equal(attempts.attempts[0].error.message, "original failure"); +}); + +test("Given invalid attempt evidence options, recorder rejects unsafe metadata and codes", async () => { + assert.throws(() => createAttemptRecorder({ maxAttempts: 0 }), /maxAttempts/); + assert.throws(() => createAttemptRecorder({ maxMetadataBytes: 0 }), /maxMetadataBytes/); + + const circular = {}; + circular.self = circular; + const recorder = createAttemptRecorder(); + assert.throws( + () => recorder.wrap(async () => "never", { metadata: circular }), + /JSON serializable/, + ); + + const serialized = JSON.stringify({ value: "é" }); + const utf8Bytes = new TextEncoder().encode(serialized).byteLength; + assert.ok(utf8Bytes > serialized.length); + assert.throws( + () => createAttemptRecorder({ maxMetadataBytes: serialized.length }) + .wrap(async () => "never", { metadata: { value: "é" } }), + /maxMetadataBytes/, + ); + + const invalidCode = createAttemptRecorder(); + await assert.rejects( + run.group(async (task) => task(invalidCode.wrap(async () => { + throw new Error("invalid code"); + }, { reasonCode: () => "Not a stable code" }))), + /invalid code/, + ); + assert.equal(invalidCode.attempts[0].reasonCode, undefined); +}); + +test("Given recorded metadata, returned attempt evidence cannot mutate recorder state", async () => { + const recorder = createAttemptRecorder(); + + await run.group(async (task) => task(recorder.wrap( + async () => "ok", + { metadata: { provider: { name: "primary" } } }, + ))); + + const exposed = recorder.attempts; + exposed[0].metadata.provider.name = "mutated"; + + assert.equal(recorder.attempts[0].metadata.provider.name, "primary"); + assert.throws( + () => recorder.wrap(async () => "never", { + metadata: { toJSON: () => undefined }, + }), + /JSON object/, + ); + assert.throws( + () => recorder.wrap(async () => "never", { + metadata: { toJSON: () => ["not", "an", "object"] }, + }), + /JSON object/, + ); +}); + test("Given the root import, replay helpers are not exported from the root runtime", async () => { const root = await import("../../dist/index.js"); assert.equal("buildReceipt" in root, false); + assert.equal("createAttemptRecorder" in root, false); assert.equal("createReceiptRecorder" in root, false); assert.equal("redactReceipt" in root, false); }); diff --git a/packages/core/tests/unit/run.test.js b/packages/core/tests/unit/run.test.js index 17a2d05..ef5e23a 100644 --- a/packages/core/tests/unit/run.test.js +++ b/packages/core/tests/unit/run.test.js @@ -10,8 +10,10 @@ import assert from "node:assert/strict"; import { getEventListeners } from "node:events"; import { run, + createBudget, createContextKey, group, + BudgetExceededError, CancellationError, TimeoutError, WorkAggregateError, @@ -768,6 +770,235 @@ test("run.retry policies cover cancel-aware delay and backoff branches", async ( ); }); +test("task context exposes the earliest deadline inherited through nested scopes", async () => { + let parentDeadline; + let childDeadline; + + const observed = await run.scope(async (parent) => { + parent.deadline(4_000); + parentDeadline = parent.status().deadlineAt; + + const inherited = await run.scope(async (child) => { + child.deadline(8_000); + return child.spawn(async (ctx) => ctx.deadlineAt); + }); + + const narrowed = await run.scope(async (child) => { + child.deadline(1_000); + childDeadline = child.status().deadlineAt; + return child.spawn(async (ctx) => ctx.deadlineAt); + }); + + return [inherited, narrowed]; + }); + + assert.deepEqual(observed, [parentDeadline, childDeadline]); + assert.ok(childDeadline < parentDeadline); + assert.equal(await run.group(async (task) => task(async (ctx) => ctx.deadlineAt)), undefined); +}); + +test("scope deadlines can only tighten and clear their timer when the scope closes", async () => { + const observed = await run.scope(async (scope) => { + scope.deadline(2_000); + const first = scope.status().deadlineAt; + scope.deadline(4_000); + const unchanged = scope.status().deadlineAt; + scope.deadline(1_000); + const tightened = scope.status().deadlineAt; + return { first, unchanged, tightened }; + }); + + assert.equal(observed.unchanged, observed.first); + assert.ok(observed.tightened < observed.first); +}); + +test("deadline introspection composes through deadline, retry, fallback, and hedge", async () => { + const deadlineAt = Date.now() + 2_000; + const observed = []; + let retryAttempts = 0; + + await run.group(async (task) => { + await task(run.deadline(run.retry(async (ctx) => { + observed.push(ctx.deadlineAt); + retryAttempts++; + if (retryAttempts === 1) throw new Error("retry once"); + return "retried"; + }, { times: 2, initialDelay: 0 }), deadlineAt)); + + await task(run.deadline(run.fallback( + async (ctx) => { + observed.push(ctx.deadlineAt); + throw new Error("use fallback"); + }, + async (ctx) => { + observed.push(ctx.deadlineAt); + return "fallback"; + } + ), deadlineAt)); + + await task(run.deadline(run.hedge(async (ctx) => { + observed.push(ctx.deadlineAt); + if (ctx.attempt === 1) await sleep(20, ctx.signal); + return ctx.attempt; + }, { after: 1, max: 2 }), deadlineAt)); + }); + + assert.ok(observed.length >= 5); + assert.ok(observed.every((value) => value === deadlineAt)); + + let scopeDeadline; + const inherited = await run.scope(async (scope) => { + scope.deadline(1_000); + scopeDeadline = scope.status().deadlineAt; + return scope.spawn(run.deadline(async (ctx) => ctx.deadlineAt, Date.now() + 2_000)); + }); + assert.equal(inherited, scopeDeadline); +}); + +test("retry budget is shared and charged before each additional attempt", async () => { + const RetryBudget = createBudget("RetryBudget", { unit: "retries" }); + let firstTaskAttempts = 0; + let secondTaskAttempts = 0; + + await assert.rejects( + run.context.with(RetryBudget, { limit: 1, spent: 0, unit: "retries" }, async () => { + await run.group(async (task) => { + await task(run.retry(async () => { + firstTaskAttempts++; + if (firstTaskAttempts === 1) throw new Error("retry first task"); + return "first complete"; + }, { times: 2, initialDelay: 0, retryBudget: RetryBudget })); + + await task(run.retry(async () => { + secondTaskAttempts++; + throw new Error("retry second task"); + }, { times: 3, initialDelay: 0, retryBudget: RetryBudget })); + }); + }), + (error) => error instanceof BudgetExceededError + && error.budgetKey === "RetryBudget" + && error.attempted === 1 + ); + + assert.equal(firstTaskAttempts, 2); + assert.equal(secondTaskAttempts, 1); +}); + +test("retry emits one generic terminal lifecycle event for every admitted attempt", async () => { + const events = []; + let calls = 0; + + await run.scope(async (scope) => { + const unsubscribe = scope.onEvent((event) => events.push(event)); + try { + await scope.spawn(run.retry(async () => { + calls++; + if (calls === 1) throw new Error("retry"); + return "ok"; + }, { times: 2, initialDelay: 0 })); + } finally { + unsubscribe(); + } + }); + + const attempts = events.filter((event) => event.type === "task:attempt"); + assert.deepEqual(attempts.map((event) => [event.attempt, event.outcome]), [ + [1, "failed"], + [2, "succeeded"], + ]); + assert.ok(attempts.every((event) => + event.durationMs >= 0 + && event.durationMs <= event.at + )); +}); + +test("nested retries keep generic task attempt ownership at the outer boundary", async () => { + const events = []; + let leafCalls = 0; + + const result = await run.scope(async (scope) => { + const unsubscribe = scope.onEvent((event) => events.push(event)); + try { + return await scope.spawn(run.retry( + run.retry(async () => { + leafCalls++; + if (leafCalls < 4) throw new Error("retry nested task"); + return "ok"; + }, { times: 2, initialDelay: 0 }), + { times: 2, initialDelay: 0 }, + )); + } finally { + unsubscribe(); + } + }); + + assert.equal(result, "ok"); + assert.equal(leafCalls, 4); + assert.deepEqual( + events + .filter((event) => event.type === "task:attempt") + .map((event) => [event.attempt, event.outcome]), + [ + [1, "failed"], + [2, "succeeded"], + ], + ); +}); + +test("retry budget is not charged when retry policy rejects another attempt", async () => { + const RetryBudget = createBudget("RejectedRetryBudget", { unit: "retries" }); + let attempts = 0; + + await assert.rejects( + run.context.with(RetryBudget, { limit: 0, spent: 0, unit: "retries" }, async () => + run.group(async (task) => task(run.retry(async () => { + attempts++; + throw new Error("terminal"); + }, { times: 3, retryIf: () => false, retryBudget: RetryBudget }))) + ), + /terminal/ + ); + + assert.equal(attempts, 1); +}); + +test("retry budget must be installed before a retry is admitted", async () => { + const RetryBudget = createBudget("MissingRetryBudget", { unit: "retries" }); + let attempts = 0; + + await assert.rejects( + run.group(async (task) => task(run.retry(async () => { + attempts++; + throw new Error("retry"); + }, { times: 2, retryBudget: RetryBudget }))), + /Budget "MissingRetryBudget" not set in scope/ + ); + + assert.equal(attempts, 1); +}); + +test("run.retry snapshots its retry budget policy at construction", async () => { + const OriginalBudget = createBudget("OriginalRetryBudget", { unit: "retries" }); + const MutatedBudget = createBudget("MutatedRetryBudget", { unit: "retries" }); + const policy = { times: 2, initialDelay: 0, retryBudget: OriginalBudget }; + let attempts = 0; + const wrapped = run.retry(async () => { + attempts++; + if (attempts === 1) throw new Error("retry"); + return "ok"; + }, policy); + policy.retryBudget = MutatedBudget; + + const result = await run.context.with( + OriginalBudget, + { limit: 1, spent: 0, unit: "retries" }, + async () => run.group(async (task) => task(wrapped)), + ); + + assert.equal(result, "ok"); + assert.equal(attempts, 2); +}); + test("retry delay listeners are removed after completed sleeps", async () => { const controller = new AbortController(); let attempts = 0; diff --git a/packages/core/tests/unit/time-policy.test.js b/packages/core/tests/unit/time-policy.test.js index b1d0b49..75fc7bf 100644 --- a/packages/core/tests/unit/time-policy.test.js +++ b/packages/core/tests/unit/time-policy.test.js @@ -8,6 +8,7 @@ import assert from "node:assert/strict"; import { test } from "vitest"; +import { createBudget } from "../../dist/index.js"; import { estimateHedge, estimateRetry, planTimePolicy } from "../../dist/time-policy/index.js"; test("Given fixed retry policy, estimateRetry computes runtime-aligned worst-case delay", () => { @@ -127,6 +128,133 @@ test("Given linear and exponential retry policies, estimateRetry respects maxDel assert.equal(exponential.upperBoundMs, 55); }); +test("Given shared retry budget snapshots, planner aggregates nested retry demand by key", () => { + const RetryBudget = createBudget("PlannerRetryBudget", { unit: "retries" }); + const plan = planTimePolicy({ + type: "series", + policies: [ + { + type: "retry", + attempt: { type: "attempt", duration: 10 }, + retry: { times: 2, retryBudget: RetryBudget }, + }, + { + type: "retry", + attempt: { + type: "retry", + attempt: { type: "attempt", duration: 10 }, + retry: { times: 2, retryBudget: RetryBudget }, + }, + retry: { times: 3, retryBudget: RetryBudget }, + }, + ], + }, { + retryBudgets: [{ + key: RetryBudget, + state: { limit: 7, spent: 1, unit: "retries" }, + }], + }); + + assert.equal(plan.valid, true); + assert.deepEqual(plan.retryBudgets, [{ + key: "PlannerRetryBudget", + required: 6, + limit: 7, + spent: 1, + remaining: 6, + status: "admissible", + }]); + assert.equal(plan.warnings.some((warning) => warning.code.startsWith("retry_budget")), false); +}); + +test("Given parallel hedges over nested retries, planner multiplies shared budget demand conservatively", () => { + const RetryBudget = createBudget("ParallelHedgeRetryBudget", { unit: "retries" }); + const plan = planTimePolicy({ + type: "parallel", + policies: [ + { + type: "hedge", + after: 5, + max: 3, + attempt: { + type: "retry", + attempt: { type: "attempt", duration: 10 }, + retry: { times: 2, retryBudget: RetryBudget }, + }, + }, + { + type: "deadline", + now: 0, + deadlineAt: 1_000, + policy: { + type: "retry", + attempt: { type: "attempt", duration: 10 }, + retry: { times: 2, retryBudget: RetryBudget }, + }, + }, + ], + }, { + retryBudgets: [{ + key: RetryBudget, + state: { limit: 4, spent: 0, unit: "retries" }, + }], + }); + + assert.equal(plan.valid, true); + assert.equal(plan.retryBudgets[0].required, 4); + assert.equal(plan.retryBudgets[0].status, "admissible"); +}); + +test("Given insufficient or missing retry budget snapshots, planner reports bounded admission warnings", () => { + const RetryBudget = createBudget("ConstrainedRetryBudget", { unit: "retries" }); + const policy = { + type: "retry", + attempt: { type: "attempt", duration: 10 }, + retry: { times: 4, retryBudget: RetryBudget }, + }; + + const exceeded = estimateRetry(policy, { + retryBudgets: [{ + key: RetryBudget, + state: { limit: 3, spent: 1, unit: "retries" }, + }], + }); + const unverified = estimateRetry(policy); + + assert.equal(exceeded.valid, false); + assert.equal(exceeded.retryBudgets[0].status, "exceeded"); + assert.equal(exceeded.retryBudgets[0].required, 3); + assert.equal(exceeded.retryBudgets[0].remaining, 2); + assert.equal(exceeded.warnings.at(-1).code, "retry_budget_exceeded"); + + assert.equal(unverified.valid, false); + assert.deepEqual(unverified.retryBudgets, [{ + key: "ConstrainedRetryBudget", + required: 3, + status: "unverified", + }]); + assert.equal(unverified.warnings.at(-1).code, "retry_budget_snapshot_missing"); +}); + +test("Given duplicate or invalid retry budget snapshots, planner rejects ambiguous state", () => { + const RetryBudget = createBudget("InvalidPlannerRetryBudget", { unit: "retries" }); + const policy = { + type: "retry", + attempt: { type: "attempt", duration: 10 }, + retry: { times: 2, retryBudget: RetryBudget }, + }; + + assert.throws(() => planTimePolicy(policy, { + retryBudgets: [ + { key: RetryBudget, state: { limit: 2, spent: 0 } }, + { key: RetryBudget, state: { limit: 2, spent: 0 } }, + ], + }), /duplicate retry budget snapshot/); + assert.throws(() => planTimePolicy(policy, { + retryBudgets: [{ key: RetryBudget, state: { limit: 1, spent: 2 } }], + }), /retry budget snapshot/); +}); + test("Given timeout policies, planTimePolicy distinguishes retry, hedge, and generic truncation", () => { const retry = planTimePolicy({ type: "timeout",