diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 857a12a..f6cdc5b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,15 +14,15 @@ For anything not covered here, open an [issue](https://github.com/parallel-web/p ## Submitting a Recipe -A recipe is a self-contained working application with a live demo and a clear pedagogical point. Every recipe in the cookbook should answer *"why would someone copy this?"* in one sentence. +A recipe is a self-contained working example with a clear pedagogical point. Every recipe in the cookbook should answer *"why would someone copy this?"* in one sentence. Hosted applications should include a live demo or deploy path. Local scripts and notebooks can omit a demo when the local workflow is the point and the README includes an end-to-end verification path. ### What makes a good recipe | ✅ Good | ❌ Avoid | | --- | --- | | Solves one specific problem (e.g. "stream Task progress to a UI") | Tries to demo every Parallel API at once (templates are the exception) | -| Shipped with a live demo URL | Local-only with no deploy story | -| Has a one-click deploy button or `DEPLOY.md` | Requires hand-rolling infra to try it | +| Has a live demo, deploy path, or verified local workflow | Uses a fake demo link or leaves the reader without a way to test it | +| Documents deployment when hosting is part of the recipe | Requires infrastructure that the README does not explain | | Real research/data domain (companies, papers, news) | "Hello world" toy data | | README explains the *design decisions*, not just setup | README is just `npm install && npm run dev` | | Uses our latest API surfaces (Search, Extract, Task SSE, Ingest) | Wraps deprecated endpoints | @@ -56,7 +56,7 @@ Every recipe README should hit these sections, roughly in this order: ```markdown # -One-sentence pitch. Live demo: +One-sentence pitch. Live demo: [Deploy button or link to DEPLOY.md] @@ -104,6 +104,8 @@ After your recipe is in place, add an entry to two places so it shows up everywh } ``` + Use `null` for `websiteUrl` when an accepted local recipe has no hosted demo. The Cookbook hides the Demo button for those entries. + Reviewers set `featured` / `popular`. ### Tags @@ -121,7 +123,7 @@ Propose new tags in your PR if none of these fit. Before opening a PR, double-check: - [ ] Recipe runs end-to-end from a fresh clone using only the README -- [ ] Live demo URL is reachable and shows what the README claims +- [ ] Live demo URL is reachable and shows what the README claims, when the recipe has one - [ ] No API keys, secrets, or `.env` / `.dev.vars` files committed (use `.example` variants) - [ ] No hardcoded Cloudflare account IDs or KV namespace IDs — use `` placeholders and document `wrangler kv:namespace create` in the README - [ ] Production-only routes live under `env.production` in `wrangler.jsonc`, not the default config diff --git a/README.md b/README.md index deee3fe..0abb61a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ --- -The Parallel Cookbook is a curated set of recipes that show how to build real applications on Parallel's web research stack — **Search**, **Extract**, **Task**, **Ingest**, and **MCP**. Each recipe is a working app with a live demo, a deploy button, and prose that explains the design decisions. +The Parallel Cookbook is a curated set of recipes that show how to build with Parallel's web research stack — **Search**, **Extract**, **Task**, **Ingest**, and **MCP**. Each recipe is a working example with a clear way to run or deploy it and prose that explains the design decisions. > **New here?** Start with the [Vercel Template](typescript-recipes/parallel-vercel-template) (TypeScript) or the [Deep Research notebook](python-recipes/Deep_Research_Recipe.ipynb) (Python) for an end-to-end tour of the platform. @@ -115,6 +115,7 @@ Recurring research, cron jobs, and webhook delivery. | Recipe | Description | APIs | Stack | Demo | | --- | --- | --- | --- | --- | | [**Daily Insights**](typescript-recipes/parallel-daily-insights) | Cron-triggered daily research feed — runs Tasks on a schedule, persists to KV, publishes a public data feed. Includes a `SPEC.md` showing the task spec used. | `Task` `Webhooks` `Cron` | Cloudflare Workers · KV | – | +| [**Vendor Intelligence**](typescript-recipes/parallel-vendor-intelligence) | Researches vendor risk, watches the structured report for changes, and runs follow-up research only when a change crosses the review threshold. | `Task` `Deep Research` `Monitors` | TypeScript · Local scripts | – | ### Deep Research & Notebooks diff --git a/typescript-recipes/parallel-vendor-intelligence/.env.example b/typescript-recipes/parallel-vendor-intelligence/.env.example new file mode 100644 index 0000000..62591ce --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/.env.example @@ -0,0 +1,5 @@ +PARALLEL_API_KEY= + +# Optional policy controls used by the three lifecycle commands. +MONITOR_FREQUENCY=1d +FOLLOW_UP_RISK_THRESHOLD=HIGH diff --git a/typescript-recipes/parallel-vendor-intelligence/.gitignore b/typescript-recipes/parallel-vendor-intelligence/.gitignore new file mode 100644 index 0000000..4f2651b --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +.vendor-intelligence/ diff --git a/typescript-recipes/parallel-vendor-intelligence/LICENSE b/typescript-recipes/parallel-vendor-intelligence/LICENSE new file mode 100644 index 0000000..9ba296c --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Parallel Web Systems + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/typescript-recipes/parallel-vendor-intelligence/README.md b/typescript-recipes/parallel-vendor-intelligence/README.md new file mode 100644 index 0000000..b7da406 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/README.md @@ -0,0 +1,243 @@ +# Vendor intelligence with Parallel + +Research a vendor once, monitor the same risk report for changes, and investigate only the changes that cross your review threshold. + +![Vendor intelligence flow](docs/vendor-intelligence-flow-infographic.png) + +This recipe uses the Parallel Task API and Monitor API through three local commands: + +- `bootstrap` researches each vendor and starts a snapshot Monitor. +- `check-updates` reconstructs new Monitor events, assesses risk deterministically, and optionally runs focused follow-up research when a change crosses the threshold. +- `cleanup` cancels the Monitors recorded in this recipe's local state. + +Parallel provides the researched evidence. The recipe validates that evidence and applies a deterministic risk policy, so the final risk level and human-review guidance do not depend on another model call. + +## Quick start + +You need Node.js 20 or newer and a [Parallel API key](https://platform.parallel.ai). `jq` is optional; it only formats the JSON output in the examples below. + +```bash +git clone https://github.com/parallel-web/parallel-cookbook.git +cd parallel-cookbook/typescript-recipes/parallel-vendor-intelligence +npm ci +cp .env.example .env +chmod 600 .env +``` + +Add your key to `.env`: + +```dotenv +PARALLEL_API_KEY=your-api-key +``` + +Keep `.env` private. It is ignored by Git. + +The included vendor file contains one public company, Cloudflare. Create its baseline and Monitor: + +```bash +npm run --silent bootstrap | jq +``` + +This command consumes credits. A normal first run can take a minute or two. It prints progress to stderr and one JSON result to stdout, so piping stdout to `jq` keeps the result readable. + +A successful result includes the Task and Monitor IDs, the cited report, and the policy decision. The shortened example below uses sample values: + +```json +{ + "vendors": 1, + "baselinesCreated": 1, + "monitorsCreated": 1, + "results": [ + { + "vendor": { "name": "Cloudflare", "domain": "cloudflare.com" }, + "baseline": { "action": "created", "runId": "trun_..." }, + "monitor": { + "action": "created", + "monitorId": "monitor_...", + "frequency": "1d", + "processor": "lite" + }, + "assessment": { + "risk": { + "level": "MEDIUM", + "requiresHumanReview": true, + "guidance": "analyst_review" + } + } + } + ] +} +``` + +Leave the Monitor active if you want it to keep watching the vendor. Run the update check later, after the Monitor has had time to produce an event: + +```bash +npm run --silent check-updates | jq +``` + +When a change is found, the command emits one structured summary. This abridged example uses sample IDs, findings, and citations rather than output from a specific live run: + +```json +{ + "monitorsChecked": 1, + "newEvents": 1, + "followUpDecisions": 1, + "followUpTasksCreated": 1, + "followUpsCompleted": 1, + "humanReviewsRequired": 1, + "changes": [ + { + "vendor": { "name": "Cloudflare", "domain": "cloudflare.com" }, + "event": { + "monitorId": "monitor_...", + "eventId": "mevt_...", + "eventDate": "2026-07-14", + "changedFields": ["cybersecurity"] + }, + "assessment": { + "risk": { + "level": "HIGH", + "requiresHumanReview": true, + "guidance": "urgent_human_review" + } + }, + "decision": { + "runFollowUp": true, + "threshold": "HIGH", + "previousLevel": "MEDIUM", + "currentLevel": "HIGH" + }, + "followUp": { + "status": "completed", + "runId": "trun_...", + "investigation": { + "what_changed": "A newly reported security incident changed the assessment.", + "confirmed_facts": ["The incident was confirmed in public reporting."], + "business_impact": "Review exposure and contingency plans.", + "open_questions": ["Does the incident affect shared data?"] + } + } + } + ], + "warnings": [], + "errors": [] +} +``` + +An empty `changes` list is a successful result; it means there are no new Monitor events to process. Running `bootstrap` again reuses the completed baseline and matching active Monitor. + +When you are finished, cancel every Monitor owned by this recipe: + +```bash +npm run --silent cleanup | jq +``` + +Do not delete `.vendor-intelligence/state.json` before cleanup. The state file tells the recipe which Monitors it owns. + +## Use your own vendor list + +`bootstrap` reads `examples/vendors.json` by default. Each entry needs a name and domain. `riskFloor` is optional: + +```json +[ + { + "name": "Cloudflare", + "domain": "cloudflare.com", + "riskFloor": "MEDIUM" + } +] +``` + +Run the command with another file: + +```bash +npm run --silent bootstrap -- --vendors /absolute/path/to/vendors.json | jq +``` + +The input must contain at least one vendor and cannot contain duplicate domains. Domains are normalized before any API call. + +Bootstrap is additive: removing a vendor from the input file does not cancel its existing Monitor. Cancel one vendor explicitly with: + +```bash +npm run --silent cleanup -- --vendor cloudflare.com | jq +``` + +Repeat `--vendor` to cancel several vendors. Run cleanup without flags to cancel all active Monitors recorded in local state. + +## How the recipe controls cost + +| Stage | Processor | When it runs | +| --- | --- | --- | +| Baseline research | `core` | Once for each new vendor or explicit retry | +| Snapshot monitoring | `lite` | At the configured Monitor frequency | +| Focused investigation | `pro` | Only when a changed risk field meets the review threshold | + +A vendor `riskFloor` at or above `FOLLOW_UP_RISK_THRESHOLD` also triggers investigation for any changed risk field. Tasks and active Monitors consume Parallel credits. Completed Tasks remain in your account history and cannot be cancelled. + +The risk assessment and threshold decision are completed before any optional follow-up Task. Follow-up research adds confirmed facts, business impact, open questions, and citations; it does not rescore the report or replace the deterministic decision. + +`adverse_events` are discrete, evidence-backed events returned by the Task, such as a breach or lawsuit. Their severity participates in aggregate scoring, and the presence of any supported adverse event requires human review even when its severity is `LOW`. + +`check-updates` reports each processed event with its changed fields, current assessment, policy decision, and one follow-up status: + +- `not_required`: the change did not cross the threshold. +- `pending`: follow-up research is still running. +- `completed`: the result includes confirmed facts, business impact, open questions, and citations. +- `failed`: the result includes the terminal Task ID and error. + +## State and recovery + +The recipe stores Task IDs, Monitor IDs, evidence snapshots, and processed event IDs in `.vendor-intelligence/state.json`. Writes are validated and atomic. A command lock prevents two lifecycle commands from changing the same state at once. Monitor patches are applied oldest to newest to a known complete predecessor; an invalid event blocks dependent partial patches until a complete event rebases the chain. + +Commands are safe to repeat. A repeated command resumes a running Task, reuses a matching Monitor, and skips Monitor events it has already processed. Cleanup only cancels Monitor IDs found in local state. + +If a command stops because of a timeout or temporary network error, run the same command again. If a Task reaches a terminal state such as `failed`, `cancelled`, or `action_required`, inspect the error before creating a replacement: + +```bash +npm run bootstrap -- --retry-failed +npm run check-updates -- --retry-failed +``` + +The recipe does not automatically retry a request that creates a paid Task or Monitor. The API cannot safely tell whether a lost response created the resource, so an automatic retry could spend credits twice. If the state file is malformed, the recipe stops instead of silently resetting it. Back up the file and recover any recorded Monitor IDs before removing the state directory. + +If a process stops while recovering a stale command lock, the error names the recovery-marker file. First confirm that no recipe command is still running. Then delete that file and run the command again: + +```bash +rm -rf .vendor-intelligence/command.lock.reclaim +``` + +## Customize the policy + +Only the API key is required. Two environment variables change the Monitor schedule and review threshold: + +```dotenv +MONITOR_FREQUENCY=1d +FOLLOW_UP_RISK_THRESHOLD=HIGH +``` + +`MONITOR_FREQUENCY` accepts values from `1h` through `30d`. `FOLLOW_UP_RISK_THRESHOLD` accepts `LOW`, `MEDIUM`, `HIGH`, or `CRITICAL`. + +The six risk dimensions live in [`src/schema.ts`](src/schema.ts). Aggregate risk and human guidance live in [`src/risk-policy.ts`](src/risk-policy.ts). Change those two modules to adapt the recipe to your organization's vendor policy. + +## Test the recipe + +The normal test suite makes no API calls: + +```bash +npm run validate +npm audit --audit-level=high +``` + +The live test consumes credits. It researches Cloudflare with a real `core` Task, creates a `lite` snapshot Monitor, checks for updates, cancels the Monitor in cleanup, and confirms its remote status is `cancelled`: + +```bash +npm run test:live +``` + +The Task API may print an advisory warning that the baseline could be too complex for the `core` processor. The command still validates the complete six-dimension report before it succeeds. If your own baseline fails validation or the evidence is not useful, simplify the report in [`src/schema.ts`](src/schema.ts) or change `baselineProcessor` to `pro` in [`src/vendor-config.ts`](src/vendor-config.ts). + +The live test does not wait for a real vendor change. Deterministic tests cover the change-to-follow-up path. + +## License + +[MIT](LICENSE) diff --git a/typescript-recipes/parallel-vendor-intelligence/docs/vendor-intelligence-flow-infographic.png b/typescript-recipes/parallel-vendor-intelligence/docs/vendor-intelligence-flow-infographic.png new file mode 100644 index 0000000..fd9617d Binary files /dev/null and b/typescript-recipes/parallel-vendor-intelligence/docs/vendor-intelligence-flow-infographic.png differ diff --git a/typescript-recipes/parallel-vendor-intelligence/examples/vendors.json b/typescript-recipes/parallel-vendor-intelligence/examples/vendors.json new file mode 100644 index 0000000..d5e9b3e --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/examples/vendors.json @@ -0,0 +1,6 @@ +[ + { + "name": "Cloudflare", + "domain": "cloudflare.com" + } +] diff --git a/typescript-recipes/parallel-vendor-intelligence/package-lock.json b/typescript-recipes/parallel-vendor-intelligence/package-lock.json new file mode 100644 index 0000000..4e2a504 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/package-lock.json @@ -0,0 +1,1867 @@ +{ + "name": "parallel-vendor-intelligence", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "parallel-vendor-intelligence", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "dotenv": "17.4.2", + "parallel-web": "1.1.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "20.19.43", + "tsx": "4.23.0", + "typescript": "5.9.3", + "vitest": "4.1.10" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "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.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "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": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "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" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "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" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "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" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "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" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "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" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "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" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@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.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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "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" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "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, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "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", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@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": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parallel-web": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/parallel-web/-/parallel-web-1.1.0.tgz", + "integrity": "sha512-lGbSbuBVBT5OD+5N157HuccucFLC4LHklZfNzqLp6OCgVbQD8dw3ZfzFvNZ3a8e6sdJixmYH65YQbkioo1KfjQ==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "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", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "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.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "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": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/typescript-recipes/parallel-vendor-intelligence/package.json b/typescript-recipes/parallel-vendor-intelligence/package.json new file mode 100644 index 0000000..4204f1a --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/package.json @@ -0,0 +1,32 @@ +{ + "name": "parallel-vendor-intelligence", + "version": "0.1.0", + "private": true, + "description": "Scripts-first vendor risk research and monitoring with Parallel", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "bootstrap": "tsx scripts/bootstrap.ts", + "check-updates": "tsx scripts/check-updates.ts", + "cleanup": "tsx scripts/cleanup.ts", + "check": "tsc", + "test": "vitest run", + "test:live": "RUN_LIVE_TESTS=1 vitest run tests/live.test.ts --reporter=verbose", + "test:watch": "vitest", + "validate": "npm run check && npm test" + }, + "dependencies": { + "dotenv": "17.4.2", + "parallel-web": "1.1.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "20.19.43", + "tsx": "4.23.0", + "typescript": "5.9.3", + "vitest": "4.1.10" + } +} diff --git a/typescript-recipes/parallel-vendor-intelligence/scripts/bootstrap.ts b/typescript-recipes/parallel-vendor-intelligence/scripts/bootstrap.ts new file mode 100644 index 0000000..16106ee --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/scripts/bootstrap.ts @@ -0,0 +1,24 @@ +import "dotenv/config"; + +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { parseArgs } from "node:util"; + +import { z } from "zod"; + +import { createVendorIntelligenceFromEnv } from "../src/config.js"; +import { VendorSchema } from "../src/schema.js"; + +const { values } = parseArgs({ + options: { + vendors: { type: "string", short: "v", default: "examples/vendors.json" }, + "retry-failed": { type: "boolean", default: false }, + }, +}); + +const vendorPath = resolve(process.cwd(), values.vendors!); +const vendors = z.array(VendorSchema).parse(JSON.parse(await readFile(vendorPath, "utf8"))); +const summary = await createVendorIntelligenceFromEnv().bootstrap(vendors, { + retryFailed: values["retry-failed"], +}); +console.log(JSON.stringify(summary, null, 2)); diff --git a/typescript-recipes/parallel-vendor-intelligence/scripts/check-updates.ts b/typescript-recipes/parallel-vendor-intelligence/scripts/check-updates.ts new file mode 100644 index 0000000..61d3512 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/scripts/check-updates.ts @@ -0,0 +1,17 @@ +import "dotenv/config"; + +import { parseArgs } from "node:util"; + +import { createVendorIntelligenceFromEnv } from "../src/config.js"; + +const { values } = parseArgs({ + options: { + "retry-failed": { type: "boolean", default: false }, + }, +}); + +const summary = await createVendorIntelligenceFromEnv().checkForUpdates({ + retryFailed: values["retry-failed"], +}); +console.log(JSON.stringify(summary, null, 2)); +if (summary.errors.length > 0) process.exitCode = 1; diff --git a/typescript-recipes/parallel-vendor-intelligence/scripts/cleanup.ts b/typescript-recipes/parallel-vendor-intelligence/scripts/cleanup.ts new file mode 100644 index 0000000..a8dabad --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/scripts/cleanup.ts @@ -0,0 +1,17 @@ +import "dotenv/config"; + +import { parseArgs } from "node:util"; + +import { createVendorIntelligenceFromEnv } from "../src/config.js"; + +const { values } = parseArgs({ + options: { + vendor: { type: "string", multiple: true }, + }, +}); + +const summary = await createVendorIntelligenceFromEnv().cleanup({ + ...(values.vendor ? { vendors: values.vendor } : {}), +}); +console.log(JSON.stringify(summary, null, 2)); +if (summary.monitors.some(({ status }) => status === "failed")) process.exitCode = 1; diff --git a/typescript-recipes/parallel-vendor-intelligence/src/command-lock.ts b/typescript-recipes/parallel-vendor-intelligence/src/command-lock.ts new file mode 100644 index 0000000..0288a82 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/src/command-lock.ts @@ -0,0 +1,202 @@ +import { randomUUID } from "node:crypto"; +import { + mkdir, + open, + readFile, + rename, + rm, + stat, + unlink, + type FileHandle, +} from "node:fs/promises"; +import { hostname } from "node:os"; + +import { z } from "zod"; + +const LockOwnerSchema = z.object({ + version: z.literal(1), + token: z.string().uuid(), + pid: z.number().int().positive(), + hostname: z.string().min(1), + command: z.string().min(1), + acquiredAt: z.string().min(1), +}); + +type LockOwner = z.infer; +const INITIALIZATION_GRACE_MS = 30_000; + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} + +async function readOwner(lockPath: string): Promise { + try { + const parsed = LockOwnerSchema.safeParse(JSON.parse(await readFile(lockPath, "utf8"))); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } +} + +async function describeLock(lockPath: string): Promise { + const owner = await readOwner(lockPath); + return owner + ? `${owner.command}, pid ${owner.pid}, since ${owner.acquiredAt}` + : undefined; +} + +async function recoveryGuardError(guardPath: string): Promise { + const owner = await readOwner(guardPath); + if (owner) { + if (owner.hostname === hostname() && !processIsAlive(owner.pid)) { + return new Error( + `A previous command stopped while recovering a stale lock (${owner.command}, pid ${owner.pid}). Verify that no vendor-intelligence command is running, delete ${guardPath}, and retry.`, + ); + } + return new Error( + `Another vendor-intelligence command is recovering a stale lock (${owner.command}, pid ${owner.pid}, since ${owner.acquiredAt}). Wait for it to finish before retrying.`, + ); + } + + try { + const details = await stat(guardPath); + if (Date.now() - details.mtimeMs >= INITIALIZATION_GRACE_MS) { + return new Error( + `An incomplete stale-lock recovery marker remains at ${guardPath}. Verify that no vendor-intelligence command is running, delete that file, and retry.`, + ); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return new Error("The stale-lock recovery marker changed. Retry the command."); + } + throw error; + } + + return new Error( + "Another vendor-intelligence command is initializing stale-lock recovery. Wait for it to finish before retrying.", + ); +} + +async function reclaimStaleLock(lockPath: string, guardOwner: LockOwner): Promise { + // Only one contender may inspect-and-reclaim. Without this guard, a slower + // contender could rename a fresh lock created after another reclaimed the stale one. + const guardPath = `${lockPath}.reclaim`; + let guardHandle: FileHandle | undefined; + try { + guardHandle = await open(guardPath, "wx", 0o600); + await guardHandle.writeFile(`${JSON.stringify(guardOwner, null, 2)}\n`, "utf8"); + await guardHandle.sync(); + if ((await readOwner(guardPath))?.token !== guardOwner.token) { + throw new Error("Stale-lock recovery ownership changed during acquisition."); + } + } catch (error) { + if (guardHandle) { + await releaseOwnedLock(guardPath, guardOwner.token, guardHandle); + guardHandle = undefined; + } + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw await recoveryGuardError(guardPath); + } + throw error; + } + + try { + const owner = await readOwner(lockPath); + if (owner) { + if (owner.hostname !== hostname() || processIsAlive(owner.pid)) return false; + } else { + try { + const details = await stat(lockPath); + if (Date.now() - details.mtimeMs < INITIALIZATION_GRACE_MS) return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; + throw error; + } + } + + const quarantinePath = `${lockPath}.stale-${randomUUID()}`; + try { + await rename(lockPath, quarantinePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; + throw error; + } + await rm(quarantinePath, { force: true }); + return true; + } finally { + if (guardHandle) { + await releaseOwnedLock(guardPath, guardOwner.token, guardHandle); + } + } +} + +async function releaseOwnedLock( + lockPath: string, + token: string, + handle: FileHandle, +): Promise { + try { + if ((await readOwner(lockPath))?.token === token) { + await unlink(lockPath).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + }); + } + } finally { + await handle.close().catch(() => {}); + } +} + +/** Serialize a complete command so check-then-create API sequences cannot overlap. */ +export async function withCommandLock(input: { + rootDirectory: string; + lockPath: string; + command: string; + action: () => Promise; +}): Promise { + await mkdir(input.rootDirectory, { recursive: true, mode: 0o700 }); + const owner: LockOwner = { + version: 1, + token: randomUUID(), + pid: process.pid, + hostname: hostname(), + command: input.command, + acquiredAt: new Date().toISOString(), + }; + + let handle: FileHandle | undefined; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + handle = await open(input.lockPath, "wx", 0o600); + await handle.writeFile(`${JSON.stringify(owner, null, 2)}\n`, "utf8"); + await handle.sync(); + if ((await readOwner(input.lockPath))?.token !== owner.token) { + throw new Error("Command lock ownership changed during acquisition."); + } + break; + } catch (error) { + if (handle) { + await releaseOwnedLock(input.lockPath, owner.token, handle); + handle = undefined; + } + if ((error as NodeJS.ErrnoException).code !== "EEXIST" || attempt > 0) throw error; + if (!(await reclaimStaleLock(input.lockPath, owner))) { + const current = await describeLock(input.lockPath); + throw new Error( + `Another vendor-intelligence command is active${current ? ` (${current})` : ""}. Wait for it to finish before retrying.`, + ); + } + } + } + + if (!handle) throw new Error("Could not acquire the vendor-intelligence command lock."); + try { + return await input.action(); + } finally { + await releaseOwnedLock(input.lockPath, owner.token, handle); + } +} diff --git a/typescript-recipes/parallel-vendor-intelligence/src/config.ts b/typescript-recipes/parallel-vendor-intelligence/src/config.ts new file mode 100644 index 0000000..cf908ea --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/src/config.ts @@ -0,0 +1,67 @@ +import { resolve } from "node:path"; + +import Parallel from "parallel-web"; + +import type { ParallelPort } from "./parallel-port.js"; +import { createParallelPort } from "./parallel-sdk-adapter.js"; +import { FileStateStore } from "./state.js"; +import { VendorIntelligence } from "./vendor-intelligence.js"; +import { + DEFAULT_CONFIG, + VendorIntelligenceConfigSchema, + type VendorIntelligenceConfig, +} from "./vendor-config.js"; + +export function configFromEnv( + env: NodeJS.ProcessEnv, +): Pick { + const parsed = VendorIntelligenceConfigSchema.parse({ + ...DEFAULT_CONFIG, + ...configValuesFromEnv(env), + }); + return { + monitorFrequency: parsed.monitorFrequency, + followUpRiskThreshold: parsed.followUpRiskThreshold, + }; +} + +function configValuesFromEnv(env: NodeJS.ProcessEnv): Record { + return { + ...(env.MONITOR_FREQUENCY !== undefined + ? { monitorFrequency: env.MONITOR_FREQUENCY } + : {}), + ...(env.FOLLOW_UP_RISK_THRESHOLD !== undefined + ? { followUpRiskThreshold: env.FOLLOW_UP_RISK_THRESHOLD } + : {}), + }; +} + +export function createVendorIntelligenceFromEnv( + env: NodeJS.ProcessEnv = process.env, + options: { + client?: ParallelPort; + stateDirectory?: string; + config?: Partial; + } = {}, +): VendorIntelligence { + const apiKey = env.PARALLEL_API_KEY; + if (!apiKey && !options.client) { + throw new Error("PARALLEL_API_KEY is required."); + } + const config = VendorIntelligenceConfigSchema.parse({ + ...DEFAULT_CONFIG, + ...configValuesFromEnv(env), + ...options.config, + }); + const client: ParallelPort = + options.client ?? + createParallelPort(new Parallel({ apiKey: apiKey!, timeout: 60_000 })); + + return new VendorIntelligence({ + client, + store: new FileStateStore( + options.stateDirectory ?? resolve(process.cwd(), ".vendor-intelligence"), + ), + config, + }); +} diff --git a/typescript-recipes/parallel-vendor-intelligence/src/parallel-port.ts b/typescript-recipes/parallel-vendor-intelligence/src/parallel-port.ts new file mode 100644 index 0000000..eafac11 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/src/parallel-port.ts @@ -0,0 +1,207 @@ +/** + * The recipe-owned portion of the Parallel API contract. + * + * Keep generated SDK types behind the adapter. Besides making orchestration + * tests small, this avoids coupling the recipe to resource-class declarations + * that happen to share names with API response objects. + */ + +export interface JsonSchema { + type: "json"; + json_schema: Record; +} + +export interface Citation { + url: string; + excerpts?: string[] | null; + title?: string | null; +} + +export interface FieldBasis { + field: string; + reasoning: string; + citations?: Citation[]; + confidence?: string | null; +} + +export interface Warning { + message: string; +} + +export interface TaskRunError { + message: string; + ref_id?: string; +} + +export type TaskRunStatus = + | "queued" + | "action_required" + | "running" + | "completed" + | "failed" + | "cancelling" + | "cancelled"; + +export interface TaskRun { + run_id: string; + interaction_id: string; + status: TaskRunStatus; + error?: TaskRunError | null; + warnings?: Warning[] | null; +} + +export interface TaskRunJsonOutput { + type: "json"; + content: unknown; + basis: unknown; +} + +export interface TaskRunTextOutput { + type: "text"; + content: unknown; + basis: unknown; +} + +export type TaskRunOutput = TaskRunJsonOutput | TaskRunTextOutput; + +export interface TaskRunResult { + run: TaskRun; + output: TaskRunOutput; +} + +export interface TaskRunCreateParams { + input: Record; + processor: string; + task_spec: { + output_schema: JsonSchema; + input_schema?: JsonSchema; + }; + metadata: Record; + previous_interaction_id?: string; +} + +export interface TaskRunResultParams { + timeout?: number; +} + +export interface RequestOptions { + maxRetries?: number; + timeout?: number; +} + +interface MonitorBase { + monitor_id: string; + status: "active" | "cancelled"; + processor: "lite" | "base"; + frequency: string; + created_at: string; + metadata?: Record | null; +} + +export interface SnapshotMonitor extends MonitorBase { + type: "snapshot"; + settings: { + task_run_id: string; + query?: string; + }; +} + +export interface EventStreamMonitor extends MonitorBase { + type: "event_stream"; + settings: { + query: string; + }; +} + +export type Monitor = SnapshotMonitor | EventStreamMonitor; + +export interface MonitorCreateParams { + type: "snapshot"; + frequency: string; + processor: "lite" | "base"; + settings: { + task_run_id: string; + }; + metadata: Record; +} + +export interface MonitorListParams { + cursor?: string; + limit?: number; + status?: Array<"active" | "cancelled">; + type?: Array<"event_stream" | "snapshot">; +} + +export interface MonitorEventsParams { + cursor?: string; + event_group_id?: string; + include_completions?: boolean; + limit?: number; +} + +export interface MonitorSnapshotEvent { + event_id: string; + event_group_id: string; + event_date: string | null; + previous_output: TaskRunOutput; + changed_output: TaskRunOutput; + event_type?: "snapshot"; +} + +export interface MonitorEventStreamEvent { + event_id: string; + event_group_id: string; + event_date: string | null; + output: TaskRunOutput; + event_type?: "event_stream"; +} + +export interface MonitorCompletionEvent { + timestamp: string; + event_type?: "completion"; +} + +export interface MonitorErrorEvent { + error_message: string; + timestamp: string; + event_type?: "error"; +} + +export type MonitorEvent = + | MonitorSnapshotEvent + | MonitorEventStreamEvent + | MonitorCompletionEvent + | MonitorErrorEvent; + +export interface PaginatedMonitorEvents { + events: MonitorEvent[]; + next_cursor?: string | null; + warnings?: Warning[] | null; +} + +export interface PaginatedMonitorResponse { + monitors: Monitor[]; + next_cursor?: string | null; +} + +export interface ParallelPort { + taskRun: { + create(params: TaskRunCreateParams, options?: RequestOptions): Promise; + retrieve(runId: string): Promise; + result( + runId: string, + params?: TaskRunResultParams | null, + options?: RequestOptions, + ): Promise; + }; + monitor: { + create(params: MonitorCreateParams, options?: RequestOptions): Promise; + retrieve(monitorId: string): Promise; + list(params?: MonitorListParams | null): Promise; + events( + monitorId: string, + params?: MonitorEventsParams | null, + ): Promise; + cancel(monitorId: string): Promise; + }; +} diff --git a/typescript-recipes/parallel-vendor-intelligence/src/parallel-sdk-adapter.ts b/typescript-recipes/parallel-vendor-intelligence/src/parallel-sdk-adapter.ts new file mode 100644 index 0000000..4828c58 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/src/parallel-sdk-adapter.ts @@ -0,0 +1,200 @@ +import type Parallel from "parallel-web"; +import type { + Monitor as SdkMonitor, + MonitorCompletionEvent as SdkMonitorCompletionEvent, + MonitorErrorEvent as SdkMonitorErrorEvent, + MonitorEventStreamEvent as SdkMonitorEventStreamEvent, + MonitorSnapshotEvent as SdkMonitorSnapshotEvent, +} from "parallel-web/resources/monitor"; +import type { + TaskRun as SdkTaskRun, + TaskRunJsonOutput as SdkTaskRunJsonOutput, + TaskRunTextOutput as SdkTaskRunTextOutput, +} from "parallel-web/resources/task-run"; + +import type { + Monitor, + MonitorEvent, + ParallelPort, + SnapshotMonitor, + TaskRun, + TaskRunOutput, +} from "./parallel-port.js"; + +export interface ParallelSdkClient { + taskRun: Pick; + monitor: Pick; +} + +function mapTaskOutput( + output: SdkTaskRunJsonOutput | SdkTaskRunTextOutput, +): TaskRunOutput { + return output.type === "json" + ? { type: "json", content: output.content, basis: output.basis } + : { type: "text", content: output.content, basis: output.basis }; +} + +function mapTaskRun(run: SdkTaskRun): TaskRun { + return { + run_id: run.run_id, + interaction_id: run.interaction_id, + status: run.status, + ...(run.error + ? { + error: { + message: run.error.message, + ref_id: run.error.ref_id, + }, + } + : {}), + ...(run.warnings + ? { + warnings: run.warnings.map(({ message }) => ({ message })), + } + : {}), + }; +} + +function mapMonitor(monitor: SdkMonitor): Monitor { + const shared = { + monitor_id: monitor.monitor_id, + status: monitor.status, + processor: monitor.processor, + frequency: monitor.frequency, + created_at: monitor.created_at, + ...(monitor.metadata ? { metadata: monitor.metadata } : {}), + }; + + if (monitor.type === "snapshot") { + if (!("task_run_id" in monitor.settings)) { + throw new Error( + `Snapshot Monitor ${monitor.monitor_id} did not include a baseline Task run ID.`, + ); + } + return { + ...shared, + type: "snapshot", + settings: { + task_run_id: monitor.settings.task_run_id, + ...(typeof monitor.settings.query === "string" + ? { query: monitor.settings.query } + : {}), + }, + }; + } + + if (!("query" in monitor.settings) || typeof monitor.settings.query !== "string") { + throw new Error(`Event-stream Monitor ${monitor.monitor_id} did not include a query.`); + } + return { + ...shared, + type: "event_stream", + settings: { query: monitor.settings.query }, + }; +} + +function mapSnapshotMonitor(monitor: SdkMonitor): SnapshotMonitor { + const mapped = mapMonitor(monitor); + if (mapped.type !== "snapshot") { + throw new Error( + `Expected a snapshot Monitor, but ${monitor.monitor_id} is ${monitor.type}.`, + ); + } + return mapped; +} + +function mapMonitorEvent( + event: + | SdkMonitorSnapshotEvent + | SdkMonitorEventStreamEvent + | SdkMonitorCompletionEvent + | SdkMonitorErrorEvent, +): MonitorEvent { + if ("changed_output" in event) { + return { + event_id: event.event_id, + event_group_id: event.event_group_id, + event_date: event.event_date, + previous_output: mapTaskOutput(event.previous_output), + changed_output: mapTaskOutput(event.changed_output), + event_type: "snapshot", + }; + } + if ("output" in event) { + return { + event_id: event.event_id, + event_group_id: event.event_group_id, + event_date: event.event_date, + output: mapTaskOutput(event.output), + event_type: "event_stream", + }; + } + if ("error_message" in event) { + return { + error_message: event.error_message, + timestamp: event.timestamp, + event_type: "error", + }; + } + return { + timestamp: event.timestamp, + event_type: "completion", + }; +} + +/** Adapt the generated SDK client to the small, stable contract used by the recipe. */ +export function createParallelPort(client: ParallelSdkClient): ParallelPort { + return { + taskRun: { + async create(params, options) { + return mapTaskRun( + await (options + ? client.taskRun.create(params, options) + : client.taskRun.create(params)), + ); + }, + async retrieve(runId) { + return mapTaskRun(await client.taskRun.retrieve(runId)); + }, + async result(runId, params, options) { + const result = await client.taskRun.result(runId, params, options); + return { + run: mapTaskRun(result.run), + output: mapTaskOutput(result.output), + }; + }, + }, + monitor: { + async create(params, options) { + return mapSnapshotMonitor( + await (options + ? client.monitor.create(params, options) + : client.monitor.create(params)), + ); + }, + async retrieve(monitorId) { + return mapMonitor(await client.monitor.retrieve(monitorId)); + }, + async list(params) { + const page = await client.monitor.list(params); + return { + monitors: page.monitors.map(mapMonitor), + ...(page.next_cursor !== undefined ? { next_cursor: page.next_cursor } : {}), + }; + }, + async events(monitorId, params) { + const page = await client.monitor.events(monitorId, params); + return { + events: page.events.map(mapMonitorEvent), + ...(page.next_cursor !== undefined ? { next_cursor: page.next_cursor } : {}), + ...(page.warnings + ? { warnings: page.warnings.map(({ message }) => ({ message })) } + : {}), + }; + }, + async cancel(monitorId) { + return mapMonitor(await client.monitor.cancel(monitorId)); + }, + }, + } satisfies ParallelPort; +} diff --git a/typescript-recipes/parallel-vendor-intelligence/src/risk-policy.ts b/typescript-recipes/parallel-vendor-intelligence/src/risk-policy.ts new file mode 100644 index 0000000..db99554 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/src/risk-policy.ts @@ -0,0 +1,289 @@ +import { z } from "zod"; + +import type { FieldBasis } from "./parallel-port.js"; +import { + EvidenceFieldSchema, + RISK_DIMENSIONS, + RiskLevelSchema, + evidenceFieldForPath, + type EvidenceField, + type RiskLevel, + type VendorReport, +} from "./schema.js"; + +const RISK_ORDER: Record = { + LOW: 0, + MEDIUM: 1, + HIGH: 2, + CRITICAL: 3, +}; + +export const POLICY_VERSION = 1; + +const GUIDANCE = { + LOW: "continue_monitoring", + MEDIUM: "analyst_review", + HIGH: "urgent_human_review", + CRITICAL: "immediate_human_escalation", +} as const; + +const SeverityCountsSchema = z.object({ + LOW: z.number().int().nonnegative(), + MEDIUM: z.number().int().nonnegative(), + HIGH: z.number().int().nonnegative(), + CRITICAL: z.number().int().nonnegative(), +}); + +const RiskReasonSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("dimension"), + field: EvidenceFieldSchema.exclude(["adverse_events"]), + level: RiskLevelSchema, + }), + z.object({ + kind: z.literal("adverse_event"), + title: z.string(), + level: RiskLevelSchema, + }), + z.object({ + kind: z.literal("vendor_floor"), + level: RiskLevelSchema, + applied: z.boolean(), + }), +]); + +const DisplayCitationSchema = z.object({ + field: EvidenceFieldSchema, + url: z.string(), + title: z.string().nullable().optional(), + excerpts: z.array(z.string()).nullable().optional(), + reasoning: z.string(), + confidence: z.string().nullable().optional(), +}); + +export const RiskAssessmentSchema = z.object({ + level: RiskLevelSchema, + evidenceLevel: RiskLevelSchema, + adverseDetected: z.boolean(), + requiresHumanReview: z.boolean(), + guidance: z.enum([ + "continue_monitoring", + "analyst_review", + "urgent_human_review", + "immediate_human_escalation", + ]), + dimensionCounts: SeverityCountsSchema, + evidenceFields: z.array(EvidenceFieldSchema), + reasons: z.array(RiskReasonSchema), + citations: z.array(DisplayCitationSchema), +}); +export type RiskAssessment = z.infer; + +const FollowUpReasonSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("changed_dimension"), + field: EvidenceFieldSchema.exclude(["adverse_events"]), + previousLevel: RiskLevelSchema, + currentLevel: RiskLevelSchema, + }), + z.object({ + kind: z.literal("changed_adverse_event"), + title: z.string(), + previousLevel: RiskLevelSchema.optional(), + currentLevel: RiskLevelSchema.optional(), + }), + z.object({ + kind: z.literal("vendor_floor"), + level: RiskLevelSchema, + }), +]); + +export const FollowUpDecisionSchema = z.object({ + runFollowUp: z.boolean(), + threshold: RiskLevelSchema, + previousLevel: RiskLevelSchema, + currentLevel: RiskLevelSchema, + changedFields: z.array(EvidenceFieldSchema), + requiresHumanReview: z.boolean(), + reasons: z.array(FollowUpReasonSchema), +}); +export type FollowUpDecision = z.infer; + +export function compareRisk(left: RiskLevel, right: RiskLevel): number { + return RISK_ORDER[left] - RISK_ORDER[right]; +} + +function maxRisk(levels: RiskLevel[]): RiskLevel { + return levels.reduce( + (highest, level) => (compareRisk(level, highest) > 0 ? level : highest), + "LOW", + ); +} + +function selectCitations( + basis: FieldBasis[] = [], + fields: readonly EvidenceField[], +): RiskAssessment["citations"] { + const wanted = new Set(fields); + const seen = new Set(); + const citations: RiskAssessment["citations"] = []; + + for (const entry of basis) { + const field = evidenceFieldForPath(entry.field); + if (!field || !wanted.has(field)) continue; + + for (const citation of entry.citations ?? []) { + const identity = `${field}\u0000${citation.url}`; + if (seen.has(identity)) continue; + seen.add(identity); + citations.push({ + field, + url: citation.url, + ...(citation.title !== undefined ? { title: citation.title } : {}), + ...(citation.excerpts !== undefined ? { excerpts: citation.excerpts } : {}), + reasoning: entry.reasoning, + ...(entry.confidence !== undefined ? { confidence: entry.confidence } : {}), + }); + } + } + + return citations; +} + +export function scoreReport( + report: VendorReport, + riskFloor?: RiskLevel, + basis: FieldBasis[] = [], +): RiskAssessment { + const dimensionCounts = { LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0 }; + const reasons: RiskAssessment["reasons"] = []; + const evidenceFields: EvidenceField[] = []; + const dimensionLevels: RiskLevel[] = []; + + for (const { key } of RISK_DIMENSIONS) { + const level = report[key].severity; + dimensionCounts[level] += 1; + dimensionLevels.push(level); + if (level !== "LOW") { + evidenceFields.push(key); + reasons.push({ kind: "dimension", field: key, level }); + } + } + + const adverseLevels = report.adverse_events.map(({ severity }) => severity); + for (const event of report.adverse_events) { + reasons.push({ kind: "adverse_event", title: event.title, level: event.severity }); + } + if (report.adverse_events.length > 0) evidenceFields.push("adverse_events"); + + const evidenceLevel = maxRisk([...dimensionLevels, ...adverseLevels]); + const level = maxRisk([evidenceLevel, riskFloor ?? "LOW"]); + if (riskFloor) { + reasons.push({ + kind: "vendor_floor", + level: riskFloor, + applied: compareRisk(riskFloor, evidenceLevel) > 0, + }); + } + + const adverseDetected = report.adverse_events.length > 0; + const requiresHumanReview = compareRisk(level, "MEDIUM") >= 0 || adverseDetected; + + return RiskAssessmentSchema.parse({ + level, + evidenceLevel, + adverseDetected, + requiresHumanReview, + guidance: adverseDetected && level === "LOW" ? "analyst_review" : GUIDANCE[level], + dimensionCounts, + evidenceFields, + reasons, + citations: selectCitations(basis, evidenceFields), + }); +} + +function eventIdentity(event: VendorReport["adverse_events"][number]): string { + return JSON.stringify([ + event.category.trim().toLowerCase(), + event.title.trim().toLowerCase(), + event.event_date ?? null, + ]); +} + +export function decideFollowUp(input: { + previousReport: VendorReport; + currentReport: VendorReport; + changedFields: readonly string[]; + threshold: RiskLevel; + riskFloor?: RiskLevel; +}): FollowUpDecision { + const changedFields = z.array(EvidenceFieldSchema).parse(input.changedFields); + const previousAssessment = scoreReport(input.previousReport, input.riskFloor); + const currentAssessment = scoreReport(input.currentReport, input.riskFloor); + const reasons: FollowUpDecision["reasons"] = []; + + for (const { key } of RISK_DIMENSIONS) { + if (!changedFields.includes(key)) continue; + const previousLevel = input.previousReport[key].severity; + const currentLevel = input.currentReport[key].severity; + if ( + compareRisk(previousLevel, input.threshold) >= 0 || + compareRisk(currentLevel, input.threshold) >= 0 + ) { + reasons.push({ kind: "changed_dimension", field: key, previousLevel, currentLevel }); + } + } + + if (changedFields.includes("adverse_events")) { + const previous = new Map( + input.previousReport.adverse_events.map((event) => [eventIdentity(event), event]), + ); + const current = new Map( + input.currentReport.adverse_events.map((event) => [eventIdentity(event), event]), + ); + + for (const identity of new Set([...previous.keys(), ...current.keys()])) { + const previousEvent = previous.get(identity); + const currentEvent = current.get(identity); + if ( + previousEvent && + currentEvent && + previousEvent.severity === currentEvent.severity && + previousEvent.summary === currentEvent.summary + ) { + continue; + } + if ( + (previousEvent && compareRisk(previousEvent.severity, input.threshold) >= 0) || + (currentEvent && compareRisk(currentEvent.severity, input.threshold) >= 0) + ) { + reasons.push({ + kind: "changed_adverse_event", + title: currentEvent?.title ?? previousEvent!.title, + ...(previousEvent ? { previousLevel: previousEvent.severity } : {}), + ...(currentEvent ? { currentLevel: currentEvent.severity } : {}), + }); + } + } + } + + if ( + changedFields.length > 0 && + input.riskFloor && + compareRisk(input.riskFloor, input.threshold) >= 0 + ) { + reasons.push({ kind: "vendor_floor", level: input.riskFloor }); + } + + return FollowUpDecisionSchema.parse({ + runFollowUp: reasons.length > 0, + threshold: input.threshold, + previousLevel: previousAssessment.level, + currentLevel: currentAssessment.level, + changedFields, + requiresHumanReview: + reasons.length > 0 && + (previousAssessment.requiresHumanReview || currentAssessment.requiresHumanReview), + reasons, + }); +} diff --git a/typescript-recipes/parallel-vendor-intelligence/src/schema.ts b/typescript-recipes/parallel-vendor-intelligence/src/schema.ts new file mode 100644 index 0000000..a71ed18 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/src/schema.ts @@ -0,0 +1,305 @@ +import { isIP } from "node:net"; + +import { z } from "zod"; + +import type { + JsonSchema, + MonitorCreateParams, + TaskRunCreateParams, +} from "./parallel-port.js"; + +export const SPEC_VERSION = 1; +export const RECIPE_METADATA = "vendor-intel"; + +export const RiskLevelSchema = z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]); +export type RiskLevel = z.infer; + +export const RISK_DIMENSIONS = [ + { + key: "financial_health", + label: "Financial health", + description: + "Financial stability, funding, solvency, material revenue changes, layoffs, and signs the vendor may be unable to meet its obligations.", + }, + { + key: "legal_regulatory", + label: "Legal and regulatory", + description: + "Material litigation, enforcement actions, sanctions, regulatory restrictions, and compliance failures that could affect the vendor relationship.", + }, + { + key: "operational_resilience", + label: "Operational resilience", + description: + "Service outages, supply-chain disruptions, business continuity concerns, capacity constraints, and other threats to reliable delivery.", + }, + { + key: "leadership_governance", + label: "Leadership and governance", + description: + "Executive or board changes, governance failures, ownership changes, fraud allegations, and leadership instability.", + }, + { + key: "esg_reputation", + label: "ESG and reputation", + description: + "Environmental, labor, ethics, public trust, and reputational developments that could create business or stakeholder risk.", + }, + { + key: "cybersecurity", + label: "Cybersecurity", + description: + "Security incidents, data breaches, exploited vulnerabilities, privacy failures, and material weaknesses in the vendor's security posture.", + }, +] as const; + +export type RiskDimensionKey = (typeof RISK_DIMENSIONS)[number]["key"]; +export const EvidenceFieldSchema = z.enum([ + ...RISK_DIMENSIONS.map(({ key }) => key), + "adverse_events", +]); +export type EvidenceField = z.infer; + +/** Map SDK basis paths such as `cybersecurity.summary` to the report field they support. */ +export function evidenceFieldForPath(path: string): EvidenceField | undefined { + const root = /^[^.[\]]+/.exec(path)?.[0]; + const parsed = EvidenceFieldSchema.safeParse(root); + return parsed.success ? parsed.data : undefined; +} + +const RiskDimensionSchema = z + .object({ + severity: RiskLevelSchema.describe( + "Current severity of the strongest supported finding in this risk dimension.", + ), + summary: z + .string() + .min(1) + .describe("A concise synthesis of the current evidence for this dimension."), + findings: z + .array(z.string().min(1)) + .describe("Specific evidence-backed findings. Return an empty list when none are found."), + }) + .strict(); + +const AdverseEventSchema = z + .object({ + category: z.string().min(1).describe("Short category such as data breach or lawsuit."), + severity: RiskLevelSchema, + title: z.string().min(1).describe("Short factual title for the event."), + summary: z.string().min(1).describe("Why the event matters to a vendor relationship."), + event_date: z + .string() + .regex(/^\d{4}(?:-\d{2})?(?:-\d{2})?$/) + .optional() + .describe("Known event date in YYYY, YYYY-MM, or YYYY-MM-DD form."), + }) + .strict(); + +const dimensionShape = Object.fromEntries( + RISK_DIMENSIONS.map((dimension) => [ + dimension.key, + RiskDimensionSchema.describe(dimension.description), + ]), +) as Record; + +export const VendorReportSchema = z + .object({ + ...dimensionShape, + adverse_events: z + .array(AdverseEventSchema) + .describe( + "Discrete, material adverse events supported by current public evidence. Do not infer an event merely from several medium-risk dimensions.", + ), + }) + .strict(); + +export type VendorReport = z.infer; + +export const ChangeInvestigationSchema = z + .object({ + what_changed: z.string().min(1), + confirmed_facts: z.array(z.string().min(1)), + business_impact: z.string().min(1), + open_questions: z.array(z.string().min(1)), + }) + .strict(); +export type ChangeInvestigation = z.infer; + +export function normalizeVendorDomain(value: string): string { + const candidate = value.trim(); + if (!candidate) { + throw new Error("Vendor domain cannot be empty."); + } + + let url: URL; + try { + url = new URL(candidate.includes("://") ? candidate : `https://${candidate}`); + } catch { + throw new Error(`Invalid vendor domain or URL: ${value}`); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`Vendor URL must use http or https: ${value}`); + } + if (url.username || url.password) { + throw new Error(`Vendor URL must not contain credentials: ${value}`); + } + + const hostname = url.hostname.toLowerCase().replace(/\.$/, ""); + const labels = hostname.split("."); + const validLabels = labels.every( + (label) => + label.length >= 1 && + label.length <= 63 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label), + ); + if ( + !hostname || + hostname.length > 253 || + labels.length < 2 || + !validLabels || + isIP(hostname) !== 0 + ) { + throw new Error(`Vendor domain must be a valid DNS hostname: ${value}`); + } + + return hostname; +} + +export const VendorSchema = z + .object({ + name: z.string().trim().min(1), + domain: z.string().transform(normalizeVendorDomain), + riskFloor: RiskLevelSchema.optional(), + }) + .strict(); + +export type Vendor = z.infer; + +const VendorTaskInputSchema = z + .object({ + objective: z.string().min(1), + vendor_name: z.string().min(1), + vendor_domain: z.string().min(1), + }) + .strict(); + +function asTaskJsonSchema(schema: z.ZodType): JsonSchema { + return { + type: "json", + json_schema: z.toJSONSchema(schema, { + target: "draft-7", + unrepresentable: "throw", + }), + }; +} + +export const VENDOR_TASK_INPUT_SCHEMA = asTaskJsonSchema(VendorTaskInputSchema); +export const VENDOR_REPORT_OUTPUT_SCHEMA = asTaskJsonSchema(VendorReportSchema); +export const CHANGE_INVESTIGATION_OUTPUT_SCHEMA = asTaskJsonSchema( + ChangeInvestigationSchema, +); + +/** + * Build the stable Task contract that a snapshot Monitor will re-run. + * Volatile values such as the current date intentionally do not appear here. + */ +export function buildBaselineTaskParams( + vendorInput: Vendor, + processor: string, +): TaskRunCreateParams { + const vendor = VendorSchema.parse(vendorInput); + + return { + input: { + objective: + "Research the vendor's current risk posture using public web evidence. Assess every requested dimension, distinguish discrete adverse events from general risk signals, and do not invent findings.", + vendor_name: vendor.name, + vendor_domain: vendor.domain, + }, + processor, + task_spec: { + input_schema: VENDOR_TASK_INPUT_SCHEMA, + output_schema: VENDOR_REPORT_OUTPUT_SCHEMA, + }, + metadata: { + recipe: RECIPE_METADATA, + vendor: vendor.domain, + spec: SPEC_VERSION, + }, + }; +} + +/** Build a focused follow-up request from durable, explicit snapshot context. */ +export function buildChangeInvestigationTaskParams(input: { + vendor: Vendor; + eventId: string; + changedFields: readonly EvidenceField[]; + previousReport: VendorReport; + currentReport: VendorReport; + previousInteractionId?: string; + policyDecision: { + threshold: RiskLevel; + previousLevel: RiskLevel; + currentLevel: RiskLevel; + requiresHumanReview: boolean; + reasons: readonly unknown[]; + }; + processor: string; +}): TaskRunCreateParams { + const vendor = VendorSchema.parse(input.vendor); + const changedFields = z.array(EvidenceFieldSchema).parse(input.changedFields); + const previousValues = Object.fromEntries( + changedFields.map((field) => [field, input.previousReport[field]]), + ); + const currentValues = Object.fromEntries( + changedFields.map((field) => [field, input.currentReport[field]]), + ); + + return { + input: { + objective: + "Investigate the detected vendor-intelligence change. Confirm what changed, explain its business impact, and identify unresolved questions. Do not choose a vendor action; deterministic policy owns that decision.", + vendor_name: vendor.name, + vendor_domain: vendor.domain, + monitor_event_id: input.eventId, + changed_fields: changedFields, + previous_values: previousValues, + current_values: currentValues, + policy_decision: input.policyDecision, + }, + processor: input.processor, + ...(input.previousInteractionId + ? { previous_interaction_id: input.previousInteractionId } + : {}), + task_spec: { + output_schema: CHANGE_INVESTIGATION_OUTPUT_SCHEMA, + }, + metadata: { + recipe: RECIPE_METADATA, + vendor: vendor.domain, + event: input.eventId, + }, + }; +} + +export function buildSnapshotMonitorParams(input: { + vendor: Vendor; + baselineRunId: string; + frequency: string; + processor: "lite" | "base"; +}): MonitorCreateParams { + const vendor = VendorSchema.parse(input.vendor); + return { + type: "snapshot", + frequency: input.frequency, + processor: input.processor, + settings: { task_run_id: input.baselineRunId }, + metadata: { + recipe: RECIPE_METADATA, + vendor: vendor.domain, + spec: String(SPEC_VERSION), + }, + }; +} diff --git a/typescript-recipes/parallel-vendor-intelligence/src/snapshot-events.ts b/typescript-recipes/parallel-vendor-intelligence/src/snapshot-events.ts new file mode 100644 index 0000000..dddc3f0 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/src/snapshot-events.ts @@ -0,0 +1,141 @@ +import { z } from "zod"; + +import type { FieldBasis } from "./parallel-port.js"; +import { + EvidenceFieldSchema, + VendorReportSchema, + evidenceFieldForPath, + type EvidenceField, + type VendorReport, +} from "./schema.js"; +import { + FieldBasisSchema, + RawSnapshotEventSchema, + type RawSnapshotEvent, +} from "./state.js"; + +export interface SnapshotEventInput { + event_id: string; + event_group_id: string; + event_date: string | null; + previous_output: { type: unknown; content: unknown; basis: unknown }; + changed_output: { type: unknown; content: unknown; basis: unknown }; +} + +export class InvalidSnapshotEventError extends Error { + constructor( + readonly eventId: string, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "InvalidSnapshotEventError"; + } +} + +export interface EvidenceSnapshot { + report: VendorReport; + basis: FieldBasis[]; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function parseBasis(value: unknown): FieldBasis[] { + return FieldBasisSchema.array().parse(value ?? []); +} + +export function rawSnapshotEvent(event: SnapshotEventInput): RawSnapshotEvent { + return RawSnapshotEventSchema.parse({ + eventId: event.event_id, + eventGroupId: event.event_group_id, + eventDate: event.event_date, + previousOutput: event.previous_output, + changedOutput: event.changed_output, + }); +} + +export function restoredSnapshotEvent(event: RawSnapshotEvent): SnapshotEventInput { + return { + event_id: event.eventId, + event_group_id: event.eventGroupId, + event_date: event.eventDate, + previous_output: event.previousOutput, + changed_output: event.changedOutput, + }; +} + +/** Apply a partial snapshot to a complete predecessor and replace changed evidence atomically. */ +export function reconstructSnapshotEvent( + event: SnapshotEventInput, + fallback?: EvidenceSnapshot, +): { + previousReport: VendorReport; + currentReport: VendorReport; + previousBasis: FieldBasis[]; + currentBasis: FieldBasis[]; + changedFields: EvidenceField[]; +} { + try { + if (event.previous_output.type !== "json" || event.changed_output.type !== "json") { + throw new Error("Snapshot outputs must both be JSON."); + } + + const previousContent = z + .record(z.string(), z.unknown()) + .parse(event.previous_output.content); + const changedContent = z + .record(z.string(), z.unknown()) + .parse(event.changed_output.content); + const previousIsEmpty = Object.keys(previousContent).length === 0; + if (previousIsEmpty && !fallback) { + throw new Error( + "Snapshot previous_output is empty and no complete predecessor is available.", + ); + } + const previousReport = previousIsEmpty + ? VendorReportSchema.parse(fallback?.report) + : VendorReportSchema.parse(previousContent); + const changedKeys = Object.keys(changedContent); + const unknownFields = changedKeys.filter( + (field) => !EvidenceFieldSchema.safeParse(field).success, + ); + if (unknownFields.length > 0) { + throw new Error( + `Snapshot changed_output contains unknown top-level fields: ${unknownFields.join(", ")}.`, + ); + } + const changedFields = z.array(EvidenceFieldSchema).parse(changedKeys); + const currentReport = VendorReportSchema.parse({ + ...previousReport, + ...changedContent, + }); + + const previousBasis = previousIsEmpty + ? FieldBasisSchema.array().parse(fallback?.basis) + : parseBasis(event.previous_output.basis); + const basis = new Map(); + for (const entry of previousBasis) basis.set(entry.field, entry); + for (const field of changedFields) { + for (const existingField of [...basis.keys()]) { + if (evidenceFieldForPath(existingField) === field) basis.delete(existingField); + } + } + for (const entry of parseBasis(event.changed_output.basis)) basis.set(entry.field, entry); + + return { + previousReport, + currentReport, + previousBasis, + currentBasis: [...basis.values()], + changedFields, + }; + } catch (error) { + throw new InvalidSnapshotEventError( + event.event_id, + `Snapshot event ${event.event_id} is invalid: ${errorMessage(error)}`, + { cause: error }, + ); + } +} diff --git a/typescript-recipes/parallel-vendor-intelligence/src/state.ts b/typescript-recipes/parallel-vendor-intelligence/src/state.ts new file mode 100644 index 0000000..b9362e6 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/src/state.ts @@ -0,0 +1,388 @@ +import { randomUUID } from "node:crypto"; +import { + mkdir, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { z } from "zod"; + +import type { FieldBasis } from "./parallel-port.js"; +import { withCommandLock } from "./command-lock.js"; +import { FollowUpDecisionSchema } from "./risk-policy.js"; +import { + ChangeInvestigationSchema, + RiskLevelSchema, + SPEC_VERSION, + VendorReportSchema, + VendorSchema, +} from "./schema.js"; + +export const STATE_VERSION = 1; + +const CitationSchema = z.object({ + url: z.string(), + excerpts: z.array(z.string()).nullable().optional(), + title: z.string().nullable().optional(), +}); + +export const FieldBasisSchema: z.ZodType = z.object({ + field: z.string(), + reasoning: z.string(), + citations: z.array(CitationSchema).optional(), + confidence: z.string().nullable().optional(), +}); + +const TaskRefSchema = z.object({ + runId: z.string().min(1), + interactionId: z.string().min(1).optional(), + startedAt: z.string().min(1).optional(), +}); + +const TaskFailureSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("remote_terminal"), + run: TaskRefSchema, + status: z.enum(["action_required", "failed", "cancelled"]), + message: z.string().min(1), + refId: z.string().min(1).optional(), + failedAt: z.string().min(1), + }), + z.object({ + kind: z.literal("invalid_output"), + run: TaskRefSchema, + status: z.literal("completed"), + message: z.string().min(1), + failedAt: z.string().min(1), + }), +]); + +const FailedAttemptsSchema = z.array(TaskFailureSchema); +const NonEmptyFailedAttemptsSchema = FailedAttemptsSchema.min(1); + +const BaselineStateSchema = z.discriminatedUnion("stage", [ + z.object({ + stage: z.literal("not_started"), + failedAttempts: FailedAttemptsSchema, + }), + z.object({ + stage: z.literal("running"), + run: TaskRefSchema, + failedAttempts: FailedAttemptsSchema, + }), + z.object({ + stage: z.literal("failed"), + failedAttempts: NonEmptyFailedAttemptsSchema, + }), + z.object({ + stage: z.literal("completed"), + run: TaskRefSchema, + failedAttempts: FailedAttemptsSchema, + evidence: z.object({ + report: VendorReportSchema, + basis: z.array(FieldBasisSchema), + observedAt: z.string().min(1), + warnings: z.array(z.string()), + }), + }), +]); + +const MonitorStateSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("active"), + monitorId: z.string().min(1), + baselineRunId: z.string().min(1), + frequency: z.string().min(1), + processor: z.enum(["lite", "base"]), + createdAt: z.string().min(1), + newestObservedEventId: z.string().min(1).optional(), + lastCheckedAt: z.string().min(1).optional(), + reportedExecutionErrors: z.array(z.string()).optional(), + }), + z.object({ + status: z.literal("cancelled"), + monitorId: z.string().min(1), + baselineRunId: z.string().min(1), + frequency: z.string().min(1), + processor: z.enum(["lite", "base"]), + createdAt: z.string().min(1), + cancelledAt: z.string().min(1), + }), +]); + +const HistoricalDecisionFields = { + // Historical decisions remain readable when a future policy version is introduced. + policyVersion: z.number().int().positive(), + evaluatedAt: z.string().min(1), + riskFloor: RiskLevelSchema.optional(), +}; + +export const NoFollowUpDecisionSchema = FollowUpDecisionSchema.extend({ + runFollowUp: z.literal(false), + ...HistoricalDecisionFields, +}); + +export const FollowUpRequiredDecisionSchema = FollowUpDecisionSchema.extend({ + runFollowUp: z.literal(true), + ...HistoricalDecisionFields, +}); + +const EventEvidenceFields = { + eventId: z.string().min(1), + monitorId: z.string().min(1), + eventDate: z.string().nullable(), + eventGroupId: z.string().min(1), + firstSeenAt: z.string().min(1), + previousReport: VendorReportSchema, + previousBasis: z.array(FieldBasisSchema), + currentReport: VendorReportSchema, + currentBasis: z.array(FieldBasisSchema), +}; + +const CompletedWithoutFollowUpSchema = z.object({ + stage: z.literal("completed_without_follow_up"), + ...EventEvidenceFields, + decision: NoFollowUpDecisionSchema, + completedAt: z.string().min(1), +}); + +const FollowUpQueuedSchema = z.object({ + stage: z.literal("follow_up_queued"), + ...EventEvidenceFields, + decision: FollowUpRequiredDecisionSchema, + failedAttempts: FailedAttemptsSchema, +}); + +const FollowUpRunningSchema = z.object({ + stage: z.literal("follow_up_running"), + ...EventEvidenceFields, + decision: FollowUpRequiredDecisionSchema, + run: TaskRefSchema, + failedAttempts: FailedAttemptsSchema, +}); + +const FollowUpFailedSchema = z.object({ + stage: z.literal("follow_up_failed"), + ...EventEvidenceFields, + decision: FollowUpRequiredDecisionSchema, + failedAttempts: NonEmptyFailedAttemptsSchema, +}); + +const FollowUpCompletedSchema = z.object({ + stage: z.literal("follow_up_completed"), + ...EventEvidenceFields, + decision: FollowUpRequiredDecisionSchema, + run: TaskRefSchema, + failedAttempts: FailedAttemptsSchema, + investigation: ChangeInvestigationSchema, + basis: z.array(FieldBasisSchema), + warnings: z.array(z.string()), + completedAt: z.string().min(1), +}); + +// Failed API payloads are evidence for diagnosis and retry, not trusted domain data. +// Keep the envelope permissive here; reconstruction is the validation boundary. +const RawTaskOutputSchema = z + .object({ + type: z.unknown(), + content: z.unknown(), + basis: z.unknown(), + }) + .passthrough(); + +export const RawSnapshotEventSchema = z.object({ + eventId: z.string().min(1), + eventGroupId: z.string().min(1), + eventDate: z.string().nullable(), + previousOutput: RawTaskOutputSchema, + changedOutput: RawTaskOutputSchema, +}); + +const EventFailedSchema = z.object({ + stage: z.literal("event_failed"), + eventId: z.string().min(1), + monitorId: z.string().min(1), + eventDate: z.string().nullable(), + eventGroupId: z.string().min(1), + firstSeenAt: z.string().min(1), + rawEvent: RawSnapshotEventSchema, + priorSnapshot: z + .object({ + report: VendorReportSchema, + basis: z.array(FieldBasisSchema), + }) + .optional(), + failure: z.object({ + kind: z.literal("invalid_event"), + message: z.string().min(1), + failedAt: z.string().min(1), + attempts: z.number().int().positive(), + }), +}); + +export const EventLedgerEntrySchema = z.discriminatedUnion("stage", [ + CompletedWithoutFollowUpSchema, + FollowUpQueuedSchema, + FollowUpRunningSchema, + FollowUpFailedSchema, + FollowUpCompletedSchema, + EventFailedSchema, +]); + +const VendorStateSchema = z.object({ + vendor: VendorSchema, + baseline: BaselineStateSchema, + monitor: MonitorStateSchema.optional(), + events: z.record(z.string(), EventLedgerEntrySchema), + latestEventId: z.string().min(1).optional(), +}); + +export const RecipeStateSchema = z + .object({ + stateVersion: z.literal(STATE_VERSION), + specVersion: z.number().int().positive(), + vendors: z.record(z.string(), VendorStateSchema), + }) + .superRefine((state, context) => { + for (const [domain, vendorState] of Object.entries(state.vendors)) { + if (domain !== vendorState.vendor.domain) { + context.addIssue({ + code: "custom", + path: ["vendors", domain, "vendor", "domain"], + message: `Vendor state key ${domain} does not match normalized domain ${vendorState.vendor.domain}.`, + }); + } + if (vendorState.monitor?.status === "active") { + if (vendorState.baseline.stage !== "completed") { + context.addIssue({ + code: "custom", + path: ["vendors", domain, "monitor"], + message: "An active Monitor requires a completed baseline Task.", + }); + } else if (vendorState.monitor.baselineRunId !== vendorState.baseline.run.runId) { + context.addIssue({ + code: "custom", + path: ["vendors", domain, "monitor", "baselineRunId"], + message: "Active Monitor baseline does not match the completed baseline Task.", + }); + } + } + for (const [eventId, event] of Object.entries(vendorState.events)) { + if (eventId !== event.eventId) { + context.addIssue({ + code: "custom", + path: ["vendors", domain, "events", eventId, "eventId"], + message: "Event map key does not match eventId.", + }); + } + if ( + event.stage === "event_failed" && + (event.rawEvent.eventId !== event.eventId || + event.rawEvent.eventGroupId !== event.eventGroupId || + event.rawEvent.eventDate !== event.eventDate) + ) { + context.addIssue({ + code: "custom", + path: ["vendors", domain, "events", eventId, "rawEvent"], + message: "Failed event identity must match its retained raw payload.", + }); + } + } + if (vendorState.latestEventId) { + const latest = vendorState.events[vendorState.latestEventId]; + if (!latest || latest.stage === "event_failed") { + context.addIssue({ + code: "custom", + path: ["vendors", domain, "latestEventId"], + message: "latestEventId must reference a successfully reconstructed event.", + }); + } + } + } + }); + +export type RecipeState = z.infer; +export type VendorState = z.infer; +type EventLedgerEntry = z.infer; +export type EventEvidence = Exclude; +export type TaskFailure = z.infer; +export type TaskRef = z.infer; +export type RawSnapshotEvent = z.infer; + +export function emptyRecipeState(): RecipeState { + return { stateVersion: STATE_VERSION, specVersion: SPEC_VERSION, vendors: {} }; +} + +export class FileStateStore { + readonly statePath: string; + readonly lockPath: string; + private readonly rootDirectory: string; + + constructor(rootDirectory: string) { + this.rootDirectory = rootDirectory; + this.statePath = join(rootDirectory, "state.json"); + this.lockPath = join(rootDirectory, "command.lock"); + } + + async read(): Promise { + return this.load(); + } + + async update(mutator: (state: RecipeState) => void): Promise { + const next = structuredClone(await this.load()); + mutator(next); + const validated = RecipeStateSchema.parse(next); + await this.write(validated); + } + + async withCommandLock(command: string, action: () => Promise): Promise { + return withCommandLock({ + rootDirectory: this.rootDirectory, + lockPath: this.lockPath, + command, + action, + }); + } + + private async load(): Promise { + let raw: string; + try { + raw = await readFile(this.statePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return emptyRecipeState(); + } + throw error; + } + + try { + const parsed = JSON.parse(raw) as { stateVersion?: unknown }; + if (parsed.stateVersion !== STATE_VERSION) { + throw new Error(`Unsupported state version ${String(parsed.stateVersion)}.`); + } + return RecipeStateSchema.parse(parsed); + } catch (error) { + throw new Error( + `Cannot read vendor intelligence state at ${this.statePath}. Back up and repair the file before running another command.`, + { cause: error }, + ); + } + } + + private async write(state: RecipeState): Promise { + const directory = dirname(this.statePath); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const temporaryPath = `${this.statePath}.tmp-${process.pid}-${randomUUID()}`; + + try { + await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); + await rename(temporaryPath, this.statePath); + } catch (error) { + await rm(temporaryPath, { force: true }); + throw error; + } + } +} diff --git a/typescript-recipes/parallel-vendor-intelligence/src/task-runner.ts b/typescript-recipes/parallel-vendor-intelligence/src/task-runner.ts new file mode 100644 index 0000000..28a188b --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/src/task-runner.ts @@ -0,0 +1,127 @@ +import type { + ParallelPort, + TaskRun, + TaskRunResult, +} from "./parallel-port.js"; +import type { TaskFailure, TaskRef } from "./state.js"; + +type TerminalStatus = "action_required" | "failed" | "cancelled"; + +class TerminalTaskError extends Error { + constructor( + readonly run: TaskRun, + message: string, + ) { + super(message); + this.name = "TerminalTaskError"; + } +} + +export class InvalidTaskOutputError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "InvalidTaskOutputError"; + } +} + +function errorStatus(error: unknown): number | undefined { + if (typeof error !== "object" || error === null || !("status" in error)) return undefined; + const status = (error as { status?: unknown }).status; + return typeof status === "number" ? status : undefined; +} + +function isTerminalStatus(status: TaskRun["status"]): status is TerminalStatus { + return status === "action_required" || status === "failed" || status === "cancelled"; +} + +/** Own Task polling and turn only confirmed terminal outcomes into durable failures. */ +export class TaskRunner { + constructor( + private readonly options: { + client: ParallelPort; + pollSeconds: number; + maxWaitMilliseconds: number; + retryDelayMilliseconds: number; + now: () => Date; + sleep: (milliseconds: number) => Promise; + clock?: () => number; + }, + ) {} + + async wait(runId: string): Promise { + const clock = this.options.clock ?? Date.now; + const deadline = clock() + this.options.maxWaitMilliseconds; + let lastError: unknown; + + while (clock() < deadline) { + try { + const result = await this.options.client.taskRun.result( + runId, + { timeout: this.options.pollSeconds }, + { maxRetries: 0 }, + ); + const terminal = this.terminalError(result.run); + if (terminal) throw terminal; + if (result.run.status === "completed") return result; + } catch (error) { + lastError = error; + if (error instanceof TerminalTaskError) throw error; + const terminal = await this.retrieveTerminal(runId); + if (terminal) throw terminal; + if (errorStatus(error) !== 408) throw error; + } + if (clock() < deadline) await this.options.sleep(this.options.retryDelayMilliseconds); + } + + const terminal = await this.retrieveTerminal(runId); + if (terminal) throw terminal; + throw new Error( + `Task ${runId} did not complete within ${this.options.maxWaitMilliseconds}ms.`, + { cause: lastError }, + ); + } + + failure(error: unknown, run: TaskRef): TaskFailure | undefined { + const failedAt = this.options.now().toISOString(); + if (error instanceof TerminalTaskError) { + return { + kind: "remote_terminal", + run: { + ...run, + interactionId: error.run.interaction_id || run.interactionId, + }, + status: error.run.status as TerminalStatus, + message: error.message, + ...(error.run.error?.ref_id ? { refId: error.run.error.ref_id } : {}), + failedAt, + }; + } + if (error instanceof InvalidTaskOutputError) { + return { + kind: "invalid_output", + run, + status: "completed", + message: error.message, + failedAt, + }; + } + return undefined; + } + + private async retrieveTerminal(runId: string): Promise { + try { + const run = await this.options.client.taskRun.retrieve(runId); + return this.terminalError(run); + } catch (error) { + return error instanceof TerminalTaskError ? error : undefined; + } + } + + private terminalError(run: TaskRun): TerminalTaskError | undefined { + if (!isTerminalStatus(run.status)) return undefined; + return new TerminalTaskError( + run, + run.error?.message ?? `Task ${run.run_id} reached terminal status ${run.status}.`, + ); + } +} diff --git a/typescript-recipes/parallel-vendor-intelligence/src/vendor-config.ts b/typescript-recipes/parallel-vendor-intelligence/src/vendor-config.ts new file mode 100644 index 0000000..29b4fdc --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/src/vendor-config.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +import { RiskLevelSchema, type RiskLevel } from "./schema.js"; + +export interface VendorIntelligenceConfig { + monitorFrequency: string; + followUpRiskThreshold: RiskLevel; + baselineProcessor: string; + monitorProcessor: "lite" | "base"; + followUpProcessor: string; + taskResultPollSeconds: number; + taskResultMaxWaitMilliseconds: number; + taskResultRetryDelayMilliseconds: number; +} + +export const MonitorFrequencySchema = z.string().refine((value) => { + const match = /^(\d+)([hdw])$/.exec(value); + if (!match) return false; + const amount = Number(match[1]); + const hours = amount * (match[2] === "h" ? 1 : match[2] === "d" ? 24 : 24 * 7); + return hours >= 1 && hours <= 30 * 24; +}, "Monitor frequency must look like 12h, 1d, or 2w and be between 1h and 30d."); + +export const VendorIntelligenceConfigSchema: z.ZodType = z + .object({ + monitorFrequency: MonitorFrequencySchema, + followUpRiskThreshold: RiskLevelSchema, + baselineProcessor: z.string().min(1), + monitorProcessor: z.enum(["lite", "base"]), + followUpProcessor: z.string().min(1), + taskResultPollSeconds: z.number().int().positive(), + taskResultMaxWaitMilliseconds: z.number().int().positive(), + taskResultRetryDelayMilliseconds: z.number().int().nonnegative(), + }) + .strict(); + +export const DEFAULT_CONFIG: VendorIntelligenceConfig = { + monitorFrequency: "1d", + followUpRiskThreshold: "HIGH", + baselineProcessor: "core", + monitorProcessor: "lite", + followUpProcessor: "pro", + taskResultPollSeconds: 25, + taskResultMaxWaitMilliseconds: 15 * 60 * 1_000, + taskResultRetryDelayMilliseconds: 250, +}; diff --git a/typescript-recipes/parallel-vendor-intelligence/src/vendor-intelligence.ts b/typescript-recipes/parallel-vendor-intelligence/src/vendor-intelligence.ts new file mode 100644 index 0000000..9bdf8b5 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/src/vendor-intelligence.ts @@ -0,0 +1,1500 @@ +import type { + FieldBasis, + Monitor, + MonitorErrorEvent, + MonitorSnapshotEvent, + ParallelPort, + SnapshotMonitor, + TaskRunResult, +} from "./parallel-port.js"; +import { + decideFollowUp, + POLICY_VERSION, + scoreReport, + type RiskAssessment, +} from "./risk-policy.js"; +import { + buildBaselineTaskParams, + buildChangeInvestigationTaskParams, + buildSnapshotMonitorParams, + ChangeInvestigationSchema, + normalizeVendorDomain, + RECIPE_METADATA, + SPEC_VERSION, + VendorReportSchema, + VendorSchema, + type ChangeInvestigation, + type EvidenceField, + type Vendor, + type VendorReport, +} from "./schema.js"; +import { + EventLedgerEntrySchema, + FieldBasisSchema, + FollowUpRequiredDecisionSchema, + NoFollowUpDecisionSchema, + type EventEvidence, + FileStateStore, + type RecipeState, + type TaskFailure, + type TaskRef, + type VendorState, +} from "./state.js"; +import { + InvalidSnapshotEventError, + rawSnapshotEvent, + reconstructSnapshotEvent, + restoredSnapshotEvent, + type EvidenceSnapshot, + type SnapshotEventInput, +} from "./snapshot-events.js"; +import { InvalidTaskOutputError, TaskRunner } from "./task-runner.js"; +import { + VendorIntelligenceConfigSchema, + type VendorIntelligenceConfig, +} from "./vendor-config.js"; + +export interface Diagnostic { + code: string; + message: string; + vendor?: string; + resourceId?: string; +} + +export interface AssessmentView { + source: + | { kind: "baseline"; runId: string } + | { + kind: "monitor_event"; + monitorId: string; + eventId: string; + eventDate: string | null; + }; + observedAt: string; + report: VendorReport; + basis: FieldBasis[]; + risk: RiskAssessment & { policyVersion: number }; +} + +export interface BootstrapSummary { + vendors: number; + baselinesCreated: number; + baselinesResumed: number; + baselinesReused: number; + monitorsCreated: number; + monitorsAdopted: number; + monitorsReused: number; + results: Array<{ + vendor: Vendor; + baseline: { + action: "created" | "resumed" | "reused"; + runId: string; + interactionId?: string; + warnings: string[]; + }; + monitor: { + action: "created" | "adopted" | "reused"; + monitorId: string; + frequency: string; + processor: "lite" | "base"; + }; + assessment: AssessmentView; + }>; + omittedActiveVendors: Array<{ vendor: string; monitorId: string }>; + warnings: Diagnostic[]; +} + +export type FollowUpView = + | { status: "not_required" } + | { status: "pending"; runId?: string } + | { + status: "completed"; + runId: string; + investigation: ChangeInvestigation; + basis: FieldBasis[]; + warnings: string[]; + } + | { + status: "failed"; + runId: string; + message: string; + failedAt: string; + }; + +export interface ChangeView { + vendor: Vendor; + event: { + monitorId: string; + eventId: string; + eventDate: string | null; + changedFields: EvidenceField[]; + }; + assessment: AssessmentView; + decision: EventEvidence["decision"]; + followUp: FollowUpView; +} + +export interface CheckSummary { + monitorsChecked: number; + newEvents: number; + followUpDecisions: number; + followUpTasksCreated: number; + followUpsCompleted: number; + humanReviewsRequired: number; + changes: ChangeView[]; + warnings: Diagnostic[]; + errors: Diagnostic[]; +} + +export interface CleanupSummary { + scope: { kind: "all" } | { kind: "vendors"; vendors: string[] }; + monitors: Array< + | { vendor: string; monitorId: string; status: "cancelled" | "already_cancelled" } + | { vendor: string; monitorId: string; status: "failed"; message: string } + >; + warnings: Diagnostic[]; +} + +type Reporter = (message: string) => void; +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "object" && error !== null && "message" in error) { + return String((error as { message: unknown }).message); + } + return String(error); +} + +function assertCurrentSpec(state: RecipeState): void { + if (state.specVersion !== SPEC_VERSION) { + throw new Error( + `State uses vendor report spec ${state.specVersion}, but this recipe uses ${SPEC_VERSION}. Run cleanup with the old state before resetting it.`, + ); + } +} + +function parseTaskBasis(value: unknown, taskLabel: string): FieldBasis[] { + try { + return FieldBasisSchema.array().parse(value ?? []); + } catch (error) { + throw new InvalidTaskOutputError( + `${taskLabel} returned an invalid evidence basis: ${errorMessage(error)}`, + { cause: error }, + ); + } +} + +function isSnapshotEvent(value: unknown): value is MonitorSnapshotEvent { + return ( + typeof value === "object" && + value !== null && + "event_id" in value && + "changed_output" in value && + "previous_output" in value + ); +} + +function isMonitorError(value: unknown): value is MonitorErrorEvent { + return ( + typeof value === "object" && + value !== null && + "error_message" in value && + "timestamp" in value + ); +} + +function isSnapshotMonitorForBaseline( + monitor: Monitor, + vendor: Vendor, + baselineRunId: string, + frequency: string, + processor: "lite" | "base", +): monitor is SnapshotMonitor { + return ( + monitor.type === "snapshot" && + monitor.status === "active" && + monitor.settings.task_run_id === baselineRunId && + monitor.frequency === frequency && + monitor.processor === processor && + monitor.metadata?.recipe === RECIPE_METADATA && + monitor.metadata?.vendor === vendor.domain && + monitor.metadata?.spec === String(SPEC_VERSION) + ); +} + +function taskWarnings(result: TaskRunResult): string[] { + return (result.run.warnings ?? []).map((warning) => warning.message); +} + +function lastFailure(entry: { failedAttempts: TaskFailure[] }): TaskFailure { + const failure = entry.failedAttempts.at(-1); + if (!failure) throw new Error("Failed state is missing its Task failure."); + return failure; +} + +export class VendorIntelligence { + private readonly client: ParallelPort; + private readonly store: FileStateStore; + private readonly config: VendorIntelligenceConfig; + private readonly now: () => Date; + private readonly sleep: (milliseconds: number) => Promise; + private readonly report: Reporter; + private readonly tasks: TaskRunner; + + constructor(options: { + client: ParallelPort; + store: FileStateStore; + config: VendorIntelligenceConfig; + now?: () => Date; + sleep?: (milliseconds: number) => Promise; + reporter?: Reporter; + }) { + this.client = options.client; + this.store = options.store; + this.config = VendorIntelligenceConfigSchema.parse(options.config); + this.now = options.now ?? (() => new Date()); + this.sleep = + options.sleep ?? + ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + this.report = options.reporter ?? ((message) => console.error(message)); + this.tasks = new TaskRunner({ + client: this.client, + pollSeconds: this.config.taskResultPollSeconds, + maxWaitMilliseconds: this.config.taskResultMaxWaitMilliseconds, + retryDelayMilliseconds: this.config.taskResultRetryDelayMilliseconds, + now: this.now, + sleep: this.sleep, + }); + } + + async bootstrap( + vendorInputs: Vendor[], + options: { retryFailed?: boolean } = {}, + ): Promise { + return this.store.withCommandLock("bootstrap", () => + this.bootstrapUnlocked(vendorInputs, options), + ); + } + + async checkForUpdates( + options: { retryFailed?: boolean } = {}, + ): Promise { + return this.store.withCommandLock("check-updates", () => + this.checkForUpdatesUnlocked(options), + ); + } + + async cleanup(options: { vendors?: string[] } = {}): Promise { + return this.store.withCommandLock("cleanup", () => this.cleanupUnlocked(options)); + } + + private async bootstrapUnlocked( + vendorInputs: Vendor[], + options: { retryFailed?: boolean }, + ): Promise { + const vendors = vendorInputs.map((vendor) => VendorSchema.parse(vendor)); + if (vendors.length === 0) throw new Error("Provide at least one vendor to bootstrap."); + const domains = new Set(); + for (const vendor of vendors) { + if (domains.has(vendor.domain)) { + throw new Error(`Vendor input contains duplicate normalized domain ${vendor.domain}.`); + } + domains.add(vendor.domain); + } + + const initial = await this.store.read(); + assertCurrentSpec(initial); + const summary: BootstrapSummary = { + vendors: vendors.length, + baselinesCreated: 0, + baselinesResumed: 0, + baselinesReused: 0, + monitorsCreated: 0, + monitorsAdopted: 0, + monitorsReused: 0, + results: [], + omittedActiveVendors: [], + warnings: [], + }; + + for (const [domain, saved] of Object.entries(initial.vendors)) { + if (!domains.has(domain) && saved.monitor?.status === "active") { + summary.omittedActiveVendors.push({ + vendor: domain, + monitorId: saved.monitor.monitorId, + }); + summary.warnings.push({ + code: "omitted_active_vendor", + vendor: domain, + resourceId: saved.monitor.monitorId, + message: `${domain} remains monitored because bootstrap is additive. Run cleanup -- --vendor ${domain} to cancel its state-owned Monitor.`, + }); + } + } + + for (const vendor of vendors) { + await this.upsertVendor(vendor); + const baseline = await this.ensureBaseline(vendor, options.retryFailed === true, summary); + const monitor = await this.ensureMonitor(vendor, baseline.run.runId, summary); + const completed = (await this.store.read()).vendors[vendor.domain]!.baseline; + if (completed.stage !== "completed") { + throw new Error(`Baseline for ${vendor.domain} did not reach completed state.`); + } + summary.results.push({ + vendor, + baseline: { + action: baseline.action, + runId: completed.run.runId, + ...(completed.run.interactionId + ? { interactionId: completed.run.interactionId } + : {}), + warnings: completed.evidence.warnings, + }, + monitor: { + action: monitor.action, + monitorId: monitor.monitor.monitor_id, + frequency: monitor.monitor.frequency, + processor: monitor.monitor.processor, + }, + assessment: this.assessmentView( + vendor, + { kind: "baseline", runId: completed.run.runId }, + completed.evidence.report, + completed.evidence.basis, + completed.evidence.observedAt, + ), + }); + } + + return summary; + } + + private async upsertVendor(vendor: Vendor): Promise { + await this.store.update((state) => { + const existing = state.vendors[vendor.domain]; + if (!existing) { + state.vendors[vendor.domain] = { + vendor, + baseline: { stage: "not_started", failedAttempts: [] }, + events: {}, + }; + return; + } + if (existing.baseline.stage !== "not_started" && existing.vendor.name !== vendor.name) { + throw new Error( + `Vendor ${vendor.domain} was bootstrapped as ${existing.vendor.name}; changing its name would diverge from the saved Task input. Restore that name or clean up and reset state.`, + ); + } + existing.vendor = vendor; + }); + } + + private async ensureBaseline( + vendor: Vendor, + retryFailed: boolean, + summary: BootstrapSummary, + ): Promise<{ run: TaskRef; action: "created" | "resumed" | "reused" }> { + let baseline = (await this.store.read()).vendors[vendor.domain]!.baseline; + if (baseline.stage === "completed") { + summary.baselinesReused += 1; + return { run: baseline.run, action: "reused" }; + } + if (baseline.stage === "failed" && !retryFailed) { + const failure = lastFailure(baseline); + throw new Error( + `Baseline Task ${failure.run.runId} failed permanently: ${failure.message}. Re-run bootstrap with --retry-failed to create a replacement Task.`, + ); + } + + let run: TaskRef; + let action: "created" | "resumed"; + const failedAttempts = baseline.failedAttempts; + if (baseline.stage === "running") { + run = baseline.run; + action = "resumed"; + summary.baselinesResumed += 1; + this.report(`Resuming baseline Task ${run.runId} for ${vendor.domain}...`); + } else { + const created = await this.client.taskRun.create( + buildBaselineTaskParams(vendor, this.config.baselineProcessor), + { maxRetries: 0 }, + ); + run = { + runId: created.run_id, + interactionId: created.interaction_id, + startedAt: this.now().toISOString(), + }; + await this.store.update((state) => { + state.vendors[vendor.domain]!.baseline = { + stage: "running", + run, + failedAttempts, + }; + }); + action = "created"; + summary.baselinesCreated += 1; + this.report(`Created baseline Task ${run.runId} for ${vendor.domain}; waiting...`); + } + + try { + const result = await this.tasks.wait(run.runId); + if (result.run.run_id !== run.runId || result.run.status !== "completed") { + throw new InvalidTaskOutputError( + `Baseline Task ${run.runId} returned an inconsistent completed result.`, + ); + } + if (result.output.type !== "json") { + throw new InvalidTaskOutputError( + `Baseline Task ${run.runId} returned ${result.output.type}, expected JSON.`, + ); + } + let report: VendorReport; + try { + report = VendorReportSchema.parse(result.output.content); + } catch (error) { + throw new InvalidTaskOutputError( + `Baseline Task ${run.runId} returned invalid vendor intelligence: ${errorMessage(error)}`, + { cause: error }, + ); + } + const basis = parseTaskBasis(result.output.basis, `Baseline Task ${run.runId}`); + const warnings = taskWarnings(result); + const completedRun: TaskRef = { + ...run, + interactionId: result.run.interaction_id || run.interactionId, + }; + const observedAt = this.now().toISOString(); + await this.store.update((state) => { + state.vendors[vendor.domain]!.baseline = { + stage: "completed", + run: completedRun, + failedAttempts, + evidence: { report, basis, observedAt, warnings }, + }; + }); + for (const warning of warnings) this.report(`Baseline ${run.runId}: ${warning}`); + return { run: completedRun, action }; + } catch (error) { + const failure = this.tasks.failure(error, run); + if (failure) { + await this.store.update((state) => { + state.vendors[vendor.domain]!.baseline = { + stage: "failed", + failedAttempts: [...failedAttempts, failure], + }; + }); + throw new Error( + `Baseline Task ${run.runId} failed permanently: ${failure.message}. Re-run bootstrap with --retry-failed to create a replacement Task.`, + { cause: error }, + ); + } + throw error; + } + } + + private async ensureMonitor( + vendor: Vendor, + baselineRunId: string, + summary: BootstrapSummary, + ): Promise<{ + monitor: SnapshotMonitor; + action: "created" | "adopted" | "reused"; + }> { + let vendorState = (await this.store.read()).vendors[vendor.domain]!; + if (vendorState.monitor?.status === "active") { + const local = vendorState.monitor; + const remote = await this.client.monitor.retrieve(local.monitorId); + if (remote.status === "cancelled") { + await this.markMonitorCancelled(vendor.domain, local.monitorId); + } else if ( + isSnapshotMonitorForBaseline( + remote, + vendor, + baselineRunId, + this.config.monitorFrequency, + this.config.monitorProcessor, + ) + ) { + summary.monitorsReused += 1; + return { monitor: remote, action: "reused" }; + } else { + throw new Error( + `Stored Monitor ${local.monitorId} no longer matches the baseline, metadata, frequency, or processor. Run cleanup before changing Monitor configuration.`, + ); + } + } + + const adopted = await this.findAdoptableMonitor(vendor, baselineRunId); + if (adopted) { + await this.saveActiveMonitor(vendor, baselineRunId, adopted); + summary.monitorsAdopted += 1; + return { monitor: adopted, action: "adopted" }; + } + + let monitor: SnapshotMonitor; + try { + monitor = await this.client.monitor.create( + buildSnapshotMonitorParams({ + vendor, + baselineRunId, + frequency: this.config.monitorFrequency, + processor: this.config.monitorProcessor, + }), + { maxRetries: 0 }, + ); + } catch (createError) { + try { + const recovered = await this.findAdoptableMonitor(vendor, baselineRunId); + if (recovered) { + await this.saveActiveMonitor(vendor, baselineRunId, recovered); + summary.monitorsAdopted += 1; + this.report( + `Recovered snapshot Monitor ${recovered.monitor_id} after an ambiguous create response.`, + ); + return { monitor: recovered, action: "adopted" }; + } + } catch (recoveryError) { + throw new Error( + `Monitor creation for ${vendor.domain} failed and recovery could not list matching Monitors. Re-run bootstrap; it will adopt a matching Monitor before creating another. Recovery error: ${errorMessage(recoveryError)}`, + { cause: createError }, + ); + } + throw new Error( + `Monitor creation for ${vendor.domain} did not return a confirmed response. Re-run bootstrap; it will adopt any matching Monitor before creating another.`, + { cause: createError }, + ); + } + const createdMonitor: Monitor = monitor; + if ( + !isSnapshotMonitorForBaseline( + createdMonitor, + vendor, + baselineRunId, + this.config.monitorFrequency, + this.config.monitorProcessor, + ) + ) { + let cancellation = "Automatic cancellation failed; cancel it manually."; + try { + const cancelled = await this.client.monitor.cancel(monitor.monitor_id); + const confirmed = + cancelled.status === "cancelled" + ? cancelled + : await this.client.monitor.retrieve(monitor.monitor_id); + if (confirmed.status === "cancelled") { + cancellation = "The mismatched Monitor was cancelled automatically."; + } + } catch { + try { + const confirmed = await this.client.monitor.retrieve(monitor.monitor_id); + if (confirmed.status === "cancelled") { + cancellation = "The mismatched Monitor was already cancelled remotely."; + } + } catch { + // The error below retains the known Monitor ID for manual recovery. + } + } + throw new Error( + `Created Monitor ${monitor.monitor_id} did not match the requested baseline, metadata, frequency, or processor and was not saved. ${cancellation}`, + ); + } + await this.saveActiveMonitor(vendor, baselineRunId, monitor); + summary.monitorsCreated += 1; + this.report(`Created snapshot Monitor ${monitor.monitor_id} for ${vendor.domain}.`); + return { monitor, action: "created" }; + } + + private async checkForUpdatesUnlocked(options: { + retryFailed?: boolean; + }): Promise { + const initial = await this.store.read(); + assertCurrentSpec(initial); + const summary: CheckSummary = { + monitorsChecked: 0, + newEvents: 0, + followUpDecisions: 0, + followUpTasksCreated: 0, + followUpsCompleted: 0, + humanReviewsRequired: 0, + changes: [], + warnings: [], + errors: [], + }; + const blockedMonitors = new Set(); + + for (const [domain, vendorState] of Object.entries(initial.vendors)) { + for (const entry of Object.values(vendorState.events)) { + try { + if (entry.stage === "event_failed") { + if (options.retryFailed) { + await this.retryFailedEvent(domain, entry.eventId, summary); + } else { + this.addDiagnostic(summary.errors, { + code: "event_requires_retry", + vendor: domain, + resourceId: entry.eventId, + message: `Event ${entry.eventId} could not be validated: ${entry.failure.message} Re-run check-updates with --retry-failed after correcting the cause.`, + }); + } + } else if (entry.stage === "follow_up_failed") { + if (options.retryFailed) { + await this.queueFailedFollowUp(domain, entry.eventId); + await this.completeFollowUp(domain, entry.eventId, summary); + } else { + this.upsertChange(vendorState.vendor, entry, summary); + const failure = lastFailure(entry); + this.addDiagnostic(summary.errors, { + code: "follow_up_requires_retry", + vendor: domain, + resourceId: failure.run.runId, + message: `Follow-up Task ${failure.run.runId} failed permanently. Re-run check-updates with --retry-failed to create a replacement Task.`, + }); + } + } else if ( + entry.stage === "follow_up_queued" || + entry.stage === "follow_up_running" + ) { + await this.completeFollowUp(domain, entry.eventId, summary); + } + } catch (error) { + const message = `Could not resume event ${entry.eventId}: ${errorMessage(error)}`; + this.addDiagnostic(summary.errors, { + code: "event_resume_failed", + vendor: domain, + resourceId: entry.eventId, + message, + }); + const currentEntry = (await this.store.read()).vendors[domain]?.events[ + entry.eventId + ]; + if ( + (currentEntry?.stage === "follow_up_queued" || + currentEntry?.stage === "follow_up_running") && + vendorState.monitor?.status === "active" + ) { + blockedMonitors.add(vendorState.monitor.monitorId); + } + break; + } + } + } + + const refreshed = await this.store.read(); + for (const [domain, vendorState] of Object.entries(refreshed.vendors)) { + const monitor = vendorState.monitor; + if (!monitor || monitor.status !== "active" || blockedMonitors.has(monitor.monitorId)) { + continue; + } + summary.monitorsChecked += 1; + try { + await this.checkMonitor(domain, monitor, summary); + } catch (error) { + this.addDiagnostic(summary.errors, { + code: "monitor_check_failed", + vendor: domain, + resourceId: monitor.monitorId, + message: `Could not check Monitor ${monitor.monitorId}: ${errorMessage(error)}`, + }); + } + } + + summary.humanReviewsRequired = summary.changes.filter( + ({ assessment }) => assessment.risk.requiresHumanReview, + ).length; + return summary; + } + + private async checkMonitor( + domain: string, + monitor: Extract, { status: "active" }>, + summary: CheckSummary, + ): Promise { + const history = await this.fetchMonitorHistory(domain, monitor.monitorId, summary); + const stateBeforeHistory = await this.store.read(); + const vendorState = stateBeforeHistory.vendors[domain]; + if (!vendorState) throw new Error(`No local vendor state for ${domain}.`); + if (monitor.newestObservedEventId && !history.seen.has(monitor.newestObservedEventId)) { + this.addDiagnostic(summary.warnings, { + code: "monitor_history_gap", + vendor: domain, + resourceId: monitor.monitorId, + message: `${monitor.monitorId}: prior event ${monitor.newestObservedEventId} is outside retained Monitor history; processing every retained unseen event.`, + }); + } + + let newestDurableEventId: string | undefined; + let cursor = this.initialEvidenceCursor(vendorState, history.seen); + for (const event of [...history.events].reverse()) { + const currentState = await this.store.read(); + const existing = currentState.vendors[domain]?.events[event.event_id]; + if (existing) { + newestDurableEventId = event.event_id; + cursor = + existing.stage === "event_failed" + ? undefined + : { report: existing.currentReport, basis: existing.currentBasis }; + continue; + } + + try { + const recorded = await this.recordSnapshotEvent( + domain, + monitor.monitorId, + event, + summary, + true, + undefined, + cursor, + ); + newestDurableEventId = event.event_id; + cursor = { report: recorded.currentReport, basis: recorded.currentBasis }; + if (recorded.stage === "follow_up_queued") { + await this.completeFollowUp(domain, event.event_id, summary); + } + } catch (error) { + if (error instanceof InvalidSnapshotEventError) { + await this.recordFailedSnapshotEvent( + domain, + monitor.monitorId, + event, + error, + cursor, + ); + newestDurableEventId = event.event_id; + cursor = undefined; + this.addDiagnostic(summary.errors, { + code: "invalid_snapshot_event", + vendor: domain, + resourceId: event.event_id, + message: error.message, + }); + summary.newEvents += 1; + continue; + } + this.addDiagnostic(summary.errors, { + code: "event_processing_failed", + vendor: domain, + resourceId: event.event_id, + message: `Could not process event ${event.event_id}: ${errorMessage(error)}`, + }); + break; + } + } + + const durable = await this.store.read(); + const newestValid = history.events + .map((event) => durable.vendors[domain]?.events[event.event_id]) + .find( + (entry): entry is EventEvidence => entry !== undefined && entry.stage !== "event_failed", + ); + await this.store.update((state) => { + const record = state.vendors[domain]; + const currentMonitor = record?.monitor; + if (!record || !currentMonitor || currentMonitor.status !== "active") return; + currentMonitor.lastCheckedAt = this.now().toISOString(); + if (newestDurableEventId) currentMonitor.newestObservedEventId = newestDurableEventId; + if (history.newErrorFingerprints.length > 0) { + currentMonitor.reportedExecutionErrors = [ + ...history.newErrorFingerprints, + ...(currentMonitor.reportedExecutionErrors ?? []), + ].filter((value, index, values) => values.indexOf(value) === index).slice(0, 100); + } + if (newestValid) record.latestEventId = newestValid.eventId; + }); + } + + private initialEvidenceCursor( + vendorState: VendorState, + retainedEventIds: ReadonlySet, + ): EvidenceSnapshot | undefined { + if (vendorState.baseline.stage !== "completed") return undefined; + const checkpoint = + vendorState.monitor?.status === "active" + ? vendorState.monitor.newestObservedEventId + : undefined; + if (checkpoint && !retainedEventIds.has(checkpoint)) { + const latest = vendorState.latestEventId + ? vendorState.events[vendorState.latestEventId] + : undefined; + if (latest && latest.stage !== "event_failed") { + return { report: latest.currentReport, basis: latest.currentBasis }; + } + return undefined; + } + return { + report: vendorState.baseline.evidence.report, + basis: vendorState.baseline.evidence.basis, + }; + } + + private async fetchMonitorHistory( + domain: string, + monitorId: string, + summary: CheckSummary, + ): Promise<{ + events: MonitorSnapshotEvent[]; + seen: Set; + newErrorFingerprints: string[]; + }> { + const state = await this.store.read(); + const monitor = state.vendors[domain]?.monitor; + const previouslyReported = new Set( + monitor?.status === "active" ? monitor.reportedExecutionErrors ?? [] : [], + ); + const seenThisFetch = new Set(); + const events: MonitorSnapshotEvent[] = []; + const seen = new Set(); + const newErrorFingerprints: string[] = []; + let reachedKnownExecutionError = false; + const cursors = new Set(); + let cursor: string | undefined; + + do { + const page = await this.client.monitor.events(monitorId, { + limit: 100, + include_completions: false, + ...(cursor ? { cursor } : {}), + }); + for (const warning of page.warnings ?? []) { + this.addDiagnostic(summary.warnings, { + code: "monitor_warning", + vendor: domain, + resourceId: monitorId, + message: `${monitorId}: ${warning.message}`, + }); + } + for (const event of page.events) { + if (isSnapshotEvent(event) && !seen.has(event.event_id)) { + seen.add(event.event_id); + events.push(event); + } else if (isMonitorError(event)) { + const fingerprint = `${event.timestamp}\u0000${event.error_message}`; + if (previouslyReported.has(fingerprint)) { + reachedKnownExecutionError = true; + } else if (!reachedKnownExecutionError && !seenThisFetch.has(fingerprint)) { + seenThisFetch.add(fingerprint); + newErrorFingerprints.push(fingerprint); + this.addDiagnostic(summary.errors, { + code: "monitor_execution_error", + vendor: domain, + resourceId: monitorId, + message: `${monitorId} execution error: ${event.error_message}`, + }); + } + } + } + const next = page.next_cursor ?? undefined; + if (next && cursors.has(next)) { + throw new Error(`Monitor ${monitorId} returned a repeated pagination cursor.`); + } + if (next) cursors.add(next); + cursor = next; + } while (cursor); + + return { events, seen, newErrorFingerprints }; + } + + private async recordSnapshotEvent( + domain: string, + monitorId: string, + event: SnapshotEventInput, + summary: CheckSummary, + countAsNew: boolean, + firstSeenAt?: string, + fallback?: EvidenceSnapshot, + ): Promise { + const state = await this.store.read(); + const vendorState = state.vendors[domain]; + if (!vendorState) throw new Error(`No local vendor state for ${domain}.`); + const reconstructed = reconstructSnapshotEvent(event, fallback); + const decision = decideFollowUp({ + previousReport: reconstructed.previousReport, + currentReport: reconstructed.currentReport, + changedFields: reconstructed.changedFields, + threshold: this.config.followUpRiskThreshold, + riskFloor: vendorState.vendor.riskFloor, + }); + const evaluatedAt = this.now().toISOString(); + const firstObservedAt = firstSeenAt ?? evaluatedAt; + const historicalDecision = decision.runFollowUp + ? FollowUpRequiredDecisionSchema.parse({ + ...decision, + policyVersion: POLICY_VERSION, + evaluatedAt, + ...(vendorState.vendor.riskFloor + ? { riskFloor: vendorState.vendor.riskFloor } + : {}), + }) + : NoFollowUpDecisionSchema.parse({ + ...decision, + policyVersion: POLICY_VERSION, + evaluatedAt, + ...(vendorState.vendor.riskFloor + ? { riskFloor: vendorState.vendor.riskFloor } + : {}), + }); + const common = { + eventId: event.event_id, + monitorId, + eventDate: event.event_date, + eventGroupId: event.event_group_id, + firstSeenAt: firstObservedAt, + previousReport: reconstructed.previousReport, + previousBasis: reconstructed.previousBasis, + currentReport: reconstructed.currentReport, + currentBasis: reconstructed.currentBasis, + }; + const entry = EventLedgerEntrySchema.parse( + decision.runFollowUp + ? { + stage: "follow_up_queued", + ...common, + decision: historicalDecision, + failedAttempts: [], + } + : { + stage: "completed_without_follow_up", + ...common, + decision: historicalDecision, + completedAt: evaluatedAt, + }, + ) as EventEvidence; + + await this.store.update((current) => { + const existing = current.vendors[domain]!.events[event.event_id]; + if (!existing || existing.stage === "event_failed") { + current.vendors[domain]!.events[event.event_id] = entry; + } + }); + this.upsertChange(vendorState.vendor, entry, summary); + if (countAsNew) summary.newEvents += 1; + if (countAsNew && decision.runFollowUp) summary.followUpDecisions += 1; + return entry; + } + + private async recordFailedSnapshotEvent( + domain: string, + monitorId: string, + event: MonitorSnapshotEvent, + error: InvalidSnapshotEventError, + priorSnapshot?: EvidenceSnapshot, + ): Promise { + const now = this.now().toISOString(); + const failed = EventLedgerEntrySchema.parse({ + stage: "event_failed", + eventId: event.event_id, + monitorId, + eventDate: event.event_date, + eventGroupId: event.event_group_id, + firstSeenAt: now, + rawEvent: rawSnapshotEvent(event), + ...(priorSnapshot ? { priorSnapshot } : {}), + failure: { + kind: "invalid_event", + message: error.message, + failedAt: now, + attempts: 1, + }, + }); + await this.store.update((state) => { + if (!state.vendors[domain]!.events[event.event_id]) { + state.vendors[domain]!.events[event.event_id] = failed; + } + }); + } + + private async retryFailedEvent( + domain: string, + eventId: string, + summary: CheckSummary, + ): Promise { + const state = await this.store.read(); + const entry = state.vendors[domain]?.events[eventId]; + if (!entry || entry.stage !== "event_failed") return; + try { + const restored = restoredSnapshotEvent(entry.rawEvent); + const fallback = entry.priorSnapshot ?? this.legacyRetryFallback(state, domain, eventId); + const recorded = await this.recordSnapshotEvent( + domain, + entry.monitorId, + restored, + summary, + false, + entry.firstSeenAt, + fallback, + ); + await this.advanceLatestIfCurrentlyObserved(domain, recorded); + if (recorded.stage === "follow_up_queued") { + await this.completeFollowUp(domain, eventId, summary); + } + } catch (error) { + if (!(error instanceof InvalidSnapshotEventError)) throw error; + await this.store.update((current) => { + const failed = current.vendors[domain]?.events[eventId]; + if (!failed || failed.stage !== "event_failed") return; + failed.failure = { + ...failed.failure, + message: error.message, + failedAt: this.now().toISOString(), + attempts: failed.failure.attempts + 1, + }; + }); + this.addDiagnostic(summary.errors, { + code: "invalid_snapshot_event", + vendor: domain, + resourceId: eventId, + message: error.message, + }); + } + } + + private legacyRetryFallback( + state: RecipeState, + domain: string, + eventId: string, + ): EvidenceSnapshot | undefined { + const vendor = state.vendors[domain]; + const entry = vendor?.events[eventId]; + if (!vendor || !entry || entry.stage !== "event_failed") return undefined; + const content = entry.rawEvent.previousOutput.content; + if ( + typeof content !== "object" || + content === null || + Array.isArray(content) || + Object.keys(content).length > 0 + ) { + return undefined; + } + const isOnlyEvent = Object.keys(vendor.events).length === 1; + const isInitialCheckpoint = + vendor.monitor?.status === "active" && + vendor.monitor.newestObservedEventId === eventId; + if (isOnlyEvent && isInitialCheckpoint && vendor.baseline.stage === "completed") { + return { + report: vendor.baseline.evidence.report, + basis: vendor.baseline.evidence.basis, + }; + } + throw new InvalidSnapshotEventError( + eventId, + `Snapshot event ${eventId} is invalid: legacy failed state has an empty previous_output but no unambiguous predecessor. Re-fetch the event history or repair the saved prior snapshot before retrying.`, + ); + } + + private async queueFailedFollowUp(domain: string, eventId: string): Promise { + await this.store.update((state) => { + const entry = state.vendors[domain]?.events[eventId]; + if (!entry || entry.stage !== "follow_up_failed") return; + state.vendors[domain]!.events[eventId] = EventLedgerEntrySchema.parse({ + ...entry, + stage: "follow_up_queued", + }); + }); + } + + private async advanceLatestIfCurrentlyObserved( + domain: string, + candidate: EventEvidence, + ): Promise { + await this.store.update((state) => { + const vendor = state.vendors[domain]; + if (!vendor) return; + if ( + vendor.monitor?.status === "active" && + vendor.monitor.newestObservedEventId === candidate.eventId + ) { + vendor.latestEventId = candidate.eventId; + } + }); + } + + private async completeFollowUp( + domain: string, + eventId: string, + summary: CheckSummary, + ): Promise { + let state = await this.store.read(); + const vendorState = state.vendors[domain]; + let entry = vendorState?.events[eventId]; + if (!vendorState || !entry || entry.stage === "event_failed") { + throw new Error(`No follow-up event ${eventId} for ${domain}.`); + } + if ( + entry.stage === "completed_without_follow_up" || + entry.stage === "follow_up_completed" || + entry.stage === "follow_up_failed" + ) { + this.upsertChange(vendorState.vendor, entry, summary); + return; + } + + let run: TaskRef; + if (entry.stage === "follow_up_running") { + run = entry.run; + this.report(`Resuming follow-up Task ${run.runId} for event ${eventId}...`); + } else { + const created = await this.client.taskRun.create( + buildChangeInvestigationTaskParams({ + vendor: vendorState.vendor, + eventId, + changedFields: entry.decision.changedFields, + previousReport: entry.previousReport, + currentReport: entry.currentReport, + ...(vendorState.baseline.stage === "completed" && + vendorState.baseline.run.interactionId + ? { previousInteractionId: vendorState.baseline.run.interactionId } + : {}), + policyDecision: { + threshold: entry.decision.threshold, + previousLevel: entry.decision.previousLevel, + currentLevel: entry.decision.currentLevel, + requiresHumanReview: entry.decision.requiresHumanReview, + reasons: entry.decision.reasons, + }, + processor: this.config.followUpProcessor, + }), + { maxRetries: 0 }, + ); + run = { + runId: created.run_id, + interactionId: created.interaction_id, + startedAt: this.now().toISOString(), + }; + await this.store.update((current) => { + const currentEntry = current.vendors[domain]!.events[eventId]!; + if (currentEntry.stage !== "follow_up_queued") return; + current.vendors[domain]!.events[eventId] = EventLedgerEntrySchema.parse({ + ...currentEntry, + stage: "follow_up_running", + run, + }); + }); + summary.followUpTasksCreated += 1; + this.report(`Created follow-up Task ${run.runId} for event ${eventId}; waiting...`); + } + + state = await this.store.read(); + entry = state.vendors[domain]?.events[eventId]; + if (!entry || entry.stage === "event_failed") { + throw new Error(`Follow-up event ${eventId} disappeared for ${domain}.`); + } + this.upsertChange(vendorState.vendor, entry, summary); + + try { + const result = await this.tasks.wait(run.runId); + if (result.run.run_id !== run.runId || result.run.status !== "completed") { + throw new InvalidTaskOutputError( + `Follow-up Task ${run.runId} returned an inconsistent completed result.`, + ); + } + if (result.output.type !== "json") { + throw new InvalidTaskOutputError( + `Follow-up Task ${run.runId} returned ${result.output.type}, expected JSON.`, + ); + } + let investigation: ChangeInvestigation; + try { + investigation = ChangeInvestigationSchema.parse(result.output.content); + } catch (error) { + throw new InvalidTaskOutputError( + `Follow-up Task ${run.runId} returned an invalid investigation: ${errorMessage(error)}`, + { cause: error }, + ); + } + const basis = parseTaskBasis(result.output.basis, `Follow-up Task ${run.runId}`); + const warnings = taskWarnings(result); + const completedAt = this.now().toISOString(); + await this.store.update((current) => { + const currentEntry = current.vendors[domain]!.events[eventId]!; + if (currentEntry.stage !== "follow_up_running") return; + current.vendors[domain]!.events[eventId] = EventLedgerEntrySchema.parse({ + ...currentEntry, + stage: "follow_up_completed", + run: { + ...run, + interactionId: result.run.interaction_id || run.interactionId, + }, + investigation, + basis, + warnings, + completedAt, + }); + }); + summary.followUpsCompleted += 1; + for (const warning of warnings) this.report(`Follow-up ${run.runId}: ${warning}`); + state = await this.store.read(); + entry = state.vendors[domain]!.events[eventId]!; + if (entry.stage !== "event_failed") { + this.upsertChange(vendorState.vendor, entry, summary); + } + } catch (error) { + const failure = this.tasks.failure(error, run); + if (!failure) throw error; + await this.store.update((current) => { + const currentEntry = current.vendors[domain]!.events[eventId]!; + if (currentEntry.stage !== "follow_up_running") return; + current.vendors[domain]!.events[eventId] = EventLedgerEntrySchema.parse({ + ...currentEntry, + stage: "follow_up_failed", + failedAttempts: [...currentEntry.failedAttempts, failure], + }); + }); + const failed = (await this.store.read()).vendors[domain]!.events[eventId]!; + if (failed.stage !== "event_failed") { + this.upsertChange(vendorState.vendor, failed, summary); + } + this.addDiagnostic(summary.errors, { + code: "follow_up_terminal_failure", + vendor: domain, + resourceId: run.runId, + message: `Follow-up Task ${run.runId} failed permanently: ${failure.message}`, + }); + } + } + + private upsertChange( + vendor: Vendor, + entry: EventEvidence, + summary: CheckSummary, + ): void { + const assessment = this.assessmentView( + vendor, + { + kind: "monitor_event", + monitorId: entry.monitorId, + eventId: entry.eventId, + eventDate: entry.eventDate, + }, + entry.currentReport, + entry.currentBasis, + entry.firstSeenAt, + ); + let followUp: FollowUpView; + if (entry.stage === "completed_without_follow_up") { + followUp = { status: "not_required" }; + } else if (entry.stage === "follow_up_queued") { + followUp = { status: "pending" }; + } else if (entry.stage === "follow_up_running") { + followUp = { status: "pending", runId: entry.run.runId }; + } else if (entry.stage === "follow_up_failed") { + const failure = lastFailure(entry); + followUp = { + status: "failed", + runId: failure.run.runId, + message: failure.message, + failedAt: failure.failedAt, + }; + } else { + followUp = { + status: "completed", + runId: entry.run.runId, + investigation: entry.investigation, + basis: entry.basis, + warnings: entry.warnings, + }; + } + const value: ChangeView = { + vendor, + event: { + monitorId: entry.monitorId, + eventId: entry.eventId, + eventDate: entry.eventDate, + changedFields: entry.decision.changedFields, + }, + assessment, + decision: entry.decision, + followUp, + }; + const index = summary.changes.findIndex( + (candidate) => + candidate.vendor.domain === vendor.domain && candidate.event.eventId === entry.eventId, + ); + if (index === -1) summary.changes.push(value); + else summary.changes[index] = value; + } + + private assessmentView( + vendor: Vendor, + source: AssessmentView["source"], + report: VendorReport, + basis: FieldBasis[], + observedAt: string, + ): AssessmentView { + return { + source, + observedAt, + report, + basis, + risk: { + ...scoreReport(report, vendor.riskFloor, basis), + policyVersion: POLICY_VERSION, + }, + }; + } + + private async cleanupUnlocked(options: { vendors?: string[] }): Promise { + const requested = options.vendors?.map(normalizeVendorDomain); + const uniqueRequested = requested ? [...new Set(requested)] : undefined; + const summary: CleanupSummary = { + scope: uniqueRequested + ? { kind: "vendors", vendors: uniqueRequested } + : { kind: "all" }, + monitors: [], + warnings: [], + }; + const state = await this.store.read(); + const domains = uniqueRequested ?? Object.keys(state.vendors); + + for (const domain of domains) { + const vendorState = state.vendors[domain]; + if (!vendorState) { + summary.warnings.push({ + code: "unknown_vendor", + vendor: domain, + message: `No saved vendor state exists for ${domain}.`, + }); + continue; + } + const monitor = vendorState.monitor; + if (!monitor) { + summary.warnings.push({ + code: "no_monitor", + vendor: domain, + message: `${domain} has no state-owned Monitor.`, + }); + continue; + } + if (monitor.status === "cancelled") { + summary.monitors.push({ + vendor: domain, + monitorId: monitor.monitorId, + status: "already_cancelled", + }); + continue; + } + + this.report(`Cancelling Monitor ${monitor.monitorId} for ${domain}...`); + let resultStatus: "cancelled" | "already_cancelled" = "cancelled"; + try { + const cancellation = await this.client.monitor.cancel(monitor.monitorId); + if (cancellation.status !== "cancelled") { + const confirmed = await this.client.monitor.retrieve(monitor.monitorId); + if (confirmed.status !== "cancelled") { + throw new Error(`Monitor ${monitor.monitorId} did not reach cancelled status.`); + } + } + } catch (cancelError) { + try { + const remote = await this.client.monitor.retrieve(monitor.monitorId); + if (remote.status !== "cancelled") throw cancelError; + resultStatus = "already_cancelled"; + } catch { + summary.monitors.push({ + vendor: domain, + monitorId: monitor.monitorId, + status: "failed", + message: errorMessage(cancelError), + }); + continue; + } + } + + try { + await this.markMonitorCancelled(domain, monitor.monitorId); + summary.monitors.push({ + vendor: domain, + monitorId: monitor.monitorId, + status: resultStatus, + }); + this.report(`Cancelled Monitor ${monitor.monitorId}.`); + } catch (error) { + summary.monitors.push({ + vendor: domain, + monitorId: monitor.monitorId, + status: "failed", + message: `Remote cancellation succeeded, but local state was not updated: ${errorMessage(error)}`, + }); + } + } + + return summary; + } + + private async findAdoptableMonitor( + vendor: Vendor, + baselineRunId: string, + ): Promise { + const candidates: SnapshotMonitor[] = []; + const cursors = new Set(); + let cursor: string | undefined; + do { + const page = await this.client.monitor.list({ + limit: 100, + status: ["active"], + type: ["snapshot"], + ...(cursor ? { cursor } : {}), + }); + candidates.push( + ...page.monitors.filter((monitor) => + isSnapshotMonitorForBaseline( + monitor, + vendor, + baselineRunId, + this.config.monitorFrequency, + this.config.monitorProcessor, + ), + ), + ); + const next = page.next_cursor ?? undefined; + if (next && cursors.has(next)) { + throw new Error("Monitor listing returned a repeated pagination cursor."); + } + if (next) cursors.add(next); + cursor = next; + } while (cursor); + + if (candidates.length > 1) { + throw new Error( + `Multiple active recipe Monitors match ${vendor.domain} and baseline ${baselineRunId}: ${candidates.map(({ monitor_id }) => monitor_id).join(", ")}. Cancel extras before retrying.`, + ); + } + return candidates[0]; + } + + private async saveActiveMonitor( + vendor: Vendor, + baselineRunId: string, + monitor: SnapshotMonitor, + ): Promise { + await this.store.update((state) => { + state.vendors[vendor.domain]!.monitor = { + status: "active", + monitorId: monitor.monitor_id, + baselineRunId, + frequency: monitor.frequency, + processor: monitor.processor, + createdAt: monitor.created_at || this.now().toISOString(), + }; + }); + } + + private async markMonitorCancelled(domain: string, monitorId: string): Promise { + await this.store.update((state) => { + const record = state.vendors[domain]?.monitor; + if (!record || record.status !== "active" || record.monitorId !== monitorId) return; + state.vendors[domain]!.monitor = { + status: "cancelled", + monitorId: record.monitorId, + baselineRunId: record.baselineRunId, + frequency: record.frequency, + processor: record.processor, + createdAt: record.createdAt, + cancelledAt: this.now().toISOString(), + }; + }); + } + + private addDiagnostic(target: Diagnostic[], diagnostic: Diagnostic): void { + target.push(diagnostic); + this.report(diagnostic.message); + } +} diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/bootstrap.test.ts b/typescript-recipes/parallel-vendor-intelligence/tests/bootstrap.test.ts new file mode 100644 index 0000000..b9d296e --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/bootstrap.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { basis, reportResult, snapshotMonitor, taskRun, vendorReport } from "./fixtures.js"; +import { + cleanupTestDirectories, + fixedNow, + runtime, + seedCompletedVendor, + vendor, +} from "./runtime.js"; + +afterEach(cleanupTestDirectories); + +describe("bootstrap", () => { + it("rejects empty and duplicate input before any API call", async () => { + const test = await runtime(); + await expect(test.service.bootstrap([])).rejects.toThrow("at least one vendor"); + await expect( + test.service.bootstrap([ + vendor, + { name: "Duplicate", domain: "https://EXAMPLE.com/path" }, + ]), + ).rejects.toThrow("duplicate normalized domain example.com"); + expect(test.taskCreate).not.toHaveBeenCalled(); + expect(test.monitorCreate).not.toHaveBeenCalled(); + }); + + it("returns the cited baseline and reuses its Task and Monitor", async () => { + const test = await runtime(); + const first = await test.service.bootstrap([vendor]); + + expect(first).toMatchObject({ + baselinesCreated: 1, + baselinesResumed: 0, + monitorsCreated: 1, + results: [ + { + vendor, + baseline: { action: "created", runId: "run-1" }, + monitor: { action: "created", monitorId: "monitor-1" }, + assessment: { + source: { kind: "baseline", runId: "run-1" }, + report: vendorReport(), + basis: [basis("cybersecurity")], + risk: { level: "LOW", policyVersion: 1 }, + }, + }, + ], + }); + expect(test.taskCreate).toHaveBeenCalledWith( + expect.objectContaining({ + processor: "core", + input: expect.objectContaining({ vendor_domain: "example.com" }), + }), + { maxRetries: 0 }, + ); + expect(test.monitorCreate).toHaveBeenCalledWith( + { + type: "snapshot", + frequency: "1d", + processor: "lite", + settings: { task_run_id: "run-1" }, + metadata: { recipe: "vendor-intel", vendor: "example.com", spec: "1" }, + }, + { maxRetries: 0 }, + ); + + const second = await test.service.bootstrap([vendor]); + expect(second).toMatchObject({ + baselinesReused: 1, + monitorsReused: 1, + results: [ + { + baseline: { action: "reused" }, + monitor: { action: "reused" }, + }, + ], + }); + expect(test.taskCreate).toHaveBeenCalledTimes(1); + expect(test.monitorCreate).toHaveBeenCalledTimes(1); + }); + + it("resumes a saved running Task instead of creating another", async () => { + const test = await runtime(); + await test.store.update((state) => { + state.vendors[vendor.domain] = { + vendor, + baseline: { + stage: "running", + run: { + runId: "saved-run", + interactionId: "interaction-saved-run", + startedAt: fixedNow.toISOString(), + }, + failedAttempts: [], + }, + events: {}, + }; + }); + test.taskResult.mockResolvedValue(reportResult(vendorReport(), "saved-run")); + const summary = await test.service.bootstrap([vendor]); + expect(summary.baselinesResumed).toBe(1); + expect(summary.results[0]?.baseline.action).toBe("resumed"); + expect(test.taskCreate).not.toHaveBeenCalled(); + }); + + it("polls a still-running Task but leaves transport failures resumable", async () => { + const retrying = await runtime(); + retrying.taskResult + .mockRejectedValueOnce({ status: 408 }) + .mockResolvedValueOnce(reportResult(vendorReport())); + await retrying.service.bootstrap([vendor]); + expect(retrying.taskResult).toHaveBeenCalledTimes(2); + expect(retrying.taskRetrieve).toHaveBeenCalledWith("run-1"); + + const transport = await runtime(); + transport.taskResult.mockRejectedValue({ status: 503, message: "unavailable" }); + await expect(transport.service.bootstrap([vendor])).rejects.toMatchObject({ status: 503 }); + expect((await transport.store.read()).vendors[vendor.domain]?.baseline.stage).toBe( + "running", + ); + }); + + it("persists terminal Task failure and spends again only with --retry-failed", async () => { + const test = await runtime(); + test.taskCreate + .mockResolvedValueOnce(taskRun("failed-run")) + .mockResolvedValueOnce(taskRun("replacement-run")); + test.taskResult + .mockRejectedValueOnce({ status: 422, message: "failed" }) + .mockResolvedValueOnce(reportResult(vendorReport(), "replacement-run")); + test.taskRetrieve.mockResolvedValueOnce({ + ...taskRun("failed-run"), + status: "failed", + error: { message: "research failed", ref_id: "ref-1" }, + }); + + await expect(test.service.bootstrap([vendor])).rejects.toThrow("--retry-failed"); + const failed = (await test.store.read()).vendors[vendor.domain]?.baseline; + expect(failed?.stage).toBe("failed"); + if (failed?.stage !== "failed") throw new Error("missing failed baseline"); + expect(failed.failedAttempts[0]).toMatchObject({ + kind: "remote_terminal", + status: "failed", + message: "research failed", + refId: "ref-1", + }); + + await expect(test.service.bootstrap([vendor])).rejects.toThrow("--retry-failed"); + expect(test.taskCreate).toHaveBeenCalledTimes(1); + + const retried = await test.service.bootstrap([vendor], { retryFailed: true }); + expect(retried.results[0]?.baseline.runId).toBe("replacement-run"); + expect(test.taskCreate).toHaveBeenCalledTimes(2); + const completed = (await test.store.read()).vendors[vendor.domain]?.baseline; + expect(completed?.stage).toBe("completed"); + if (completed?.stage !== "completed") throw new Error("missing completed baseline"); + expect(completed.failedAttempts).toHaveLength(1); + }); + + it("treats a completed Task with invalid output as an explicit failed attempt", async () => { + const test = await runtime(); + test.taskResult.mockResolvedValue({ + run: { ...taskRun("run-1"), status: "completed" }, + output: { type: "text", content: "not structured", basis: [] }, + }); + await expect(test.service.bootstrap([vendor])).rejects.toThrow("--retry-failed"); + const baseline = (await test.store.read()).vendors[vendor.domain]?.baseline; + expect(baseline?.stage).toBe("failed"); + if (baseline?.stage !== "failed") throw new Error("missing failed baseline"); + expect(baseline.failedAttempts[0]?.kind).toBe("invalid_output"); + }); + + it("treats an invalid completed Task basis as an explicit failed attempt", async () => { + const test = await runtime(); + test.taskResult.mockResolvedValue({ + ...reportResult(vendorReport()), + output: { + type: "json", + content: vendorReport(), + basis: "not-a-basis-array", + }, + }); + + await expect(test.service.bootstrap([vendor])).rejects.toThrow("--retry-failed"); + const baseline = (await test.store.read()).vendors[vendor.domain]?.baseline; + expect(baseline?.stage).toBe("failed"); + if (baseline?.stage !== "failed") throw new Error("missing failed baseline"); + expect(baseline.failedAttempts[0]).toMatchObject({ kind: "invalid_output" }); + expect(baseline.failedAttempts[0]?.message).toContain("invalid evidence basis"); + }); + + it("paginates and adopts exactly one matching orphan Monitor", async () => { + const test = await runtime(); + await seedCompletedVendor(test.store); + const unrelated = snapshotMonitor("unrelated", "other-run"); + const adopted = snapshotMonitor("adopted", "run-example.com"); + test.monitorList + .mockResolvedValueOnce({ monitors: [unrelated], next_cursor: "next" }) + .mockResolvedValueOnce({ monitors: [adopted] }); + const summary = await test.service.bootstrap([vendor]); + expect(summary.monitorsAdopted).toBe(1); + expect(summary.results[0]?.monitor).toMatchObject({ + action: "adopted", + monitorId: "adopted", + }); + expect(test.monitorCreate).not.toHaveBeenCalled(); + }); + + it("re-scans and adopts after an ambiguous Monitor create failure", async () => { + const test = await runtime(); + const recovered = snapshotMonitor("recovered", "run-1"); + test.monitorList + .mockResolvedValueOnce({ monitors: [] }) + .mockResolvedValueOnce({ monitors: [recovered] }); + test.monitorCreate.mockRejectedValue({ status: 503, message: "response lost" }); + + const summary = await test.service.bootstrap([vendor]); + expect(summary).toMatchObject({ monitorsCreated: 0, monitorsAdopted: 1 }); + expect(summary.results[0]?.monitor).toMatchObject({ + action: "adopted", + monitorId: "recovered", + }); + expect(test.monitorCreate).toHaveBeenCalledOnce(); + expect(test.monitorList).toHaveBeenCalledTimes(2); + }); + + it("gives safe retry guidance when an ambiguous Monitor create is not yet discoverable", async () => { + const test = await runtime(); + test.monitorList.mockResolvedValue({ monitors: [] }); + test.monitorCreate.mockRejectedValue({ status: 503, message: "response lost" }); + + await expect(test.service.bootstrap([vendor])).rejects.toThrow( + "Re-run bootstrap; it will adopt any matching Monitor", + ); + expect(test.monitorList).toHaveBeenCalledTimes(2); + }); + + it("cancels a newly created Monitor whose response does not match the request", async () => { + const test = await runtime(); + test.monitorCreate.mockResolvedValue(snapshotMonitor("mismatched", "wrong-run")); + + await expect(test.service.bootstrap([vendor])).rejects.toThrow( + "mismatched Monitor was cancelled automatically", + ); + expect(test.monitorCancel).toHaveBeenCalledWith("mismatched"); + expect((await test.store.read()).vendors[vendor.domain]?.monitor).toBeUndefined(); + }); + + it("refuses to choose between multiple matching orphan Monitors", async () => { + const test = await runtime(); + await seedCompletedVendor(test.store); + test.monitorList.mockResolvedValue({ + monitors: [ + snapshotMonitor("first", "run-example.com"), + snapshotMonitor("second", "run-example.com"), + ], + }); + await expect(test.service.bootstrap([vendor])).rejects.toThrow("first, second"); + expect(test.monitorCreate).not.toHaveBeenCalled(); + }); + + it("replaces a remotely cancelled Monitor without recreating the baseline", async () => { + const test = await runtime(); + await seedCompletedVendor(test.store, { monitorId: "stale-monitor" }); + test.monitorRetrieve.mockResolvedValue( + snapshotMonitor("stale-monitor", "run-example.com", "cancelled"), + ); + const summary = await test.service.bootstrap([vendor]); + expect(summary).toMatchObject({ baselinesReused: 1, monitorsCreated: 1 }); + expect(test.taskCreate).not.toHaveBeenCalled(); + }); + + it("rejects changing the saved baseline identity", async () => { + const test = await runtime(); + await seedCompletedVendor(test.store, { monitorId: "monitor-1" }); + await expect( + test.service.bootstrap([{ name: "Renamed", domain: "example.com" }]), + ).rejects.toThrow("changing its name"); + }); + + it("warns that omitted vendors remain active because bootstrap is additive", async () => { + const test = await runtime(); + const omitted = { name: "Omitted", domain: "omitted.example.com" }; + await seedCompletedVendor(test.store, { + inputVendor: omitted, + monitorId: "omitted-monitor", + }); + const summary = await test.service.bootstrap([vendor]); + expect(summary.omittedActiveVendors).toEqual([ + { vendor: "omitted.example.com", monitorId: "omitted-monitor" }, + ]); + expect(summary.warnings[0]).toMatchObject({ code: "omitted_active_vendor" }); + }); +}); diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/check-updates.test.ts b/typescript-recipes/parallel-vendor-intelligence/tests/check-updates.test.ts new file mode 100644 index 0000000..f2904e6 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/check-updates.test.ts @@ -0,0 +1,878 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import type { MonitorErrorEvent, MonitorSnapshotEvent } from "../src/parallel-port.js"; +import { DEFAULT_CONFIG } from "../src/vendor-config.js"; +import { VendorIntelligence } from "../src/vendor-intelligence.js"; +import { + basis, + investigation, + investigationResult, + snapshotEvent, + snapshotMonitor, + taskRun, + vendorReport, +} from "./fixtures.js"; +import { + cleanupTestDirectories, + fixedNow, + runtime, + seedCompletedVendor, + vendor, +} from "./runtime.js"; + +afterEach(cleanupTestDirectories); + +describe("snapshot reconstruction and update checks", () => { + it("treats an empty event page as a successful check", async () => { + const test = await runtime(); + await seedCompletedVendor(test.store, { monitorId: "monitor-1" }); + await expect(test.service.checkForUpdates()).resolves.toMatchObject({ + monitorsChecked: 1, + newEvents: 0, + changes: [], + errors: [], + }); + }); + + it("returns a rich assessment for a non-material change without creating a Task", async () => { + const test = await runtime(); + const previous = vendorReport(); + const event = snapshotEvent({ + eventId: "low-change", + previous, + changed: { + financial_health: { + ...previous.financial_health, + summary: "A small wording change.", + }, + }, + changedBasis: [basis("financial_health")], + }); + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: previous }); + test.monitorEvents.mockResolvedValue({ events: [event] }); + + const summary = await test.service.checkForUpdates(); + expect(summary).toMatchObject({ + newEvents: 1, + followUpTasksCreated: 0, + changes: [ + { + vendor, + event: { eventId: "low-change", changedFields: ["financial_health"] }, + assessment: { + report: { financial_health: { summary: "A small wording change." } }, + risk: { level: "LOW", guidance: "continue_monitoring" }, + }, + followUp: { status: "not_required" }, + }, + ], + }); + expect(test.taskCreate).not.toHaveBeenCalled(); + expect( + (await test.store.read()).vendors[vendor.domain]?.events["low-change"]?.stage, + ).toBe("completed_without_follow_up"); + }); + + it("persists and returns the completed focused investigation", async () => { + const test = await runtime(); + const previous = vendorReport(); + const event = snapshotEvent({ + eventId: "high-change", + previous, + changed: { + cybersecurity: vendorReport({ cybersecurity: "HIGH" }).cybersecurity, + }, + changedBasis: [basis("cybersecurity")], + }); + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: previous }); + test.monitorEvents.mockResolvedValue({ events: [event] }); + test.taskCreate.mockResolvedValue(taskRun("follow-1")); + test.taskResult.mockResolvedValue(investigationResult("follow-1")); + + const summary = await test.service.checkForUpdates(); + expect(summary).toMatchObject({ + newEvents: 1, + followUpDecisions: 1, + followUpTasksCreated: 1, + followUpsCompleted: 1, + humanReviewsRequired: 1, + changes: [ + { + assessment: { risk: { level: "HIGH", guidance: "urgent_human_review" } }, + decision: { runFollowUp: true, policyVersion: 1 }, + followUp: { + status: "completed", + runId: "follow-1", + investigation, + basis: [basis("what_changed")], + }, + }, + ], + }); + expect(test.taskCreate).toHaveBeenCalledWith( + expect.objectContaining({ + processor: "pro", + previous_interaction_id: "interaction-run-example.com", + input: expect.objectContaining({ monitor_event_id: "high-change" }), + }), + { maxRetries: 0 }, + ); + expect( + (await test.store.read()).vendors[vendor.domain]?.events["high-change"]?.stage, + ).toBe("follow_up_completed"); + }); + + it("deduplicates persisted events in a fresh service instance", async () => { + const test = await runtime(); + const previous = vendorReport(); + const event = snapshotEvent({ + eventId: "once", + previous, + changed: { + legal_regulatory: { ...previous.legal_regulatory, summary: "updated" }, + }, + }); + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: previous }); + test.monitorEvents.mockResolvedValue({ events: [event] }); + expect((await test.service.checkForUpdates()).newEvents).toBe(1); + + const second = new VendorIntelligence({ + client: test.client, + store: test.store, + config: DEFAULT_CONFIG, + now: () => fixedNow, + sleep: async () => {}, + reporter: () => {}, + }); + expect((await second.checkForUpdates()).newEvents).toBe(0); + expect(test.taskCreate).not.toHaveBeenCalled(); + }); + + it("paginates newest-first history and keeps the newest durable assessment", async () => { + const test = await runtime(); + const baseline = vendorReport(); + const middle = { + ...baseline, + financial_health: { ...baseline.financial_health, summary: "middle" }, + }; + const oldest = snapshotEvent({ + eventId: "oldest", + previous: baseline, + changed: { financial_health: middle.financial_health }, + }); + const newest = snapshotEvent({ + eventId: "newest", + previous: middle, + changed: { + legal_regulatory: { ...middle.legal_regulatory, summary: "newest" }, + }, + }); + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: baseline }); + test.monitorEvents + .mockResolvedValueOnce({ events: [newest], next_cursor: "older" }) + .mockResolvedValueOnce({ events: [oldest] }); + + const summary = await test.service.checkForUpdates(); + expect(summary.changes.map(({ event }) => event.eventId)).toEqual(["oldest", "newest"]); + expect((await test.store.read()).vendors[vendor.domain]?.latestEventId).toBe("newest"); + }); + + it("resumes a saved follow-up after a transient result failure", async () => { + const test = await runtime(); + const previous = vendorReport(); + const high = snapshotEvent({ + eventId: "resumable", + previous, + changed: { cybersecurity: vendorReport({ cybersecurity: "HIGH" }).cybersecurity }, + }); + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: previous }); + test.monitorEvents.mockResolvedValueOnce({ events: [high] }).mockResolvedValue({ events: [] }); + test.taskCreate.mockResolvedValue(taskRun("saved-follow-up")); + test.taskResult + .mockRejectedValueOnce({ status: 503, message: "temporary" }) + .mockResolvedValueOnce(investigationResult("saved-follow-up")); + + const interrupted = await test.service.checkForUpdates(); + expect(interrupted.errors).toHaveLength(1); + expect(interrupted.changes).toEqual([ + expect.objectContaining({ + event: expect.objectContaining({ eventId: "resumable" }), + followUp: { status: "pending", runId: "saved-follow-up" }, + }), + ]); + expect( + (await test.store.read()).vendors[vendor.domain]?.events.resumable?.stage, + ).toBe("follow_up_running"); + expect((await test.service.checkForUpdates()).followUpsCompleted).toBe(1); + expect(test.taskCreate).toHaveBeenCalledTimes(1); + }); + + it("persists terminal follow-up failure, continues newer events, and retries explicitly", async () => { + const test = await runtime(); + const previous = vendorReport(); + const high = snapshotEvent({ + eventId: "failed-high", + previous, + changed: { cybersecurity: vendorReport({ cybersecurity: "HIGH" }).cybersecurity }, + }); + const newer = snapshotEvent({ + eventId: "newer-low", + previous: { + ...previous, + cybersecurity: vendorReport({ cybersecurity: "HIGH" }).cybersecurity, + }, + changed: { + legal_regulatory: { ...previous.legal_regulatory, summary: "newer" }, + }, + }); + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: previous }); + test.monitorEvents.mockResolvedValueOnce({ events: [newer, high] }).mockResolvedValue({ + events: [], + }); + test.taskCreate + .mockResolvedValueOnce(taskRun("failed-follow-up")) + .mockResolvedValueOnce(taskRun("replacement-follow-up")); + test.taskResult + .mockRejectedValueOnce({ status: 422, message: "failed" }) + .mockResolvedValueOnce(investigationResult("replacement-follow-up")); + test.taskRetrieve.mockResolvedValueOnce({ + ...taskRun("failed-follow-up"), + status: "failed", + error: { message: "investigation failed" }, + }); + + const first = await test.service.checkForUpdates(); + expect(first.changes.map(({ event }) => event.eventId)).toEqual([ + "failed-high", + "newer-low", + ]); + const state = await test.store.read(); + expect(state.vendors[vendor.domain]?.events["failed-high"]?.stage).toBe( + "follow_up_failed", + ); + expect(state.vendors[vendor.domain]?.events["newer-low"]?.stage).toBe( + "completed_without_follow_up", + ); + + await test.service.checkForUpdates(); + expect(test.taskCreate).toHaveBeenCalledTimes(1); + const retried = await test.service.checkForUpdates({ retryFailed: true }); + expect(retried.followUpsCompleted).toBe(1); + expect(retried.changes[0]?.followUp).toMatchObject({ + status: "completed", + runId: "replacement-follow-up", + }); + expect(test.taskCreate).toHaveBeenCalledTimes(2); + }); + + it("persists an invalid completed follow-up basis as a terminal failure", async () => { + const test = await runtime(); + const previous = vendorReport(); + const event = snapshotEvent({ + eventId: "invalid-follow-up-basis", + previous, + changed: { cybersecurity: vendorReport({ cybersecurity: "HIGH" }).cybersecurity }, + }); + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: previous }); + test.monitorEvents.mockResolvedValue({ events: [event] }); + test.taskCreate.mockResolvedValue(taskRun("follow-invalid-basis")); + test.taskResult.mockResolvedValue({ + ...investigationResult("follow-invalid-basis"), + output: { + type: "json", + content: investigation, + basis: { invalid: true }, + }, + }); + + const summary = await test.service.checkForUpdates(); + expect(summary.errors).toContainEqual( + expect.objectContaining({ code: "follow_up_terminal_failure" }), + ); + const saved = (await test.store.read()).vendors[vendor.domain]?.events[ + "invalid-follow-up-basis" + ]; + expect(saved?.stage).toBe("follow_up_failed"); + if (!saved || saved.stage !== "follow_up_failed") throw new Error("missing failure"); + expect(saved.failedAttempts[0]).toMatchObject({ kind: "invalid_output" }); + }); + + it("quarantines an invalid event, processes newer events, and can retry saved raw data", async () => { + const test = await runtime(); + const previous = vendorReport(); + const poison: MonitorSnapshotEvent = { + event_id: "poison", + event_group_id: "group-poison", + event_date: "2026-07-08", + event_type: "snapshot", + previous_output: { type: "json", content: previous, basis: [] }, + changed_output: { + type: "json", + content: { unknown_field: { value: true } }, + basis: [], + }, + }; + const newer = snapshotEvent({ + eventId: "newer-valid", + previous, + changed: { + financial_health: { ...previous.financial_health, summary: "newer" }, + }, + }); + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: previous }); + test.monitorEvents + .mockResolvedValueOnce({ events: [newer] }) + .mockResolvedValueOnce({ events: [newer, poison] }) + .mockResolvedValue({ events: [] }); + + await test.service.checkForUpdates(); + const first = await test.service.checkForUpdates(); + expect(first.errors).toContainEqual( + expect.objectContaining({ code: "invalid_snapshot_event", resourceId: "poison" }), + ); + let state = await test.store.read(); + expect(state.vendors[vendor.domain]?.events.poison?.stage).toBe("event_failed"); + expect(state.vendors[vendor.domain]?.latestEventId).toBe("newer-valid"); + + await test.store.update((current) => { + const failed = current.vendors[vendor.domain]?.events.poison; + if (!failed || failed.stage !== "event_failed") throw new Error("missing poison"); + if (failed.rawEvent.changedOutput.type !== "json") throw new Error("expected JSON"); + failed.rawEvent.changedOutput.content = { + legal_regulatory: { ...previous.legal_regulatory, summary: "recovered older event" }, + }; + }); + const retriedAt = new Date("2026-07-10T15:30:00.000Z"); + const retryService = new VendorIntelligence({ + client: test.client, + store: test.store, + config: DEFAULT_CONFIG, + now: () => retriedAt, + sleep: async () => {}, + reporter: () => {}, + }); + const retried = await retryService.checkForUpdates({ retryFailed: true }); + expect(retried.errors).toEqual([]); + state = await test.store.read(); + const recovered = state.vendors[vendor.domain]?.events.poison; + expect(recovered?.stage).toBe("completed_without_follow_up"); + if (!recovered || recovered.stage === "event_failed") throw new Error("missing event"); + expect(recovered.firstSeenAt).toBe(fixedNow.toISOString()); + expect(recovered.decision.evaluatedAt).toBe(retriedAt.toISOString()); + expect(state.vendors[vendor.domain]?.latestEventId).toBe("newer-valid"); + }); + + it("quarantines opaque malformed event payloads without blocking newer events", async () => { + const test = await runtime(); + const previous = vendorReport(); + const malformedContent: MonitorSnapshotEvent = { + event_id: "scalar-content", + event_group_id: "group-scalar-content", + event_date: "2026-07-07", + previous_output: { type: "json", content: previous, basis: [] }, + changed_output: { type: "json", content: 42, basis: [] }, + }; + const malformedBasis: MonitorSnapshotEvent = { + event_id: "malformed-basis", + event_group_id: "group-malformed-basis", + event_date: "2026-07-08", + previous_output: { type: "json", content: previous, basis: [] }, + changed_output: { + type: "json", + content: { + financial_health: { ...previous.financial_health, summary: "changed" }, + }, + basis: "not-an-array", + }, + }; + const newest = snapshotEvent({ + eventId: "valid-after-malformed", + previous, + changed: { + legal_regulatory: { ...previous.legal_regulatory, summary: "newest" }, + }, + }); + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: previous }); + test.monitorEvents.mockResolvedValue({ + events: [newest, malformedBasis, malformedContent], + }); + + const summary = await test.service.checkForUpdates(); + expect(summary.newEvents).toBe(3); + expect(summary.errors.filter(({ code }) => code === "invalid_snapshot_event")).toHaveLength( + 2, + ); + const state = await test.store.read(); + expect(state.vendors[vendor.domain]?.events["scalar-content"]?.stage).toBe( + "event_failed", + ); + expect(state.vendors[vendor.domain]?.events["malformed-basis"]?.stage).toBe( + "event_failed", + ); + expect(state.vendors[vendor.domain]?.latestEventId).toBe("valid-after-malformed"); + }); + + it("blocks same-pass history after a retried poison event starts a transient follow-up", async () => { + const test = await runtime(); + const previous = vendorReport(); + const poison: MonitorSnapshotEvent = { + event_id: "retry-to-follow-up", + event_group_id: "group-retry-to-follow-up", + event_date: "2026-07-09", + previous_output: { type: "json", content: previous, basis: [] }, + changed_output: { type: "json", content: { unknown_field: true }, basis: [] }, + }; + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: previous }); + test.monitorEvents.mockResolvedValueOnce({ events: [poison] }); + await test.service.checkForUpdates(); + await test.store.update((state) => { + const failed = state.vendors[vendor.domain]?.events["retry-to-follow-up"]; + if (!failed || failed.stage !== "event_failed") throw new Error("missing poison"); + failed.rawEvent.changedOutput.content = { + cybersecurity: vendorReport({ cybersecurity: "HIGH" }).cybersecurity, + }; + }); + test.taskCreate.mockResolvedValue(taskRun("transient-follow-up")); + test.taskResult.mockRejectedValue({ status: 503, message: "temporary" }); + test.monitorEvents.mockReset(); + test.monitorEvents.mockResolvedValue({ + events: [ + snapshotEvent({ + eventId: "newer-event", + previous, + changed: { + financial_health: { ...previous.financial_health, summary: "newer" }, + }, + }), + ], + }); + + const summary = await test.service.checkForUpdates({ retryFailed: true }); + expect(summary.errors).toContainEqual( + expect.objectContaining({ code: "event_resume_failed" }), + ); + expect(summary.changes).toContainEqual( + expect.objectContaining({ + event: expect.objectContaining({ eventId: "retry-to-follow-up" }), + followUp: { status: "pending", runId: "transient-follow-up" }, + }), + ); + expect(test.monitorEvents).not.toHaveBeenCalled(); + expect(test.taskCreate).toHaveBeenCalledOnce(); + }); + + it("reports retained-history gaps", async () => { + const test = await runtime(); + await seedCompletedVendor(test.store, { monitorId: "monitor-1" }); + await test.store.update((state) => { + const monitor = state.vendors[vendor.domain]?.monitor; + if (monitor?.status === "active") monitor.newestObservedEventId = "expired"; + }); + const summary = await test.service.checkForUpdates(); + expect(summary.warnings).toContainEqual( + expect.objectContaining({ code: "monitor_history_gap" }), + ); + }); + + it("recomputes current display risk without rewriting the historical decision", async () => { + const test = await runtime(); + const previous = vendorReport(); + const event = snapshotEvent({ + eventId: "historical-policy", + previous, + changed: { + financial_health: { ...previous.financial_health, summary: "minor change" }, + }, + }); + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: previous }); + test.monitorEvents.mockResolvedValue({ events: [event] }); + await test.service.checkForUpdates(); + + test.monitorEvents.mockResolvedValue({ events: [] }); + test.monitorRetrieve.mockResolvedValue( + snapshotMonitor("monitor-1", "run-example.com"), + ); + const bootstrap = await test.service.bootstrap([ + { ...vendor, riskFloor: "HIGH" }, + ]); + expect(bootstrap.results[0]?.assessment.risk.level).toBe("HIGH"); + const saved = (await test.store.read()).vendors[vendor.domain]?.events[ + "historical-policy" + ]; + expect(saved?.stage).toBe("completed_without_follow_up"); + if (!saved || saved.stage === "event_failed") throw new Error("missing event"); + expect(saved.decision.runFollowUp).toBe(false); + expect(saved.decision.riskFloor).toBeUndefined(); + expect(saved.decision.policyVersion).toBe(1); + }); + + it("retains a newest-first checkpoint without re-reporting older execution errors", async () => { + const test = await runtime(); + await seedCompletedVendor(test.store, { monitorId: "monitor-1" }); + const failures: MonitorErrorEvent[] = Array.from({ length: 101 }, (_, index) => ({ + event_type: "error", + timestamp: `2026-07-09T11:${String(index).padStart(2, "0")}:00.000Z`, + error_message: `error-${index}`, + })); + test.monitorEvents.mockResolvedValue({ events: failures }); + expect((await test.service.checkForUpdates()).errors).toHaveLength(101); + const saved = await test.store.read(); + const monitor = saved.vendors[vendor.domain]?.monitor; + expect(monitor?.status).toBe("active"); + if (monitor?.status !== "active") throw new Error("missing active monitor"); + expect(monitor.reportedExecutionErrors).toHaveLength(100); + expect(monitor.reportedExecutionErrors?.[0]).toContain("error-0"); + const second = await test.service.checkForUpdates(); + expect(second.errors).toEqual([]); + expect((await test.service.checkForUpdates()).errors).toEqual([]); + }); + + it("deduplicates a pagination-boundary error without hiding later new errors", async () => { + const test = await runtime(); + await seedCompletedVendor(test.store, { monitorId: "monitor-1" }); + const first: MonitorErrorEvent = { + event_type: "error", + timestamp: "2026-07-09T12:00:00.000Z", + error_message: "newest failure", + }; + const second: MonitorErrorEvent = { + event_type: "error", + timestamp: "2026-07-09T11:00:00.000Z", + error_message: "older new failure", + }; + test.monitorEvents + .mockResolvedValueOnce({ events: [first], next_cursor: "older" }) + .mockResolvedValueOnce({ events: [first, second] }); + + const summary = await test.service.checkForUpdates(); + expect(summary.errors.map(({ message }) => message)).toEqual([ + expect.stringContaining("newest failure"), + expect.stringContaining("older new failure"), + ]); + }); + + it("reconstructs sequential empty-previous patches oldest to newest", async () => { + const test = await runtime(); + const baseline = vendorReport(); + const firstFinancial = { + ...baseline.financial_health, + summary: "First observed financial change.", + }; + const secondLegal = { + ...baseline.legal_regulatory, + summary: "Later legal change.", + }; + const first: MonitorSnapshotEvent = { + event_id: "partial-first", + event_group_id: "group-partial-first", + event_date: "2026-07-13", + previous_output: { type: "json", content: {}, basis: [] }, + changed_output: { + type: "json", + content: { financial_health: firstFinancial }, + basis: [basis("financial_health", "https://first.test/financial")], + }, + }; + const second: MonitorSnapshotEvent = { + event_id: "partial-second", + event_group_id: "group-partial-second", + event_date: "2026-07-14", + previous_output: { type: "json", content: {}, basis: [] }, + changed_output: { + type: "json", + content: { legal_regulatory: secondLegal }, + basis: [basis("legal_regulatory", "https://second.test/legal")], + }, + }; + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: baseline }); + await test.store.update((state) => { + const completed = state.vendors[vendor.domain]?.baseline; + if (completed?.stage !== "completed") throw new Error("missing baseline"); + completed.evidence.basis = [ + basis("cybersecurity", "https://baseline.test/security"), + basis("financial_health", "https://baseline.test/financial"), + ]; + }); + test.monitorEvents.mockResolvedValue({ events: [second, first] }); + + const summary = await test.service.checkForUpdates(); + expect(summary.changes.map(({ event }) => event.eventId)).toEqual([ + "partial-first", + "partial-second", + ]); + const saved = (await test.store.read()).vendors[vendor.domain]?.events["partial-second"]; + expect(saved?.stage).toBe("completed_without_follow_up"); + if (!saved || saved.stage === "event_failed") throw new Error("missing second event"); + expect(saved.previousReport.financial_health).toEqual(firstFinancial); + expect(saved.currentReport.legal_regulatory).toEqual(secondLegal); + expect(saved.currentBasis).toEqual([ + basis("cybersecurity", "https://baseline.test/security"), + basis("financial_health", "https://first.test/financial"), + basis("legal_regulatory", "https://second.test/legal"), + ]); + }); + + it("blocks an empty-previous patch after an invalid predecessor", async () => { + const test = await runtime(); + const baseline = vendorReport(); + const invalid: MonitorSnapshotEvent = { + event_id: "invalid-predecessor", + event_group_id: "group-invalid-predecessor", + event_date: "2026-07-13", + previous_output: { type: "json", content: {}, basis: [] }, + changed_output: { type: "json", content: { unknown_field: true }, basis: [] }, + }; + const dependent: MonitorSnapshotEvent = { + event_id: "dependent-partial", + event_group_id: "group-dependent-partial", + event_date: "2026-07-14", + previous_output: { type: "json", content: {}, basis: [] }, + changed_output: { + type: "json", + content: { + financial_health: { + ...baseline.financial_health, + summary: "Must not merge into stale baseline state.", + }, + }, + basis: [], + }, + }; + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: baseline }); + test.monitorEvents.mockResolvedValue({ events: [dependent, invalid] }); + + const summary = await test.service.checkForUpdates(); + expect(summary.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ resourceId: "invalid-predecessor" }), + expect.objectContaining({ + resourceId: "dependent-partial", + message: expect.stringContaining("no complete predecessor"), + }), + ]), + ); + const state = await test.store.read(); + expect(state.vendors[vendor.domain]?.events["invalid-predecessor"]?.stage).toBe( + "event_failed", + ); + expect(state.vendors[vendor.domain]?.events["dependent-partial"]?.stage).toBe( + "event_failed", + ); + expect(state.vendors[vendor.domain]?.latestEventId).toBeUndefined(); + }); + + it("resumes after an invalid event when the API supplies a complete predecessor", async () => { + const test = await runtime(); + const baseline = vendorReport(); + const invalid: MonitorSnapshotEvent = { + event_id: "invalid-before-rebase", + event_group_id: "group-invalid-before-rebase", + event_date: "2026-07-13", + previous_output: { type: "json", content: {}, basis: [] }, + changed_output: { type: "json", content: { unknown_field: true }, basis: [] }, + }; + const completeRebase = snapshotEvent({ + eventId: "complete-rebase", + previous: vendorReport({ operational_resilience: "MEDIUM" }), + changed: { + legal_regulatory: { + ...baseline.legal_regulatory, + summary: "Complete predecessor restored the chain.", + }, + }, + }); + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: baseline }); + test.monitorEvents.mockResolvedValue({ events: [completeRebase, invalid] }); + + const summary = await test.service.checkForUpdates(); + expect(summary.changes.map(({ event }) => event.eventId)).toEqual(["complete-rebase"]); + const state = await test.store.read(); + expect(state.vendors[vendor.domain]?.events["invalid-before-rebase"]?.stage).toBe( + "event_failed", + ); + expect(state.vendors[vendor.domain]?.events["complete-rebase"]?.stage).toBe( + "completed_without_follow_up", + ); + expect(state.vendors[vendor.domain]?.latestEventId).toBe("complete-rebase"); + }); + + it("seeds a retained-history gap from the latest durable checkpoint", async () => { + const test = await runtime(); + const baseline = vendorReport(); + const first: MonitorSnapshotEvent = { + event_id: "checkpoint", + event_group_id: "group-checkpoint", + event_date: "2026-07-13", + previous_output: { type: "json", content: {}, basis: [] }, + changed_output: { + type: "json", + content: { + financial_health: { + ...baseline.financial_health, + summary: "Durable checkpoint value.", + }, + }, + basis: [], + }, + }; + const afterGap: MonitorSnapshotEvent = { + event_id: "after-gap", + event_group_id: "group-after-gap", + event_date: "2026-07-14", + previous_output: { type: "json", content: {}, basis: [] }, + changed_output: { + type: "json", + content: { + legal_regulatory: { + ...baseline.legal_regulatory, + summary: "Retained event after the checkpoint.", + }, + }, + basis: [], + }, + }; + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: baseline }); + test.monitorEvents.mockResolvedValueOnce({ events: [first] }); + await test.service.checkForUpdates(); + test.monitorEvents.mockResolvedValue({ events: [afterGap] }); + + const summary = await test.service.checkForUpdates(); + expect(summary.warnings).toContainEqual( + expect.objectContaining({ code: "monitor_history_gap" }), + ); + const saved = (await test.store.read()).vendors[vendor.domain]?.events["after-gap"]; + expect(saved?.stage).toBe("completed_without_follow_up"); + if (!saved || saved.stage === "event_failed") throw new Error("missing gap event"); + expect(saved.previousReport.financial_health.summary).toBe("Durable checkpoint value."); + }); + + it("retries an empty-previous failed event from its persisted prior snapshot", async () => { + const test = await runtime(); + const baseline = vendorReport(); + const event: MonitorSnapshotEvent = { + event_id: "retry-with-snapshot", + event_group_id: "group-retry-with-snapshot", + event_date: "2026-07-14", + previous_output: { type: "json", content: {}, basis: [] }, + changed_output: { type: "json", content: { unknown_field: true }, basis: [] }, + }; + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: baseline }); + test.monitorEvents.mockResolvedValueOnce({ events: [event] }).mockResolvedValue({ events: [] }); + await test.service.checkForUpdates(); + expect((await test.service.checkForUpdates()).errors).toContainEqual( + expect.objectContaining({ + code: "event_requires_retry", + message: expect.stringContaining("unknown_field"), + }), + ); + await test.store.update((state) => { + const failed = state.vendors[vendor.domain]?.events["retry-with-snapshot"]; + if (!failed || failed.stage !== "event_failed") throw new Error("missing failure"); + expect(failed.priorSnapshot?.report).toEqual(baseline); + failed.rawEvent.changedOutput.content = { + financial_health: { + ...baseline.financial_health, + summary: "Recovered deterministically.", + }, + }; + }); + + const summary = await test.service.checkForUpdates({ retryFailed: true }); + expect(summary.errors).toEqual([]); + const recovered = (await test.store.read()).vendors[vendor.domain]?.events[ + "retry-with-snapshot" + ]; + expect(recovered?.stage).toBe("completed_without_follow_up"); + expect(test.taskCreate).not.toHaveBeenCalled(); + }); + + it("recovers a safe legacy first event but rejects ambiguous legacy recovery", async () => { + const safe = await runtime(); + const baseline = vendorReport(); + const legacy: MonitorSnapshotEvent = { + event_id: "legacy-first", + event_group_id: "group-legacy-first", + event_date: "2026-07-14", + previous_output: { type: "json", content: {}, basis: [] }, + changed_output: { type: "json", content: { unknown_field: true }, basis: [] }, + }; + await seedCompletedVendor(safe.store, { monitorId: "monitor-1", report: baseline }); + safe.monitorEvents.mockResolvedValueOnce({ events: [legacy] }).mockResolvedValue({ events: [] }); + await safe.service.checkForUpdates(); + await safe.store.update((state) => { + const failed = state.vendors[vendor.domain]?.events["legacy-first"]; + if (!failed || failed.stage !== "event_failed") throw new Error("missing legacy event"); + delete failed.priorSnapshot; + failed.rawEvent.changedOutput.content = { + financial_health: baseline.financial_health, + }; + }); + expect((await safe.service.checkForUpdates({ retryFailed: true })).errors).toEqual([]); + + const ambiguous = await runtime(); + const valid = snapshotEvent({ + eventId: "prior-valid", + previous: baseline, + changed: { financial_health: baseline.financial_health }, + }); + const ambiguousFailure: MonitorSnapshotEvent = { + ...legacy, + event_id: "legacy-ambiguous", + event_group_id: "group-legacy-ambiguous", + }; + await seedCompletedVendor(ambiguous.store, { + monitorId: "monitor-1", + report: baseline, + }); + ambiguous.monitorEvents + .mockResolvedValueOnce({ events: [ambiguousFailure, valid] }) + .mockResolvedValue({ events: [] }); + await ambiguous.service.checkForUpdates(); + await ambiguous.store.update((state) => { + const failed = state.vendors[vendor.domain]?.events["legacy-ambiguous"]; + if (!failed || failed.stage !== "event_failed") throw new Error("missing ambiguity"); + delete failed.priorSnapshot; + failed.rawEvent.changedOutput.content = { + legal_regulatory: baseline.legal_regulatory, + }; + }); + + const rejected = await ambiguous.service.checkForUpdates({ retryFailed: true }); + expect(rejected.errors).toContainEqual( + expect.objectContaining({ + resourceId: "legacy-ambiguous", + message: expect.stringContaining("no unambiguous predecessor"), + }), + ); + expect( + (await ambiguous.store.read()).vendors[vendor.domain]?.events["legacy-ambiguous"] + ?.stage, + ).toBe("event_failed"); + }); + + it("does not duplicate a paid follow-up Task when history is replayed", async () => { + const test = await runtime(); + const baseline = vendorReport(); + const high: MonitorSnapshotEvent = { + event_id: "one-paid-follow-up", + event_group_id: "group-one-paid-follow-up", + event_date: "2026-07-14", + previous_output: { type: "json", content: {}, basis: [] }, + changed_output: { + type: "json", + content: { + cybersecurity: vendorReport({ cybersecurity: "HIGH" }).cybersecurity, + }, + basis: [], + }, + }; + await seedCompletedVendor(test.store, { monitorId: "monitor-1", report: baseline }); + test.monitorEvents.mockResolvedValue({ events: [high] }); + test.taskCreate.mockResolvedValue(taskRun("only-follow-up")); + test.taskResult.mockResolvedValue(investigationResult("only-follow-up")); + + expect((await test.service.checkForUpdates()).followUpTasksCreated).toBe(1); + expect((await test.service.checkForUpdates()).newEvents).toBe(0); + expect(test.taskCreate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/cleanup.test.ts b/typescript-recipes/parallel-vendor-intelligence/tests/cleanup.test.ts new file mode 100644 index 0000000..1259eab --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/cleanup.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { snapshotMonitor } from "./fixtures.js"; +import { + cleanupTestDirectories, + runtime, + seedCompletedVendor, + vendor, +} from "./runtime.js"; + +afterEach(cleanupTestDirectories); + +describe("cleanup", () => { + it("cancels every state-owned active Monitor and reports failures independently", async () => { + const test = await runtime(); + const second = { name: "Second", domain: "second.example.com" }; + await seedCompletedVendor(test.store, { monitorId: "first-monitor" }); + await seedCompletedVendor(test.store, { inputVendor: second, monitorId: "second-monitor" }); + test.monitorCancel.mockImplementation(async (monitorId) => { + if (monitorId === "first-monitor") throw new Error("cannot cancel first"); + return snapshotMonitor(monitorId, "run-second.example.com", "cancelled"); + }); + test.monitorRetrieve.mockImplementation(async (monitorId) => + snapshotMonitor( + monitorId, + monitorId === "first-monitor" ? "run-example.com" : "run-second.example.com", + ), + ); + + const summary = await test.service.cleanup(); + expect(summary.monitors).toEqual([ + { + vendor: "example.com", + monitorId: "first-monitor", + status: "failed", + message: "cannot cancel first", + }, + { + vendor: "second.example.com", + monitorId: "second-monitor", + status: "cancelled", + }, + ]); + expect(test.monitorCancel).toHaveBeenCalledTimes(2); + const state = await test.store.read(); + expect(state.vendors[vendor.domain]?.monitor?.status).toBe("active"); + expect(state.vendors[second.domain]?.monitor?.status).toBe("cancelled"); + }); + + it("accepts a Monitor already cancelled remotely after cancel throws", async () => { + const test = await runtime(); + await seedCompletedVendor(test.store, { monitorId: "monitor-1" }); + test.monitorCancel.mockRejectedValue(new Error("already cancelled")); + test.monitorRetrieve.mockResolvedValue( + snapshotMonitor("monitor-1", "run-example.com", "cancelled"), + ); + const summary = await test.service.cleanup(); + expect(summary.monitors).toEqual([ + { + vendor: "example.com", + monitorId: "monitor-1", + status: "already_cancelled", + }, + ]); + expect((await test.store.read()).vendors[vendor.domain]?.monitor?.status).toBe("cancelled"); + }); + + it("reports locally cancelled Monitors consistently during cleanup-all", async () => { + const test = await runtime(); + await seedCompletedVendor(test.store, { + monitorId: "monitor-1", + monitorStatus: "cancelled", + }); + + const summary = await test.service.cleanup(); + expect(summary.monitors).toEqual([ + { + vendor: "example.com", + monitorId: "monitor-1", + status: "already_cancelled", + }, + ]); + expect(test.monitorCancel).not.toHaveBeenCalled(); + }); + + it("scopes cancellation to explicitly requested normalized vendors", async () => { + const test = await runtime(); + const second = { name: "Second", domain: "second.example.com" }; + await seedCompletedVendor(test.store, { monitorId: "first-monitor" }); + await seedCompletedVendor(test.store, { inputVendor: second, monitorId: "second-monitor" }); + test.monitorCancel.mockImplementation(async (monitorId) => + snapshotMonitor(monitorId, "run-second.example.com", "cancelled"), + ); + + const summary = await test.service.cleanup({ vendors: ["https://SECOND.example.com/path"] }); + expect(summary.scope).toEqual({ kind: "vendors", vendors: ["second.example.com"] }); + expect(summary.monitors).toEqual([ + { + vendor: "second.example.com", + monitorId: "second-monitor", + status: "cancelled", + }, + ]); + expect(test.monitorCancel).toHaveBeenCalledWith("second-monitor"); + expect(test.monitorCancel).not.toHaveBeenCalledWith("first-monitor"); + }); + + it("warns instead of calling the API for unknown or monitor-less scoped vendors", async () => { + const test = await runtime(); + await seedCompletedVendor(test.store); + const summary = await test.service.cleanup({ + vendors: ["missing.example.com", "example.com"], + }); + expect(summary.warnings.map(({ code }) => code)).toEqual(["unknown_vendor", "no_monitor"]); + expect(test.monitorCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/cli.test.ts b/typescript-recipes/parallel-vendor-intelligence/tests/cli.test.ts new file mode 100644 index 0000000..64f191c --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/cli.test.ts @@ -0,0 +1,76 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +const exec = promisify(execFile); +const recipeDirectory = resolve(); + +async function temporaryCliPackage(): Promise { + const directory = await mkdtemp(join(tmpdir(), "vendor-intelligence-cli-")); + await symlink(join(recipeDirectory, "node_modules"), join(directory, "node_modules"), "dir"); + await writeFile( + join(directory, "package.json"), + `${JSON.stringify( + { + private: true, + scripts: { + bootstrap: `${join(recipeDirectory, "node_modules/.bin/tsx")} ${join(recipeDirectory, "scripts/bootstrap.ts")}`, + "check-updates": `${join(recipeDirectory, "node_modules/.bin/tsx")} ${join(recipeDirectory, "scripts/check-updates.ts")}`, + cleanup: `${join(recipeDirectory, "node_modules/.bin/tsx")} ${join(recipeDirectory, "scripts/cleanup.ts")}`, + }, + }, + null, + 2, + )}\n`, + ); + return directory; +} + +describe("CLI output", () => { + it.each(["check-updates", "cleanup"])( + "npm run --silent %s writes exactly one JSON document to stdout", + async (command) => { + const directory = await temporaryCliPackage(); + try { + const { stdout } = await exec("npm", ["run", "--silent", command], { + cwd: directory, + env: { ...process.env, PARALLEL_API_KEY: "test-key" }, + }); + expect(() => JSON.parse(stdout)).not.toThrow(); + expect(JSON.stringify(JSON.parse(stdout), null, 2) + "\n").toBe(stdout); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); + + it("returns a nonzero status and keeps stdout empty for invalid bootstrap input", async () => { + const directory = await temporaryCliPackage(); + try { + const vendorsPath = join(directory, "vendors.json"); + await writeFile(vendorsPath, "[]\n"); + let failure: unknown; + try { + await exec( + "npm", + ["run", "--silent", "bootstrap", "--", "--vendors", vendorsPath], + { + cwd: directory, + env: { ...process.env, PARALLEL_API_KEY: "test-key" }, + }, + ); + } catch (error) { + failure = error; + } + + expect(failure).toMatchObject({ code: 1, stdout: "" }); + expect(failure).toMatchObject({ stderr: expect.stringContaining("at least one vendor") }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/config.test.ts b/typescript-recipes/parallel-vendor-intelligence/tests/config.test.ts new file mode 100644 index 0000000..6c7d06d --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/config.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { + configFromEnv, + createVendorIntelligenceFromEnv, +} from "../src/config.js"; +import { MonitorFrequencySchema } from "../src/vendor-config.js"; +import { fakeClient } from "./runtime.js"; + +describe("configuration", () => { + it("uses the two documented teaching defaults", () => { + expect(configFromEnv({})).toEqual({ + monitorFrequency: "1d", + followUpRiskThreshold: "HIGH", + }); + }); + + it.each(["1h", "12h", "1d", "30d", "4w"])( + "accepts an in-range Monitor frequency: %s", + (frequency) => { + expect(MonitorFrequencySchema.parse(frequency)).toBe(frequency); + }, + ); + + it.each(["0h", "31d", "5w", "daily", "1.5h"])( + "rejects an invalid Monitor frequency: %s", + (frequency) => { + expect(() => MonitorFrequencySchema.parse(frequency)).toThrow(); + }, + ); + + it("rejects an unknown follow-up threshold", () => { + expect(() => configFromEnv({ FOLLOW_UP_RISK_THRESHOLD: "SEVERE" })).toThrow(); + }); + + it("requires an API key when no test client is injected", () => { + expect(() => createVendorIntelligenceFromEnv({})).toThrow("PARALLEL_API_KEY"); + }); + + it("validates programmatic overrides after merging them with defaults", () => { + expect(() => + createVendorIntelligenceFromEnv( + {}, + { + client: fakeClient().client, + config: { taskResultMaxWaitMilliseconds: -1 }, + }, + ), + ).toThrow(); + }); + + it("lets a valid programmatic override replace an invalid environment value", () => { + expect(() => + createVendorIntelligenceFromEnv( + { MONITOR_FREQUENCY: "never" }, + { + client: fakeClient().client, + config: { monitorFrequency: "2d" }, + }, + ), + ).not.toThrow(); + }); +}); diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/fixtures.ts b/typescript-recipes/parallel-vendor-intelligence/tests/fixtures.ts new file mode 100644 index 0000000..15268c9 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/fixtures.ts @@ -0,0 +1,126 @@ +import type { + FieldBasis, + MonitorSnapshotEvent, + SnapshotMonitor, + TaskRun, + TaskRunResult, +} from "../src/parallel-port.js"; + +import { + RISK_DIMENSIONS, + type ChangeInvestigation, + type RiskLevel, + type VendorReport, +} from "../src/schema.js"; + +export function vendorReport( + levels: Partial> = {}, + adverseEvents: VendorReport["adverse_events"] = [], +): VendorReport { + return Object.fromEntries([ + ...RISK_DIMENSIONS.map(({ key }) => [ + key, + { + severity: levels[key] ?? "LOW", + summary: `${key} summary`, + findings: levels[key] && levels[key] !== "LOW" ? [`${key} finding`] : [], + }, + ]), + ["adverse_events", adverseEvents], + ]) as VendorReport; +} + +export function basis(field: string, url = `https://example.com/${field}`): FieldBasis { + return { + field, + reasoning: `${field} reasoning`, + confidence: "high", + citations: [{ url, title: `${field} source`, excerpts: [`${field} excerpt`] }], + }; +} + +export function taskRun(runId = "run-1"): TaskRun { + return { + run_id: runId, + interaction_id: `interaction-${runId}`, + status: "queued", + }; +} + +export function reportResult(report: VendorReport, runId = "run-1"): TaskRunResult { + return { + run: { ...taskRun(runId), status: "completed" }, + output: { + type: "json", + content: report, + basis: [basis("cybersecurity")], + }, + }; +} + +export const investigation: ChangeInvestigation = { + what_changed: "A material security event changed the assessment.", + confirmed_facts: ["The event is confirmed by public reporting."], + business_impact: "A human should assess exposure and contingency plans.", + open_questions: ["Does the incident affect shared data?"], +}; + +export function investigationResult(runId = "follow-1"): TaskRunResult { + return { + run: { ...taskRun(runId), status: "completed" }, + output: { + type: "json", + content: investigation, + basis: [basis("what_changed")], + }, + }; +} + +export function snapshotMonitor( + monitorId = "monitor-1", + baselineRunId = "run-1", + status: "active" | "cancelled" = "active", +): SnapshotMonitor { + return { + monitor_id: monitorId, + type: "snapshot", + status, + processor: "lite", + frequency: "1d", + created_at: "2026-07-09T00:00:00.000Z", + settings: { + query: "vendor query", + task_run_id: baselineRunId, + }, + metadata: { + recipe: "vendor-intel", + vendor: "example.com", + spec: "1", + }, + }; +} + +export function snapshotEvent(input: { + eventId: string; + previous: VendorReport; + changed: Partial; + previousBasis?: FieldBasis[]; + changedBasis?: FieldBasis[]; + eventDate?: string; +}): MonitorSnapshotEvent { + return { + event_id: input.eventId, + event_group_id: `group-${input.eventId}`, + event_date: input.eventDate ?? "2026-07-09", + previous_output: { + type: "json", + content: input.previous, + basis: input.previousBasis ?? [], + }, + changed_output: { + type: "json", + content: input.changed, + basis: input.changedBasis ?? [], + }, + }; +} diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/live.test.ts b/typescript-recipes/parallel-vendor-intelligence/tests/live.test.ts new file mode 100644 index 0000000..fbb6ff7 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/live.test.ts @@ -0,0 +1,226 @@ +import "dotenv/config"; + +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import Parallel from "parallel-web"; +import { describe, expect, it, vi } from "vitest"; + +import type { ParallelPort } from "../src/parallel-port.js"; +import { createParallelPort } from "../src/parallel-sdk-adapter.js"; +import { RECIPE_METADATA, SPEC_VERSION } from "../src/schema.js"; +import { emptyRecipeState, FileStateStore } from "../src/state.js"; +import { DEFAULT_CONFIG } from "../src/vendor-config.js"; +import { VendorIntelligence } from "../src/vendor-intelligence.js"; +import { snapshotMonitor, vendorReport } from "./fixtures.js"; +import { fakeClient } from "./runtime.js"; + +const liveTest = process.env.RUN_LIVE_TESTS === "1" ? it : it.skip; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function finalizeLiveTest(input: { + directory: string; + client: ParallelPort; + service: Pick; + store: Pick; + monitorIds: Set; +}): Promise { + const failures: Error[] = []; + const recordFailure = (operation: string, error: unknown) => { + failures.push(new Error(`${operation}: ${errorMessage(error)}`, { cause: error })); + }; + const stateOwnedIds = new Set(input.monitorIds); + + try { + try { + const cleanup = await input.service.cleanup(); + for (const monitorId of stateOwnedIds) { + const result = cleanup.monitors.find((monitor) => monitor.monitorId === monitorId); + if (!result || result.status === "failed") { + recordFailure( + `State-owned cleanup did not cancel Monitor ${monitorId}`, + result?.status === "failed" ? result.message : "Monitor was missing from the result", + ); + } + } + } catch (error) { + recordFailure("State-owned cleanup failed", error); + } + + try { + const state = await input.store.read(); + const baseline = state.vendors["cloudflare.com"]?.baseline; + if (baseline?.stage === "completed") { + let cursor: string | undefined; + const seenCursors = new Set(); + do { + const page = await input.client.monitor.list({ + limit: 100, + status: ["active"], + type: ["snapshot"], + ...(cursor ? { cursor } : {}), + }); + for (const monitor of page.monitors) { + if ( + monitor.type === "snapshot" && + monitor.settings.task_run_id === baseline.run.runId && + monitor.frequency === "30d" && + monitor.processor === "lite" && + monitor.metadata?.recipe === RECIPE_METADATA && + monitor.metadata.vendor === "cloudflare.com" && + monitor.metadata.spec === String(SPEC_VERSION) + ) { + input.monitorIds.add(monitor.monitor_id); + } + } + const next = page.next_cursor ?? undefined; + if (next && seenCursors.has(next)) throw new Error("Repeated live-test cursor."); + if (next) seenCursors.add(next); + cursor = next; + } while (cursor); + } + } catch (error) { + recordFailure("Fallback Monitor discovery failed", error); + } + + for (const monitorId of input.monitorIds) { + let cancellationError: unknown; + try { + await input.client.monitor.cancel(monitorId); + } catch (error) { + cancellationError = error; + } + + try { + const remote = await input.client.monitor.retrieve(monitorId); + if (remote.status !== "cancelled") { + throw new Error(`Monitor remained ${remote.status}.`); + } + console.error(`Confirmed cancelled Monitor ${monitorId}.`); + } catch (error) { + recordFailure( + `Could not confirm cancellation of Monitor ${monitorId}`, + cancellationError && error !== cancellationError + ? new AggregateError([cancellationError, error], "Cancellation was not confirmed.") + : error, + ); + } + } + } finally { + try { + await rm(input.directory, { recursive: true, force: true }); + } catch (error) { + recordFailure("Temporary-directory cleanup failed", error); + } + } + + if (failures.length > 0) { + throw new AggregateError(failures, "The live test could not complete all cleanup steps."); + } +} + +describe("live-test cleanup", () => { + it("still cancels known Monitors when discovery fails and continues after one ID fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "vendor-intelligence-finalizer-")); + const fake = fakeClient(); + const cancelled = new Set(); + fake.monitorList.mockRejectedValue(new Error("list unavailable")); + fake.monitorCancel.mockImplementation(async (monitorId) => { + if (monitorId === "unreachable") throw new Error("cancel unavailable"); + cancelled.add(monitorId); + return snapshotMonitor(monitorId, "baseline-live", "cancelled"); + }); + fake.monitorRetrieve.mockImplementation(async (monitorId) => { + if (monitorId === "unreachable") throw new Error("retrieve unavailable"); + return snapshotMonitor( + monitorId, + "baseline-live", + cancelled.has(monitorId) ? "cancelled" : "active", + ); + }); + const state = emptyRecipeState(); + state.vendors["cloudflare.com"] = { + vendor: { name: "Cloudflare", domain: "cloudflare.com" }, + baseline: { + stage: "completed", + run: { runId: "baseline-live" }, + failedAttempts: [], + evidence: { + report: vendorReport(), + basis: [], + observedAt: "2026-07-09T00:00:00.000Z", + warnings: [], + }, + }, + events: {}, + }; + + await expect( + finalizeLiveTest({ + directory, + client: fake.client, + service: { cleanup: vi.fn().mockRejectedValue(new Error("cleanup unavailable")) }, + store: { read: vi.fn().mockResolvedValue(state) }, + monitorIds: new Set(["unreachable", "known"]), + }), + ).rejects.toThrow("could not complete all cleanup steps"); + + expect(fake.monitorList).toHaveBeenCalledOnce(); + expect(fake.monitorCancel).toHaveBeenCalledWith("unreachable"); + expect(fake.monitorCancel).toHaveBeenCalledWith("known"); + await expect(stat(directory)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); + +describe("live production lifecycle", () => { + liveTest( + "bootstraps, checks, and confirms state-owned Monitor cancellation", + async () => { + const apiKey = process.env.PARALLEL_API_KEY; + if (!apiKey) throw new Error("PARALLEL_API_KEY is required for the live test."); + + const directory = await mkdtemp(join(tmpdir(), "vendor-intelligence-live-")); + const production = createParallelPort(new Parallel({ apiKey, timeout: 60_000 })); + const monitorIds = new Set(); + const client = { + ...production, + monitor: { + ...production.monitor, + async create(params, options) { + const monitor = await production.monitor.create(params, options); + monitorIds.add(monitor.monitor_id); + return monitor; + }, + }, + } satisfies ParallelPort; + const store = new FileStateStore(directory); + const service = new VendorIntelligence({ + client, + store, + config: { ...DEFAULT_CONFIG, monitorFrequency: "30d" }, + }); + + try { + const bootstrap = await service.bootstrap([ + { name: "Cloudflare", domain: "cloudflare.com" }, + ]); + const monitorId = bootstrap.results[0]?.monitor.monitorId; + if (monitorId) monitorIds.add(monitorId); + expect(bootstrap.results).toHaveLength(1); + expect(bootstrap.results[0]?.assessment.basis.length).toBeGreaterThan(0); + expect(monitorId).toBeTruthy(); + + const check = await service.checkForUpdates(); + expect(check.monitorsChecked).toBe(1); + expect(check.errors).toEqual([]); + } finally { + await finalizeLiveTest({ directory, client, service, store, monitorIds }); + } + }, + 20 * 60 * 1_000, + ); +}); diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/parallel-sdk-adapter.test.ts b/typescript-recipes/parallel-vendor-intelligence/tests/parallel-sdk-adapter.test.ts new file mode 100644 index 0000000..3f3f899 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/parallel-sdk-adapter.test.ts @@ -0,0 +1,412 @@ +import Parallel from "parallel-web"; +import type { + Monitor as SdkMonitor, + MonitorCompletionEvent as SdkMonitorCompletionEvent, + MonitorErrorEvent as SdkMonitorErrorEvent, + MonitorEventStreamEvent as SdkMonitorEventStreamEvent, + MonitorSnapshotEvent as SdkMonitorSnapshotEvent, + PaginatedMonitorEvents as SdkPaginatedMonitorEvents, + PaginatedMonitorResponse as SdkPaginatedMonitorResponse, +} from "parallel-web/resources/monitor"; +import type { + TaskRun as SdkTaskRun, + TaskRunJsonOutput as SdkTaskRunJsonOutput, + TaskRunResult as SdkTaskRunResult, + TaskRunTextOutput as SdkTaskRunTextOutput, +} from "parallel-web/resources/task-run"; +import { describe, expect, it, vi } from "vitest"; + +import type { + EventStreamMonitor, + MonitorCreateParams, + MonitorEventsParams, + MonitorListParams, + RequestOptions, + SnapshotMonitor, + TaskRunCreateParams, + TaskRunJsonOutput, + TaskRunResultParams, + TaskRunTextOutput, +} from "../src/parallel-port.js"; +import { createParallelPort } from "../src/parallel-sdk-adapter.js"; + +const CREATED_AT = "2026-07-09T12:00:00.000Z"; + +function sdkTaskRun( + runId: string, + status: SdkTaskRun["status"], +): SdkTaskRun { + return Object.assign(new Parallel({ apiKey: "fixture-api-key" }).taskRun, { + created_at: CREATED_AT, + interaction_id: `interaction-${runId}`, + is_active: status === "queued" || status === "running" || status === "cancelling", + modified_at: CREATED_AT, + processor: "base", + run_id: runId, + status, + }); +} + +function sdkJsonOutput(): SdkTaskRunJsonOutput { + return { + type: "json", + content: { risk_level: "high" }, + basis: [ + { + field: "risk_level", + reasoning: "A regulator published an enforcement action.", + confidence: "high", + citations: [ + { + url: "https://regulator.example/enforcement", + title: "Enforcement action", + excerpts: ["The action became effective today."], + }, + { + url: "https://vendor.example/update", + title: null, + excerpts: null, + }, + ], + }, + ], + }; +} + +function sdkTextOutput(): SdkTaskRunTextOutput { + return { + type: "text", + content: "A new material event was detected.", + basis: [ + { + field: "output", + reasoning: "The announcement is new.", + citations: [{ url: "https://vendor.example/announcement" }], + }, + ], + }; +} + +function sdkSnapshotMonitor( + monitorId = "monitor-snapshot", + status: SdkMonitor["status"] = "active", +): SdkMonitor { + return { + created_at: CREATED_AT, + frequency: "1d", + monitor_id: monitorId, + processor: "lite", + settings: { + task_run_id: "run-baseline", + query: "Track material vendor-risk changes.", + }, + status, + type: "snapshot", + metadata: { recipe: "vendor-intel" }, + }; +} + +function sdkEventStreamMonitor( + monitorId = "monitor-stream", + status: SdkMonitor["status"] = "active", +): SdkMonitor { + return { + created_at: CREATED_AT, + frequency: "2d", + monitor_id: monitorId, + processor: "base", + settings: { query: "Track vendor announcements." }, + status, + type: "event_stream", + metadata: null, + }; +} + +const mappedJsonOutput = { + type: "json", + content: { risk_level: "high" }, + basis: [ + { + field: "risk_level", + reasoning: "A regulator published an enforcement action.", + confidence: "high", + citations: [ + { + url: "https://regulator.example/enforcement", + title: "Enforcement action", + excerpts: ["The action became effective today."], + }, + { + url: "https://vendor.example/update", + title: null, + excerpts: null, + }, + ], + }, + ], +} satisfies TaskRunJsonOutput; + +const mappedTextOutput = { + type: "text", + content: "A new material event was detected.", + basis: [ + { + field: "output", + reasoning: "The announcement is new.", + citations: [{ url: "https://vendor.example/announcement" }], + }, + ], +} satisfies TaskRunTextOutput; + +function mappedSnapshotMonitor( + status: SnapshotMonitor["status"] = "active", +): SnapshotMonitor { + return { + monitor_id: "monitor-snapshot", + status, + processor: "lite", + frequency: "1d", + created_at: CREATED_AT, + metadata: { recipe: "vendor-intel" }, + type: "snapshot", + settings: { + task_run_id: "run-baseline", + query: "Track material vendor-risk changes.", + }, + }; +} + +const mappedEventStreamMonitor = { + monitor_id: "monitor-stream", + status: "active", + processor: "base", + frequency: "2d", + created_at: CREATED_AT, + type: "event_stream", + settings: { query: "Track vendor announcements." }, +} satisfies EventStreamMonitor; + +describe("createParallelPort", () => { + it("forwards Task requests and maps runs, errors, warnings, and field basis", async () => { + const client = new Parallel({ apiKey: "test-api-key" }); + const createdRun = Object.assign(sdkTaskRun("run-created", "failed"), { + error: { message: "The Task could not complete.", ref_id: "error-ref" }, + warnings: [ + { + message: "One requested field was ambiguous.", + type: "spec_validation_warning" as const, + }, + ], + }) satisfies SdkTaskRun; + const retrievedRun = sdkTaskRun("run-retrieved", "running"); + const result = { + run: sdkTaskRun("run-result", "completed"), + output: sdkJsonOutput(), + } satisfies SdkTaskRunResult; + const create = vi.spyOn(client.taskRun, "create").mockResolvedValue(createdRun); + const retrieve = vi + .spyOn(client.taskRun, "retrieve") + .mockResolvedValue(retrievedRun); + const taskResult = vi.spyOn(client.taskRun, "result").mockResolvedValue(result); + const port = createParallelPort(client); + const createParams = { + input: { vendor: "Example", domain: "example.com" }, + processor: "base", + task_spec: { + output_schema: { + type: "json", + json_schema: { type: "object" }, + }, + }, + metadata: { recipe: "vendor-intel", attempt: 1 }, + previous_interaction_id: "interaction-prior", + } satisfies TaskRunCreateParams; + const resultParams = { timeout: 60 } satisfies TaskRunResultParams; + const requestOptions = { + maxRetries: 1, + timeout: 5_000, + } satisfies RequestOptions; + const createOptions = { maxRetries: 0 } satisfies RequestOptions; + + await expect(port.taskRun.create(createParams, createOptions)).resolves.toEqual({ + run_id: "run-created", + interaction_id: "interaction-run-created", + status: "failed", + error: { + message: "The Task could not complete.", + ref_id: "error-ref", + }, + warnings: [{ message: "One requested field was ambiguous." }], + }); + await expect(port.taskRun.retrieve("run-retrieved")).resolves.toEqual({ + run_id: "run-retrieved", + interaction_id: "interaction-run-retrieved", + status: "running", + }); + await expect( + port.taskRun.result("run-result", resultParams, requestOptions), + ).resolves.toEqual({ + run: { + run_id: "run-result", + interaction_id: "interaction-run-result", + status: "completed", + }, + output: mappedJsonOutput, + }); + + expect(create).toHaveBeenCalledOnce(); + expect(create).toHaveBeenCalledWith(createParams, createOptions); + expect(retrieve).toHaveBeenCalledOnce(); + expect(retrieve).toHaveBeenCalledWith("run-retrieved"); + expect(taskResult).toHaveBeenCalledOnce(); + expect(taskResult).toHaveBeenCalledWith( + "run-result", + resultParams, + requestOptions, + ); + }); + + it("forwards Monitor requests and maps monitors, every event variant, and pagination", async () => { + const client = new Parallel({ apiKey: "test-api-key" }); + const snapshot = sdkSnapshotMonitor(); + const stream = sdkEventStreamMonitor(); + const cancelled = sdkSnapshotMonitor("monitor-snapshot", "cancelled"); + const monitorPage = { + monitors: [snapshot, stream], + next_cursor: "monitor-cursor", + } satisfies SdkPaginatedMonitorResponse; + const snapshotEvent = { + event_id: "event-snapshot", + event_group_id: "group-snapshot", + event_date: "2026-07-09", + previous_output: sdkJsonOutput(), + changed_output: sdkTextOutput(), + event_type: "snapshot", + } satisfies SdkMonitorSnapshotEvent; + const streamEvent = { + event_id: "event-stream", + event_group_id: "group-stream", + event_date: null, + output: sdkTextOutput(), + event_type: "event_stream", + } satisfies SdkMonitorEventStreamEvent; + const errorEvent = { + error_message: "The scheduled execution exceeded quota.", + timestamp: "2026-07-09T13:00:00.000Z", + event_type: "error", + } satisfies SdkMonitorErrorEvent; + const completionEvent = { + timestamp: "2026-07-09T14:00:00.000Z", + event_type: "completion", + } satisfies SdkMonitorCompletionEvent; + const eventPage = { + events: [snapshotEvent, streamEvent, errorEvent, completionEvent], + next_cursor: "event-cursor", + warnings: [{ message: "Results were truncated.", type: "warning" }], + } satisfies SdkPaginatedMonitorEvents; + const create = vi.spyOn(client.monitor, "create").mockResolvedValue(snapshot); + const retrieve = vi.spyOn(client.monitor, "retrieve").mockResolvedValue(stream); + const list = vi.spyOn(client.monitor, "list").mockResolvedValue(monitorPage); + const events = vi.spyOn(client.monitor, "events").mockResolvedValue(eventPage); + const cancel = vi.spyOn(client.monitor, "cancel").mockResolvedValue(cancelled); + const port = createParallelPort(client); + const createParams = { + type: "snapshot", + frequency: "1d", + processor: "lite", + settings: { task_run_id: "run-baseline" }, + metadata: { recipe: "vendor-intel" }, + } satisfies MonitorCreateParams; + const listParams = { + cursor: "monitor-before", + limit: 20, + status: ["active", "cancelled"], + type: ["snapshot", "event_stream"], + } satisfies MonitorListParams; + const eventParams = { + cursor: "event-before", + event_group_id: "group-filter", + include_completions: true, + limit: 50, + } satisfies MonitorEventsParams; + const createOptions = { maxRetries: 0 } satisfies RequestOptions; + + await expect(port.monitor.create(createParams, createOptions)).resolves.toEqual( + mappedSnapshotMonitor(), + ); + await expect(port.monitor.retrieve("monitor-stream")).resolves.toEqual( + mappedEventStreamMonitor, + ); + await expect(port.monitor.list(listParams)).resolves.toEqual({ + monitors: [mappedSnapshotMonitor(), mappedEventStreamMonitor], + next_cursor: "monitor-cursor", + }); + await expect( + port.monitor.events("monitor-snapshot", eventParams), + ).resolves.toEqual({ + events: [ + { + event_id: "event-snapshot", + event_group_id: "group-snapshot", + event_date: "2026-07-09", + previous_output: mappedJsonOutput, + changed_output: mappedTextOutput, + event_type: "snapshot", + }, + { + event_id: "event-stream", + event_group_id: "group-stream", + event_date: null, + output: mappedTextOutput, + event_type: "event_stream", + }, + { + error_message: "The scheduled execution exceeded quota.", + timestamp: "2026-07-09T13:00:00.000Z", + event_type: "error", + }, + { + timestamp: "2026-07-09T14:00:00.000Z", + event_type: "completion", + }, + ], + next_cursor: "event-cursor", + warnings: [{ message: "Results were truncated." }], + }); + await expect(port.monitor.cancel("monitor-snapshot")).resolves.toEqual( + mappedSnapshotMonitor("cancelled"), + ); + + expect(create).toHaveBeenCalledOnce(); + expect(create).toHaveBeenCalledWith(createParams, createOptions); + expect(retrieve).toHaveBeenCalledOnce(); + expect(retrieve).toHaveBeenCalledWith("monitor-stream"); + expect(list).toHaveBeenCalledOnce(); + expect(list).toHaveBeenCalledWith(listParams); + expect(events).toHaveBeenCalledOnce(); + expect(events).toHaveBeenCalledWith("monitor-snapshot", eventParams); + expect(cancel).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledWith("monitor-snapshot"); + }); + + it("rejects an event-stream response from snapshot Monitor creation", async () => { + const client = new Parallel({ apiKey: "test-api-key" }); + const create = vi + .spyOn(client.monitor, "create") + .mockResolvedValue(sdkEventStreamMonitor("unexpected-stream")); + const port = createParallelPort(client); + const createParams = { + type: "snapshot", + frequency: "1d", + processor: "lite", + settings: { task_run_id: "run-baseline" }, + metadata: { recipe: "vendor-intel" }, + } satisfies MonitorCreateParams; + + await expect(port.monitor.create(createParams)).rejects.toThrow( + "Expected a snapshot Monitor, but unexpected-stream is event_stream.", + ); + expect(create).toHaveBeenCalledOnce(); + expect(create).toHaveBeenCalledWith(createParams); + }); +}); diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/risk-policy.test.ts b/typescript-recipes/parallel-vendor-intelligence/tests/risk-policy.test.ts new file mode 100644 index 0000000..843c232 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/risk-policy.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vitest"; + +import { decideFollowUp, scoreReport } from "../src/risk-policy.js"; +import { basis, vendorReport } from "./fixtures.js"; + +describe("scoreReport", () => { + it("keeps an all-low report low", () => { + const assessment = scoreReport(vendorReport()); + expect(assessment).toMatchObject({ + level: "LOW", + evidenceLevel: "LOW", + adverseDetected: false, + requiresHumanReview: false, + guidance: "continue_monitoring", + }); + }); + + it("lets every dimension drive aggregate risk, including operational resilience", () => { + expect(scoreReport(vendorReport({ operational_resilience: "CRITICAL" })).level).toBe( + "CRITICAL", + ); + }); + + it("uses adverse-event severity as an aggregate floor", () => { + const assessment = scoreReport( + vendorReport({}, [ + { + category: "breach", + severity: "HIGH", + title: "Confirmed breach", + summary: "Customer data may be affected.", + }, + ]), + ); + expect(assessment.level).toBe("HIGH"); + expect(assessment.adverseDetected).toBe(true); + }); + + it("requires analyst review for a medium adverse event", () => { + const assessment = scoreReport( + vendorReport({}, [ + { + category: "operations", + severity: "MEDIUM", + title: "Regional service disruption", + summary: "A limited disruption may affect delivery.", + }, + ]), + ); + expect(assessment).toMatchObject({ + level: "MEDIUM", + adverseDetected: true, + requiresHumanReview: true, + guidance: "analyst_review", + }); + }); + + it("does not call several medium dimensions an adverse event", () => { + const assessment = scoreReport( + vendorReport({ + financial_health: "MEDIUM", + legal_regulatory: "MEDIUM", + cybersecurity: "MEDIUM", + }), + ); + expect(assessment.level).toBe("MEDIUM"); + expect(assessment.adverseDetected).toBe(false); + }); + + it("applies a vendor floor without fabricating evidence", () => { + const assessment = scoreReport(vendorReport(), "HIGH", [basis("cybersecurity")]); + expect(assessment.level).toBe("HIGH"); + expect(assessment.evidenceLevel).toBe("LOW"); + expect(assessment.citations).toEqual([]); + }); + + it("preserves field attribution while deduplicating the same field and URL", () => { + const assessment = scoreReport( + vendorReport({ cybersecurity: "HIGH", financial_health: "MEDIUM" }), + undefined, + [ + basis("cybersecurity", "https://source.test/item"), + basis("cybersecurity", "https://source.test/item"), + basis("financial_health", "https://source.test/item"), + ], + ); + expect(assessment.citations).toEqual([ + expect.objectContaining({ field: "cybersecurity", url: "https://source.test/item" }), + expect.objectContaining({ field: "financial_health", url: "https://source.test/item" }), + ]); + }); + + it("attributes nested Task basis paths to their top-level risk fields", () => { + const report = vendorReport( + { cybersecurity: "HIGH" }, + [ + { + category: "breach", + severity: "MEDIUM", + title: "Incident", + summary: "A confirmed incident occurred.", + }, + ], + ); + const assessment = scoreReport(report, undefined, [ + basis("cybersecurity.summary", "https://source.test/security"), + basis("adverse_events.0.summary", "https://source.test/incident"), + ]); + + expect(assessment.citations).toEqual([ + expect.objectContaining({ + field: "cybersecurity", + url: "https://source.test/security", + }), + expect.objectContaining({ + field: "adverse_events", + url: "https://source.test/incident", + }), + ]); + }); +}); + +describe("decideFollowUp", () => { + it("does not investigate an unrelated low change beside an unchanged high field", () => { + const report = vendorReport({ cybersecurity: "HIGH" }); + expect( + decideFollowUp({ + previousReport: report, + currentReport: { + ...report, + financial_health: { ...report.financial_health, summary: "New low detail" }, + }, + changedFields: ["financial_health"], + threshold: "HIGH", + }).runFollowUp, + ).toBe(false); + }); + + it("investigates both escalation into and resolution from a high level", () => { + const low = vendorReport(); + const high = vendorReport({ cybersecurity: "HIGH" }); + expect( + decideFollowUp({ + previousReport: low, + currentReport: high, + changedFields: ["cybersecurity"], + threshold: "HIGH", + }).runFollowUp, + ).toBe(true); + expect( + decideFollowUp({ + previousReport: high, + currentReport: low, + changedFields: ["cybersecurity"], + threshold: "HIGH", + }).runFollowUp, + ).toBe(true); + }); + + it("ignores an unchanged high adverse event when only a low event is added", () => { + const existing = { + category: "breach", + severity: "HIGH" as const, + title: "Historical breach", + summary: "Previously known.", + }; + const previous = vendorReport({}, [existing]); + const current = vendorReport({}, [ + existing, + { + category: "reputation", + severity: "LOW", + title: "Minor complaint", + summary: "Low-signal complaint.", + }, + ]); + expect( + decideFollowUp({ + previousReport: previous, + currentReport: current, + changedFields: ["adverse_events"], + threshold: "HIGH", + }).runFollowUp, + ).toBe(false); + }); + + it("uses a configured high vendor floor for any changed risk field", () => { + const report = vendorReport(); + expect( + decideFollowUp({ + previousReport: report, + currentReport: report, + changedFields: ["financial_health"], + threshold: "HIGH", + riskFloor: "HIGH", + }).reasons, + ).toContainEqual({ kind: "vendor_floor", level: "HIGH" }); + }); + + it("represents a high adverse-event resolution as one before-and-after reason", () => { + const previous = vendorReport({}, [ + { + category: "breach", + severity: "HIGH", + title: "Security incident", + summary: "The incident is under investigation.", + event_date: "2026-07-01", + }, + ]); + const current = vendorReport({}, [ + { + category: "breach", + severity: "LOW", + title: "Security incident", + summary: "The incident was contained.", + event_date: "2026-07-01", + }, + ]); + const decision = decideFollowUp({ + previousReport: previous, + currentReport: current, + changedFields: ["adverse_events"], + threshold: "HIGH", + }); + expect(decision.reasons).toEqual([ + { + kind: "changed_adverse_event", + title: "Security incident", + previousLevel: "HIGH", + currentLevel: "LOW", + }, + ]); + expect(decision.requiresHumanReview).toBe(true); + }); + + it("rejects unknown changed fields instead of silently discarding them", () => { + const report = vendorReport(); + expect(() => + decideFollowUp({ + previousReport: report, + currentReport: report, + changedFields: ["unknown_field"], + threshold: "HIGH", + }), + ).toThrow(); + }); +}); diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/runtime.ts b/typescript-recipes/parallel-vendor-intelligence/tests/runtime.ts new file mode 100644 index 0000000..826fcc1 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/runtime.ts @@ -0,0 +1,153 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { vi } from "vitest"; + +import type { ParallelPort } from "../src/parallel-port.js"; +import type { Vendor, VendorReport } from "../src/schema.js"; +import { FileStateStore } from "../src/state.js"; +import { + VendorIntelligence, +} from "../src/vendor-intelligence.js"; +import { + DEFAULT_CONFIG, + type VendorIntelligenceConfig, +} from "../src/vendor-config.js"; +import { + investigationResult, + reportResult, + snapshotMonitor, + taskRun, + vendorReport, +} from "./fixtures.js"; + +export const vendor: Vendor = { name: "Example", domain: "example.com" }; +export const fixedNow = new Date("2026-07-09T12:00:00.000Z"); + +const directories: string[] = []; + +export function fakeClient() { + const taskCreate = vi.fn(async () => taskRun()); + const taskRetrieve = vi.fn(async (runId) => + taskRun(runId), + ); + const taskResult = vi.fn(async () => + reportResult(vendorReport()), + ); + const monitorCreate = vi.fn(async (params) => + snapshotMonitor("monitor-1", params.settings.task_run_id), + ); + const monitorRetrieve = vi.fn(async () => + snapshotMonitor(), + ); + const monitorList = vi.fn(async () => ({ + monitors: [], + })); + const monitorEvents = vi.fn(async () => ({ + events: [], + })); + const monitorCancel = vi.fn(async (id) => + snapshotMonitor(id, "run-1", "cancelled"), + ); + const client = { + taskRun: { create: taskCreate, retrieve: taskRetrieve, result: taskResult }, + monitor: { + create: monitorCreate, + retrieve: monitorRetrieve, + list: monitorList, + events: monitorEvents, + cancel: monitorCancel, + }, + } satisfies ParallelPort; + return { + client, + taskCreate, + taskRetrieve, + taskResult, + monitorCreate, + monitorRetrieve, + monitorList, + monitorEvents, + monitorCancel, + }; +} + +export async function runtime( + fake = fakeClient(), + config: Partial = {}, +) { + const directory = await mkdtemp(join(tmpdir(), "vendor-intelligence-runtime-")); + directories.push(directory); + const store = new FileStateStore(directory); + const reporter = vi.fn<(message: string) => void>(); + const service = new VendorIntelligence({ + client: fake.client, + store, + config: { ...DEFAULT_CONFIG, ...config }, + now: () => fixedNow, + sleep: async () => {}, + reporter, + }); + return { ...fake, directory, store, service, reporter }; +} + +export async function seedCompletedVendor( + store: FileStateStore, + options: { + inputVendor?: Vendor; + report?: VendorReport; + monitorId?: string; + monitorStatus?: "active" | "cancelled"; + } = {}, +): Promise { + const inputVendor = options.inputVendor ?? vendor; + const report = options.report ?? vendorReport(); + const observedAt = fixedNow.toISOString(); + await store.update((state) => { + state.vendors[inputVendor.domain] = { + vendor: inputVendor, + baseline: { + stage: "completed", + run: { + runId: `run-${inputVendor.domain}`, + interactionId: `interaction-run-${inputVendor.domain}`, + startedAt: observedAt, + }, + failedAttempts: [], + evidence: { report, basis: [], observedAt, warnings: [] }, + }, + monitor: options.monitorId + ? options.monitorStatus === "cancelled" + ? { + status: "cancelled", + monitorId: options.monitorId, + baselineRunId: `run-${inputVendor.domain}`, + frequency: "1d", + processor: "lite", + createdAt: observedAt, + cancelledAt: observedAt, + } + : { + status: "active", + monitorId: options.monitorId, + baselineRunId: `run-${inputVendor.domain}`, + frequency: "1d", + processor: "lite", + createdAt: observedAt, + } + : undefined, + events: {}, + }; + }); +} + +export async function cleanupTestDirectories(): Promise { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +} + +export function completedInvestigationResult(runId = "follow-1") { + return investigationResult(runId); +} diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/schema.test.ts b/typescript-recipes/parallel-vendor-intelligence/tests/schema.test.ts new file mode 100644 index 0000000..141dba7 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/schema.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; + +import { + ChangeInvestigationSchema, + buildBaselineTaskParams, + buildChangeInvestigationTaskParams, + normalizeVendorDomain, + RISK_DIMENSIONS, + VENDOR_REPORT_OUTPUT_SCHEMA, + VendorReportSchema, + VendorSchema, +} from "../src/schema.js"; + +const dimension = { + severity: "LOW" as const, + summary: "No material current risk found.", + findings: [], +}; + +const validReport = { + financial_health: dimension, + legal_regulatory: dimension, + operational_resilience: dimension, + leadership_governance: dimension, + esg_reputation: dimension, + cybersecurity: dimension, + adverse_events: [], +}; + +describe("vendor domain normalization", () => { + it.each([ + ["Example.COM", "example.com"], + ["https://Example.COM/path?query=1", "example.com"], + ["http://sub.example.com./", "sub.example.com"], + ])("normalizes %s", (input, expected) => { + expect(normalizeVendorDomain(input)).toBe(expected); + }); + + it.each([ + "", + "localhost", + "127.0.0.1", + "https://10.0.0.1", + "foo..com", + "-bad.example.com", + "mailto:test@example.com", + "https://user:pass@example.com", + ])( + "rejects unsupported input %s", + (input) => { + expect(() => normalizeVendorDomain(input)).toThrow(); + }, + ); +}); + +describe("vendor assessment contract", () => { + it("exposes six flat dimensions plus adverse events", () => { + expect(RISK_DIMENSIONS.map(({ key }) => key)).toEqual([ + "financial_health", + "legal_regulatory", + "operational_resilience", + "leadership_governance", + "esg_reputation", + "cybersecurity", + ]); + expect(VendorReportSchema.parse(validReport)).toEqual(validReport); + }); + + it("rejects prototype-only and volatile fields", () => { + expect(() => + VendorReportSchema.parse({ + ...validReport, + assessment_date: "2026-07-09", + }), + ).toThrow(); + expect(() => + VendorReportSchema.parse({ + dimensions: validReport, + adverse_events: [], + }), + ).toThrow(); + expect(() => + VendorReportSchema.parse({ + ...validReport, + cybersecurity: { + ...dimension, + status: "LOW", + }, + }), + ).toThrow(); + }); + + it("generates a top-level JSON schema for Task field basis", () => { + const jsonSchema = VENDOR_REPORT_OUTPUT_SCHEMA.json_schema as { + properties?: Record; + required?: string[]; + additionalProperties?: boolean; + }; + + expect(Object.keys(jsonSchema.properties ?? {})).toEqual([ + ...RISK_DIMENSIONS.map(({ key }) => key), + "adverse_events", + ]); + expect(jsonSchema.required).toEqual([ + ...RISK_DIMENSIONS.map(({ key }) => key), + "adverse_events", + ]); + expect(jsonSchema.additionalProperties).toBe(false); + }); + + it("builds a stable baseline Task without current-time input", () => { + const vendor = VendorSchema.parse({ + name: "Example", + domain: "https://EXAMPLE.com/path", + riskFloor: "MEDIUM", + }); + const params = buildBaselineTaskParams(vendor, "core"); + + expect(params.input).toMatchObject({ + vendor_name: "Example", + vendor_domain: "example.com", + }); + expect(params.processor).toBe("core"); + expect(JSON.stringify(params)).not.toContain("assessment_date"); + }); + + it("rejects model-owned human guidance from focused follow-ups", () => { + expect(() => + ChangeInvestigationSchema.parse({ + what_changed: "A change", + confirmed_facts: [], + business_impact: "Impact", + open_questions: [], + recommended_human_action: "urgent_human_review", + }), + ).toThrow(); + }); + + it("rejects unknown changed fields before building focused research input", () => { + expect(() => + Reflect.apply(buildChangeInvestigationTaskParams, undefined, [ + { + vendor: { name: "Example", domain: "example.com" }, + eventId: "event-1", + changedFields: ["unknown_field"], + previousReport: validReport, + currentReport: validReport, + policyDecision: { + threshold: "HIGH", + previousLevel: "LOW", + currentLevel: "LOW", + requiresHumanReview: false, + reasons: [], + }, + processor: "pro", + }, + ]), + ).toThrow(); + }); +}); diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/snapshot-events.test.ts b/typescript-recipes/parallel-vendor-intelligence/tests/snapshot-events.test.ts new file mode 100644 index 0000000..5f7ffdb --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/snapshot-events.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; + +import { + InvalidSnapshotEventError, + reconstructSnapshotEvent, +} from "../src/snapshot-events.js"; +import { basis, vendorReport } from "./fixtures.js"; + +describe("reconstructSnapshotEvent", () => { + it("applies the live empty-previous payload to a complete fallback snapshot", () => { + const previous = vendorReport(); + const financialHealth = { + ...previous.financial_health, + summary: "Liquidity weakened during the latest quarter.", + }; + + const reconstructed = reconstructSnapshotEvent( + { + event_id: "mevt_bb390d1f4512e6a4a54b0cecdce63bda8261f8d980f4139180f41391", + event_group_id: "live-empty-previous", + event_date: "2026-07-14", + previous_output: { type: "json", content: {}, basis: [] }, + changed_output: { + type: "json", + content: { financial_health: financialHealth }, + basis: [basis("financial_health", "https://current.test/financial")], + }, + }, + { + report: previous, + basis: [basis("financial_health", "https://old.test/financial")], + }, + ); + + expect(reconstructed.previousReport).toEqual(previous); + expect(reconstructed.currentReport.financial_health).toEqual(financialHealth); + expect(reconstructed.currentBasis).toEqual([ + basis("financial_health", "https://current.test/financial"), + ]); + }); + + it("prefers a complete API predecessor over a supplied fallback", () => { + const apiPrevious = vendorReport({ cybersecurity: "MEDIUM" }); + const fallback = vendorReport({ cybersecurity: "HIGH" }); + const reconstructed = reconstructSnapshotEvent( + { + event_id: "api-rebase", + event_group_id: "group-api-rebase", + event_date: "2026-07-14", + previous_output: { type: "json", content: apiPrevious, basis: [] }, + changed_output: { + type: "json", + content: { + financial_health: { + ...apiPrevious.financial_health, + summary: "API predecessor won.", + }, + }, + basis: [], + }, + }, + { report: fallback, basis: [basis("cybersecurity")] }, + ); + + expect(reconstructed.previousReport.cybersecurity.severity).toBe("MEDIUM"); + expect(reconstructed.currentReport.cybersecurity.severity).toBe("MEDIUM"); + }); + + it.each([ + ["unknown changed field", vendorReport(), { unknown_field: true }], + ["non-empty malformed predecessor", { financial_health: {} }, { + financial_health: vendorReport().financial_health, + }], + ])("rejects an %s even when a fallback exists", (_name, previousContent, changed) => { + expect(() => + reconstructSnapshotEvent( + { + event_id: "strict-validation", + event_group_id: "group-strict-validation", + event_date: "2026-07-14", + previous_output: { type: "json", content: previousContent, basis: [] }, + changed_output: { type: "json", content: changed, basis: [] }, + }, + { report: vendorReport(), basis: [] }, + ), + ).toThrow(InvalidSnapshotEventError); + }); + + it("removes stale bracket-notation basis for every changed top-level field", () => { + const previous = vendorReport( + { cybersecurity: "HIGH" }, + [ + { + category: "breach", + severity: "HIGH", + title: "Resolved incident", + summary: "The incident was under investigation.", + }, + ], + ); + + const reconstructed = reconstructSnapshotEvent({ + event_id: "resolved", + event_group_id: "group-resolved", + event_date: "2026-07-09", + previous_output: { + type: "json", + content: previous, + basis: [ + basis("cybersecurity[summary]", "https://old.test/security"), + basis("adverse_events[0].summary", "https://old.test/incident"), + basis("financial_health.summary", "https://current.test/financial"), + ], + }, + changed_output: { + type: "json", + content: { + cybersecurity: { + ...previous.cybersecurity, + severity: "LOW", + summary: "The incident was resolved.", + }, + adverse_events: [], + }, + basis: [], + }, + }); + + expect(reconstructed.currentBasis).toEqual([ + basis("financial_health.summary", "https://current.test/financial"), + ]); + }); +}); diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/state.test.ts b/typescript-recipes/parallel-vendor-intelligence/tests/state.test.ts new file mode 100644 index 0000000..84f6c9e --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/state.test.ts @@ -0,0 +1,385 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises"; +import { hostname, tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { FileStateStore, STATE_VERSION } from "../src/state.js"; +import { vendorReport } from "./fixtures.js"; + +const directories: string[] = []; + +async function temporaryStore(): Promise<{ directory: string; store: FileStateStore }> { + const directory = await mkdtemp(join(tmpdir(), "vendor-intelligence-state-")); + directories.push(directory); + return { directory, store: new FileStateStore(directory) }; +} + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("FileStateStore", () => { + it("returns an empty current-version state when the file is absent", async () => { + const { store } = await temporaryStore(); + await expect(store.read()).resolves.toEqual({ + stateVersion: STATE_VERSION, + specVersion: 1, + vendors: {}, + }); + }); + + it("validates and preserves successive updates", async () => { + const { store } = await temporaryStore(); + await store.update((state) => { + state.vendors["example.com"] = { + vendor: { name: "Example", domain: "example.com" }, + baseline: { stage: "not_started", failedAttempts: [] }, + events: {}, + }; + }); + await store.update((state) => { + state.vendors["example.com"]!.vendor.riskFloor = "HIGH"; + }); + expect((await store.read()).vendors["example.com"]?.vendor.riskFloor).toBe("HIGH"); + }); + + it("fails loudly on malformed state and ignores stray temporary files", async () => { + const { directory, store } = await temporaryStore(); + await writeFile(join(directory, "state.json.tmp-orphan"), "not json"); + await expect(store.read()).resolves.toMatchObject({ vendors: {} }); + await writeFile(store.statePath, "not json"); + await expect(store.read()).rejects.toThrow(store.statePath); + }); + + it("rejects mismatched vendor keys without replacing the valid file", async () => { + const { store } = await temporaryStore(); + await store.update((state) => { + state.vendors["example.com"] = { + vendor: { name: "Example", domain: "example.com" }, + baseline: { stage: "not_started", failedAttempts: [] }, + events: {}, + }; + }); + const before = await readFile(store.statePath, "utf8"); + await expect( + store.update((state) => { + state.vendors["wrong.example"] = state.vendors["example.com"]!; + }), + ).rejects.toThrow(); + expect(await readFile(store.statePath, "utf8")).toBe(before); + }); + + it("rejects an active Monitor attached to a different baseline", async () => { + const { store } = await temporaryStore(); + const report = vendorReport(); + await expect( + store.update((state) => { + state.vendors["example.com"] = { + vendor: { name: "Example", domain: "example.com" }, + baseline: { + stage: "completed", + run: { + runId: "baseline-1", + interactionId: "interaction-1", + startedAt: "2026-07-09T00:00:00.000Z", + }, + failedAttempts: [], + evidence: { + report, + basis: [], + observedAt: "2026-07-09T00:00:00.000Z", + warnings: [], + }, + }, + monitor: { + status: "active", + monitorId: "monitor-1", + baselineRunId: "different-baseline", + frequency: "1d", + processor: "lite", + createdAt: "2026-07-09T00:00:00.000Z", + }, + events: {}, + }; + }), + ).rejects.toThrow("baseline does not match"); + }); + + it("serializes commands across independent store instances", async () => { + const { directory, store } = await temporaryStore(); + const other = new FileStateStore(directory); + let release!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const acquired = new Promise((resolve) => { + entered = resolve; + }); + const first = store.withCommandLock("bootstrap", async () => { + entered(); + await gate; + return "done"; + }); + await acquired; + await expect( + other.withCommandLock("check-updates", async () => "should not run"), + ).rejects.toThrow("Another vendor-intelligence command is active"); + release(); + await expect(first).resolves.toBe("done"); + await expect(other.withCommandLock("cleanup", async () => "released")).resolves.toBe( + "released", + ); + }); + + it("releases the command lock after the action throws", async () => { + const { store } = await temporaryStore(); + await expect( + store.withCommandLock("bootstrap", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + await expect(store.withCommandLock("cleanup", async () => "ok")).resolves.toBe("ok"); + }); + + it("reclaims a dead same-host owner but not a fresh malformed lock", async () => { + const { store } = await temporaryStore(); + await writeFile( + store.lockPath, + `${JSON.stringify({ + version: 1, + token: randomUUID(), + pid: 2_147_483_647, + hostname: hostname(), + command: "bootstrap", + acquiredAt: "2026-07-09T00:00:00.000Z", + })}\n`, + ); + await expect(store.withCommandLock("cleanup", async () => "reclaimed")).resolves.toBe( + "reclaimed", + ); + + await writeFile(store.lockPath, ""); + await expect(store.withCommandLock("cleanup", async () => "unsafe")).rejects.toThrow( + "Another vendor-intelligence command is active", + ); + const old = new Date(Date.now() - 31_000); + await utimes(store.lockPath, old, old); + await expect(store.withCommandLock("cleanup", async () => "old-reclaimed")).resolves.toBe( + "old-reclaimed", + ); + }); + + it("allows only one contender to reclaim a stale lock", async () => { + const { directory, store } = await temporaryStore(); + const other = new FileStateStore(directory); + await writeFile( + store.lockPath, + `${JSON.stringify({ + version: 1, + token: randomUUID(), + pid: 2_147_483_647, + hostname: hostname(), + command: "stale", + acquiredAt: "2026-07-09T00:00:00.000Z", + })}\n`, + ); + + let entered = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let firstEntry!: () => void; + const firstEntered = new Promise((resolve) => { + firstEntry = resolve; + }); + const action = async () => { + entered += 1; + firstEntry(); + await gate; + return "entered"; + }; + const attempts = [ + store.withCommandLock("first", action), + other.withCommandLock("second", action), + ]; + const settled = Promise.allSettled(attempts); + + await firstEntered; + await new Promise((resolve) => setImmediate(resolve)); + expect(entered).toBe(1); + release(); + const results = await settled; + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1); + expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1); + }); + + it("identifies an orphaned stale-lock recovery marker", async () => { + const { store } = await temporaryStore(); + const deadOwner = { + version: 1, + token: randomUUID(), + pid: 2_147_483_647, + hostname: hostname(), + command: "cleanup", + acquiredAt: "2026-07-09T00:00:00.000Z", + }; + await writeFile(store.lockPath, `${JSON.stringify(deadOwner)}\n`); + await writeFile(`${store.lockPath}.reclaim`, `${JSON.stringify(deadOwner)}\n`); + + await expect( + store.withCommandLock("cleanup", async () => "unsafe"), + ).rejects.toThrow(`${store.lockPath}.reclaim`); + + await rm(`${store.lockPath}.reclaim`); + await expect(store.withCommandLock("cleanup", async () => "reclaimed")).resolves.toBe( + "reclaimed", + ); + }); + + it("does not delete a replacement lock owned by another token", async () => { + const { store } = await temporaryStore(); + const replacement = { + version: 1, + token: randomUUID(), + pid: process.pid, + hostname: hostname(), + command: "replacement", + acquiredAt: new Date().toISOString(), + }; + await store.withCommandLock("bootstrap", async () => { + await writeFile(store.lockPath, `${JSON.stringify(replacement)}\n`); + }); + expect(JSON.parse(await readFile(store.lockPath, "utf8"))).toMatchObject({ + token: replacement.token, + }); + }); + + it("rejects unsupported state versions", async () => { + const { store } = await temporaryStore(); + const unsupported = { + stateVersion: 99, + specVersion: 1, + vendors: {}, + }; + await writeFile(store.statePath, `${JSON.stringify(unsupported)}\n`); + await expect(store.read()).rejects.toThrow("Cannot read vendor intelligence state"); + }); + + it("keeps historical decisions readable across policy-version changes", async () => { + const { store } = await temporaryStore(); + const report = vendorReport(); + await store.update((state) => { + state.vendors["example.com"] = { + vendor: { name: "Example", domain: "example.com" }, + baseline: { stage: "not_started", failedAttempts: [] }, + events: { + historical: { + stage: "completed_without_follow_up", + eventId: "historical", + monitorId: "monitor-old", + eventDate: "2026-07-09", + eventGroupId: "group-historical", + firstSeenAt: "2026-07-09T00:00:00.000Z", + previousReport: report, + previousBasis: [], + currentReport: report, + currentBasis: [], + decision: { + runFollowUp: false, + threshold: "HIGH", + previousLevel: "LOW", + currentLevel: "LOW", + changedFields: ["financial_health"], + requiresHumanReview: false, + reasons: [], + policyVersion: 99, + evaluatedAt: "2026-07-09T00:00:00.000Z", + }, + completedAt: "2026-07-09T00:00:00.000Z", + }, + }, + latestEventId: "historical", + }; + }); + + const historical = (await store.read()).vendors["example.com"]?.events.historical; + expect(historical?.stage).toBe("completed_without_follow_up"); + if (!historical || historical.stage === "event_failed") { + throw new Error("missing historical event"); + } + expect(historical.decision.policyVersion).toBe(99); + }); + + it("reads legacy failed events that predate persisted prior snapshots", async () => { + const { store } = await temporaryStore(); + const report = vendorReport(); + const observedAt = "2026-07-09T00:00:00.000Z"; + await writeFile( + store.statePath, + `${JSON.stringify({ + stateVersion: STATE_VERSION, + specVersion: 1, + vendors: { + "example.com": { + vendor: { name: "Example", domain: "example.com" }, + baseline: { + stage: "completed", + run: { runId: "baseline-1", interactionId: "interaction-1" }, + failedAttempts: [], + evidence: { report, basis: [], observedAt, warnings: [] }, + }, + monitor: { + status: "active", + monitorId: "monitor-1", + baselineRunId: "baseline-1", + frequency: "1d", + processor: "lite", + createdAt: observedAt, + newestObservedEventId: "legacy-failure", + }, + events: { + "legacy-failure": { + stage: "event_failed", + eventId: "legacy-failure", + monitorId: "monitor-1", + eventDate: "2026-07-09", + eventGroupId: "group-legacy-failure", + firstSeenAt: observedAt, + rawEvent: { + eventId: "legacy-failure", + eventGroupId: "group-legacy-failure", + eventDate: "2026-07-09", + previousOutput: { type: "json", content: {}, basis: [] }, + changedOutput: { + type: "json", + content: { unknown_field: true }, + basis: [], + }, + }, + failure: { + kind: "invalid_event", + message: "legacy validation failure", + failedAt: observedAt, + attempts: 1, + }, + }, + }, + }, + }, + })}\n`, + ); + + const failed = (await store.read()).vendors["example.com"]?.events[ + "legacy-failure" + ]; + expect(failed?.stage).toBe("event_failed"); + if (!failed || failed.stage !== "event_failed") throw new Error("missing failure"); + expect(failed.priorSnapshot).toBeUndefined(); + }); +}); diff --git a/typescript-recipes/parallel-vendor-intelligence/tests/task-runner.test.ts b/typescript-recipes/parallel-vendor-intelligence/tests/task-runner.test.ts new file mode 100644 index 0000000..82162ab --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tests/task-runner.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { TaskRunner } from "../src/task-runner.js"; +import { fixedNow, fakeClient } from "./runtime.js"; +import { reportResult, taskRun, vendorReport } from "./fixtures.js"; + +function runner( + fake: ReturnType, + options: { clock?: () => number; sleep?: (milliseconds: number) => Promise } = {}, +) { + return new TaskRunner({ + client: fake.client, + pollSeconds: 1, + maxWaitMilliseconds: 10, + retryDelayMilliseconds: 5, + now: () => fixedNow, + sleep: options.sleep ?? (async () => {}), + clock: options.clock, + }); +} + +describe("TaskRunner", () => { + it.each(["failed", "cancelled", "action_required"] as const)( + "classifies a resolved %s result as a remote terminal failure", + async (status) => { + const fake = fakeClient(); + const terminalRun = { + ...taskRun("terminal-run"), + status, + error: { message: `${status} message`, ref_id: "ref-1" }, + }; + fake.taskResult.mockResolvedValue({ + ...reportResult(vendorReport(), "terminal-run"), + run: terminalRun, + }); + const tasks = runner(fake); + + let caught: unknown; + try { + await tasks.wait("terminal-run"); + } catch (error) { + caught = error; + } + expect(tasks.failure(caught, { + runId: "terminal-run", + startedAt: fixedNow.toISOString(), + })).toMatchObject({ + kind: "remote_terminal", + status, + message: `${status} message`, + refId: "ref-1", + }); + expect(fake.taskRetrieve).not.toHaveBeenCalled(); + }, + ); + + it("uses the injected clock to stop polling at the configured deadline", async () => { + const fake = fakeClient(); + fake.taskResult.mockRejectedValue({ status: 408, message: "still running" }); + fake.taskRetrieve.mockResolvedValue({ ...taskRun("slow-run"), status: "running" }); + let time = 0; + const tasks = runner(fake, { + clock: () => time, + sleep: async (milliseconds) => { + time += milliseconds; + }, + }); + + await expect(tasks.wait("slow-run")).rejects.toThrow("did not complete within 10ms"); + expect(fake.taskResult).toHaveBeenCalledTimes(2); + }); +}); diff --git a/typescript-recipes/parallel-vendor-intelligence/tsconfig.json b/typescript-recipes/parallel-vendor-intelligence/tsconfig.json new file mode 100644 index 0000000..683f464 --- /dev/null +++ b/typescript-recipes/parallel-vendor-intelligence/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/website/cookbook.json b/website/cookbook.json index 89b1b6b..b24855d 100644 --- a/website/cookbook.json +++ b/website/cookbook.json @@ -62,6 +62,18 @@ "imageUrl": null, "tags": ["monitoring", "task", "webhooks", "cloudflare"] }, + { + "slug": "parallel-vendor-intelligence", + "popular": false, + "featured": false, + "title": "Vendor Intelligence", + "description": "Researches vendor risk, watches the structured report for changes, and runs follow-up research only when a change crosses the review threshold.", + "repoUrl": "https://github.com/parallel-web/parallel-cookbook/tree/main/typescript-recipes/parallel-vendor-intelligence", + "websiteUrl": null, + "creators": ["s-jag"], + "imageUrl": null, + "tags": ["monitoring", "task", "deep-research", "enrichment", "typescript"] + }, { "slug": "parallel-supabase-enrichment", "popular": true, diff --git a/website/cookbook.schema.json b/website/cookbook.schema.json index 709cd61..4aadc78 100644 --- a/website/cookbook.schema.json +++ b/website/cookbook.schema.json @@ -34,9 +34,9 @@ "description": "GitHub repository URL" }, "websiteUrl": { - "type": "string", + "type": ["string", "null"], "format": "uri", - "description": "Live demo website URL" + "description": "Live demo website URL, or null when the recipe has no hosted demo" }, "creators": { "type": "array", diff --git a/website/home.html b/website/home.html index 3280fab..ea04b4d 100644 --- a/website/home.html +++ b/website/home.html @@ -360,6 +360,11 @@

All Cookbooks

}); } + function createDemoLink(cookbook, className) { + if (!cookbook.websiteUrl) return ''; + return `Demo`; + } + function createCard(cookbook) { const card = document.createElement('div'); card.className = 'group'; @@ -368,6 +373,7 @@

All Cookbooks

const imageContent = hasImage ? `${cookbook.title}` : cookbook.title; const bgColor = hasImage ? 'transparent' : `linear-gradient(135deg, ${stringToColor(cookbook.repoUrl)} 0%, ${stringToColor(cookbook.repoUrl + 'salt')} 100%)`; const descriptionHtml = `

${cookbook.description}

` + const demoButton = createDemoLink(cookbook, 'flex-1 bg-[var(--color-signal)] text-white px-4 py-2 rounded-md font-mono text-sm text-center no-underline hover:opacity-80'); card.innerHTML = `
@@ -382,7 +388,7 @@

${cookbook.title}

Code - Demo + ${demoButton}
@@ -445,6 +451,7 @@

${cookbook.title}

const hasImage = cookbook.imageUrl && cookbook.imageUrl !== '/assets/placeholder.png'; const imageContent = hasImage ? `${cookbook.title}` : ''; const bgColor = hasImage ? 'transparent' : `linear-gradient(135deg, ${stringToColor(cookbook.repoUrl)} 0%, ${stringToColor(cookbook.repoUrl + 'salt')} 100%)`; + const demoButton = createDemoLink(cookbook, 'px-3 py-1.5 border border-[var(--color-border)] rounded text-sm text-center font-mono transition-all text-[var(--color-text)] no-underline hover:bg-[var(--color-signal)] hover:text-white hover:border-[var(--color-signal)] whitespace-nowrap'); item.innerHTML = `
@@ -461,7 +468,7 @@

${cookbook.title}

${creators}
Code - Demo + ${demoButton}
`; return item; @@ -489,6 +496,7 @@

${cookbook.title}

const hasImage = cookbook.imageUrl && cookbook.imageUrl !== '/assets/placeholder.png'; const imageContent = hasImage ? `${cookbook.title}` : ''; const bgColor = hasImage ? 'transparent' : `linear-gradient(135deg, ${stringToColor(cookbook.repoUrl)} 0%, ${stringToColor(cookbook.repoUrl + 'salt')} 100%)`; + const demoButton = createDemoLink(cookbook, 'flex-1 px-3 py-2 border border-[var(--color-border)] rounded text-sm text-center font-mono transition-all text-[var(--color-text)] no-underline hover:bg-[var(--color-signal)] hover:text-white hover:border-[var(--color-signal)]'); card.innerHTML = `
@@ -508,7 +516,7 @@

${cookbook.ti
${tags}
Code - Demo + ${demoButton}

@@ -563,4 +571,4 @@

${cookbook.ti - \ No newline at end of file +