From f3a511268ed17dbaa06d3ed5816cc187e3ddcb0e Mon Sep 17 00:00:00 2001 From: Taylor Addison Date: Thu, 13 Aug 2026 16:23:35 -0400 Subject: [PATCH 01/90] =?UTF-8?q?Phase=200:=20collector=20skeleton=20?= =?UTF-8?q?=E2=80=94=20schemas,=20queue,=20artifacts,=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Job/result protocol (schema 1) with the device/host executor split, Fastify + SQLite collector with long-poll claiming, pool-aware dispatch, idempotent results, sha256 artifact store with Range support, and a server-rendered dashboard. End-to-end smoke script simulates both executor types (19 checks, all passing). Co-Authored-By: Claude Fable 5 --- .gitignore | 5 + README.md | 39 + package-lock.json | 1653 ++++++++++++++++++++++++++++++++++++ package.json | 26 + schemas/job.schema.json | 70 ++ schemas/result.schema.json | 62 ++ scripts/smoke.ts | 151 ++++ src/dash.ts | 84 ++ src/db.ts | 56 ++ src/server.ts | 216 +++++ tsconfig.json | 12 + 11 files changed, 2374 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 schemas/job.schema.json create mode 100644 schemas/result.schema.json create mode 100644 scripts/smoke.ts create mode 100644 src/dash.ts create mode 100644 src/db.ts create mode 100644 src/server.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6191f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +data/ +artifacts/store/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4d3095 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# fleet-collector + +Phase 0 of the Fleet Runner plan: the collector service — device registry, job +queue, artifact store, results DB, and dashboard for the device fleet. + +Node + Fastify + SQLite (WAL). No auth: the collector is only reachable over the +Tailscale mesh. Runs under `launchd` on the Mac mini. + +## Run + +``` +npm install +npm start # listens on :8787 (FLEET_PORT to change) +npm run smoke # end-to-end check against a running collector +``` + +## Endpoints + +| Method & path | Purpose | +|---|---| +| `POST /devices/register` | Device checks in with descriptor + pool tags (upsert) | +| `GET /devices/:id/next-job` | Long-poll (~25 s) for `executor: "device"` work; 204 when none | +| `GET /executor/next-job` | Long-poll for `executor: "host"` work (`?name=` labels the claimant) | +| `POST /jobs` | Enqueue a job spec (curl, CI, or the future scheduler); 409 on duplicate `job_id` | +| `GET /jobs/:id` | Job status | +| `POST /results` | Result rows (`kind: "result"`, idempotent by job/device/iter) and telemetry (`kind: "beacon"`); `final: true` closes the job | +| `POST /artifacts` | Upload raw bytes (models or app builds); returns `sha256` | +| `GET /artifacts/:sha256` | Download, supports Range requests | +| `GET /dash` | Server-rendered dashboard | + +Job and result shapes are documented in [`schemas/`](schemas/) (`"schema": 1`). + +## Phase 0 scope notes + +- Artifact uploads are buffered in memory — fine for smoke tests and APKs, + needs streaming before multi-GB models (Phase 2). +- `targets.match` expressions and `targets.exclusive` locks are accepted but not + enforced yet (Phase 4 alongside pools/scheduler). +- No scheduler yet: nightly runs arrive when cron enqueues land in Phase 4. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4b8f9a4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1653 @@ +{ + "name": "fleet-collector", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fleet-collector", + "version": "0.1.0", + "dependencies": { + "better-sqlite3": "^12.2.0", + "fastify": "^5.4.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^24.2.0", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz", + "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.0.tgz", + "integrity": "sha512-A3RNEaDIHWaxFW8n8rNJaW1wQ+XAXuoU71llfUQJjuh5WaYLmKfRhhanaJBOx8m2EBPQkR6sDBovYdHNe9F6rA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "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/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "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/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "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": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6aa5715 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "fleet-collector", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Fleet Runner collector: device registry, job queue, artifact store, results DB, dashboard.", + "scripts": { + "start": "tsx src/server.ts", + "dev": "tsx watch src/server.ts", + "smoke": "tsx scripts/smoke.ts" + }, + "dependencies": { + "better-sqlite3": "^12.2.0", + "fastify": "^5.4.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^24.2.0", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + }, + "allowScripts": { + "esbuild@0.28.2": true, + "better-sqlite3@12.11.1": true + } +} diff --git a/schemas/job.schema.json b/schemas/job.schema.json new file mode 100644 index 0000000..ba01b61 --- /dev/null +++ b/schemas/job.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fleet.local/schemas/job/v1", + "title": "Fleet Runner job spec (schema 1)", + "type": "object", + "required": ["schema", "job_id", "workload", "executor"], + "properties": { + "schema": { "const": 1 }, + "job_id": { "type": "string", "minLength": 1 }, + "workload": { + "enum": ["benchmark", "batch", "pipeline", "install", "ui-test", "drain", "soak"] + }, + "executor": { + "enum": ["device", "host"], + "description": "device = on-device runner app claims by polling; host = Mac-side executor drives devices from outside" + }, + "model": { + "type": "object", + "description": "ML workloads: the model artifact to run", + "required": ["name", "format", "sha256"], + "properties": { + "name": { "type": "string" }, + "format": { "enum": ["gguf", "mlmodelc", "tflite"] }, + "quant": { "type": "string" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + }, + "app": { + "type": "object", + "description": "App-testing workloads: the build artifact to install/run", + "required": ["name", "build", "sha256"], + "properties": { + "name": { "type": "string" }, + "build": { "type": "string" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + }, + "backend": { "enum": ["llama.cpp", "coreml", "litert", "whisper.cpp"] }, + "suite": { + "type": "object", + "description": "ui-test workloads", + "properties": { + "kind": { "enum": ["maestro", "xcuitest"] }, + "flows": { "type": "string" } + } + }, + "params": { "type": "object" }, + "targets": { + "type": "object", + "properties": { + "pool": { "type": "string" }, + "match": { "type": "string", "description": "expression matching device descriptors — not evaluated in Phase 0" }, + "exclusive": { "type": "boolean" } + } + }, + "constraints": { + "type": "object", + "properties": { + "require_charging": { "type": "boolean" }, + "min_battery_pct": { "type": "number" } + } + }, + "report_to": { + "type": "object", + "properties": { + "github_status": { "type": "string", "description": "owner/repo@sha to post a commit status to (Phase 5)" } + } + } + } +} diff --git a/schemas/result.schema.json b/schemas/result.schema.json new file mode 100644 index 0000000..9a441ed --- /dev/null +++ b/schemas/result.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fleet.local/schemas/result/v1", + "title": "Fleet Runner result row (schema 1)", + "description": "Posted to /results. kind=result rows are idempotent by (job_id, device_id, iter). kind=beacon rows are telemetry samples, also posted during host-driven jobs.", + "type": "object", + "required": ["schema", "kind", "device_id"], + "properties": { + "schema": { "const": 1 }, + "kind": { "enum": ["result", "beacon"] }, + "job_id": { "type": "string", "description": "required for kind=result; optional for beacon samples" }, + "device_id": { "type": "string" }, + "iter": { "type": "integer", "minimum": 0, "description": "iteration number; use 0 for single-shot results and the summary row" }, + "final": { "type": "boolean", "description": "marks the job finished on this device" }, + "ok": { "type": "boolean", "description": "with final: pass/fail verdict" }, + "device": { + "type": "object", + "properties": { + "model": { "type": "string" }, + "soc": { "type": "string" }, + "ram_mb": { "type": "number" }, + "os": { "type": "string" }, + "app_ver": { "type": "string" } + } + }, + "metrics": { + "type": "object", + "description": "benchmark metrics; memory is labeled, never laundered", + "properties": { + "load_ms": { "type": "number" }, + "prefill_tok_s": { "type": "number" }, + "decode_tok_s": { "type": "number" }, + "ttft_ms": { "type": "number" }, + "peak_mem_mb": { "type": "number" }, + "mem_method": { "enum": ["phys_footprint", "pss"] }, + "thermal": { "type": "array", "items": { "enum": ["nominal", "fair", "serious", "critical"] } }, + "battery_start_pct": { "type": "number" }, + "battery_end_pct": { "type": "number" } + } + }, + "test": { + "type": "object", + "description": "ui-test outcomes", + "properties": { + "passed": { "type": "integer" }, + "failed": { "type": "integer" }, + "artifacts": { "type": "array", "items": { "type": "string" }, "description": "sha256 refs of screenshots/videos/junit uploaded to the artifact store" } + } + }, + "beacon": { + "type": "object", + "description": "kind=beacon telemetry sample", + "properties": { + "battery_pct": { "type": "number" }, + "charging": { "type": "boolean" }, + "thermal": { "enum": ["nominal", "fair", "serious", "critical"] }, + "mem_pressure": { "type": "string" }, + "process_alive": { "type": "object", "additionalProperties": { "type": "boolean" } } + } + } + } +} diff --git a/scripts/smoke.ts b/scripts/smoke.ts new file mode 100644 index 0000000..2f18105 --- /dev/null +++ b/scripts/smoke.ts @@ -0,0 +1,151 @@ +// End-to-end smoke: simulates one device-executor client and one host-executor +// client against a running collector. Exercises every Phase 0 endpoint. +// Usage: npm run smoke (collector must be running on FLEET_URL, default :8787) +import { createHash } from "node:crypto"; + +const BASE = process.env.FLEET_URL ?? "http://127.0.0.1:8787"; +let failures = 0; + +function check(name: string, cond: boolean, detail = "") { + if (cond) console.log(` ok ${name}`); + else { + failures++; + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ""}`); + } +} + +async function json(method: string, url: string, body?: unknown) { + const res = await fetch(`${BASE}${url}`, { + method, + headers: body !== undefined ? { "content-type": "application/json" } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + return { status: res.status, body: res.status === 204 ? null : await res.json().catch(() => null) }; +} + +const run = Date.now(); +const DEVICE = `smoke-pixel-${run}`; +const BENCH_JOB = `smoke-bench-${run}`; +const UI_JOB = `smoke-uitest-${run}`; + +console.log(`smoke against ${BASE}`); + +// 1. register a device +{ + const r = await json("POST", "/devices/register", { + device_id: DEVICE, + descriptor: { model: "Pixel 4a", soc: "SD730G", ram_mb: 5793, os: "android-13" }, + pools: ["ml-capable", "android-ui"], + }); + check("device registers", r.status === 200 && r.body?.ok === true, JSON.stringify(r)); +} + +// 2. upload an artifact, download it back, verify hash + range requests +{ + const blob = Buffer.from(`fake-gguf-model-${run}`.repeat(1000)); + const sha = createHash("sha256").update(blob).digest("hex"); + const up = await fetch(`${BASE}/artifacts`, { + method: "POST", + headers: { "content-type": "application/octet-stream", "x-artifact-name": "smoke.gguf" }, + body: blob, + }); + const upBody = await up.json(); + check("artifact upload returns sha", up.status === 201 && upBody.sha256 === sha, JSON.stringify(upBody)); + + const down = await fetch(`${BASE}/artifacts/${sha}`); + const roundtrip = Buffer.from(await down.arrayBuffer()); + check("artifact roundtrips by hash", createHash("sha256").update(roundtrip).digest("hex") === sha); + + const ranged = await fetch(`${BASE}/artifacts/${sha}`, { headers: { range: "bytes=0-9" } }); + const first10 = Buffer.from(await ranged.arrayBuffer()); + check( + "range request works", + ranged.status === 206 && first10.length === 10 && first10.equals(blob.subarray(0, 10)), + `status=${ranged.status} len=${first10.length}`, + ); + + // 3. enqueue a device-executor benchmark referencing the artifact + const r = await json("POST", "/jobs", { + schema: 1, job_id: BENCH_JOB, workload: "benchmark", executor: "device", + model: { name: "smoke-model", format: "gguf", quant: "Q4_K_M", sha256: sha }, + backend: "llama.cpp", + params: { prompt_tokens: 512, gen_tokens: 128, warmup_iters: 1, measure_iters: 2 }, + targets: { pool: "ml-capable" }, + }); + check("benchmark job enqueues", r.status === 201, JSON.stringify(r)); + const dup = await json("POST", "/jobs", { schema: 1, job_id: BENCH_JOB, workload: "benchmark", executor: "device" }); + check("duplicate job_id rejected with 409", dup.status === 409); +} + +// 4. enqueue a host-executor ui-test job +{ + const r = await json("POST", "/jobs", { + schema: 1, job_id: UI_JOB, workload: "ui-test", executor: "host", + app: { name: "greenfolio-android", build: "smoke", sha256: "0".repeat(64) }, + suite: { kind: "maestro", flows: "flows/smoke/*.yaml" }, + targets: { pool: "android-ui", exclusive: true }, + }); + check("ui-test job enqueues", r.status === 201, JSON.stringify(r)); +} + +// 5. device long-polls and claims ONLY the device job +{ + const r = await json("GET", `/devices/${DEVICE}/next-job`); + check("device claims benchmark job", r.status === 200 && r.body?.job_id === BENCH_JOB, JSON.stringify(r.body)); + check("claimed spec carries model + params", r.body?.model?.name === "smoke-model" && r.body?.params?.measure_iters === 2); +} + +// 6. host executor claims ONLY the host job +{ + const r = await json("GET", "/executor/next-job?name=mac-mini"); + check("host executor claims ui-test job", r.status === 200 && r.body?.job_id === UI_JOB, JSON.stringify(r.body)); +} + +// 7. device posts beacon + per-iteration results + final summary (idempotently) +{ + await json("POST", "/results", { + schema: 1, kind: "beacon", device_id: DEVICE, job_id: BENCH_JOB, + beacon: { battery_pct: 74, charging: true, thermal: "nominal" }, + }); + for (const iter of [1, 2]) { + await json("POST", "/results", { + schema: 1, kind: "result", job_id: BENCH_JOB, device_id: DEVICE, iter, + metrics: { decode_tok_s: 9.8 + iter, prefill_tok_s: 61.2, ttft_ms: 8420, peak_mem_mb: 812, mem_method: "pss", thermal: ["nominal"] }, + }); + } + const retry = await json("POST", "/results", { + schema: 1, kind: "result", job_id: BENCH_JOB, device_id: DEVICE, iter: 2, + metrics: { decode_tok_s: 11.8, prefill_tok_s: 61.2, ttft_ms: 8420, peak_mem_mb: 812, mem_method: "pss", thermal: ["nominal"] }, + }); + check("result retry is idempotent", retry.status === 200); + const fin = await json("POST", "/results", { + schema: 1, kind: "result", job_id: BENCH_JOB, device_id: DEVICE, iter: 0, final: true, ok: true, + metrics: { decode_tok_s: 10.8, prefill_tok_s: 61.2, ttft_ms: 8420, peak_mem_mb: 812, mem_method: "pss", thermal: ["nominal", "fair"] }, + }); + check("final result accepted", fin.status === 200); + const job = await json("GET", `/jobs/${BENCH_JOB}`); + check("final result marks job done", job.body?.status === "done", `status=${job.body?.status}`); +} + +// 8. host executor reports a failing ui-test run +{ + const fin = await json("POST", "/results", { + schema: 1, kind: "result", job_id: UI_JOB, device_id: DEVICE, iter: 0, final: true, ok: false, + test: { passed: 11, failed: 1, artifacts: [] }, + }); + check("ui-test final accepted", fin.status === 200); + const job = await json("GET", `/jobs/${UI_JOB}`); + check("failing run marks job failed", job.body?.status === "failed", `status=${job.body?.status}`); +} + +// 9. dashboard shows all of it +{ + const html = await (await fetch(`${BASE}/dash`)).text(); + check("dashboard lists device", html.includes(DEVICE)); + check("dashboard lists both jobs", html.includes(BENCH_JOB) && html.includes(UI_JOB)); + check("dashboard shows benchmark summary", html.includes("tok/s")); + check("dashboard shows ui-test verdict", html.includes("11 passed / 1 failed")); +} + +console.log(failures === 0 ? "\nsmoke: ALL PASS" : `\nsmoke: ${failures} FAILURE(S)`); +process.exit(failures === 0 ? 0 : 1); diff --git a/src/dash.ts b/src/dash.ts new file mode 100644 index 0000000..ff19a24 --- /dev/null +++ b/src/dash.ts @@ -0,0 +1,84 @@ +import { db } from "./db.js"; + +const esc = (s: unknown) => + String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]!)); + +export function renderDash(): string { + const devices = db + .prepare("SELECT * FROM devices ORDER BY last_seen DESC") + .all() as { device_id: string; descriptor: string; pools: string; last_seen: string; last_beacon: string | null }[]; + const jobs = db + .prepare("SELECT * FROM jobs ORDER BY created_at DESC LIMIT 50") + .all() as { job_id: string; executor: string; workload: string; status: string; created_at: string; claimed_by: string | null; finished_at: string | null }[]; + const results = db + .prepare("SELECT * FROM results ORDER BY created_at DESC LIMIT 50") + .all() as { job_id: string; device_id: string; iter: number; payload: string; created_at: string }[]; + + const deviceRows = devices + .map((d) => { + const desc = JSON.parse(d.descriptor); + const beacon = d.last_beacon ? JSON.parse(d.last_beacon) : null; + return ` + ${esc(d.device_id)} + ${esc(desc.model ?? "?")} · ${esc(desc.os ?? "?")} + ${esc(JSON.parse(d.pools).join(", "))} + ${beacon?.beacon?.battery_pct != null ? esc(beacon.beacon.battery_pct) + "%" : "—"} + ${esc(beacon?.beacon?.thermal ?? "—")} + ${esc(d.last_seen)} + `; + }) + .join(""); + + const jobRows = jobs + .map( + (j) => ` + ${esc(j.job_id)} + ${esc(j.workload)} + ${esc(j.executor)} + ${esc(j.status)} + ${esc(j.claimed_by ?? "—")} + ${esc(j.finished_at ?? j.created_at)} + `, + ) + .join(""); + + const resultRows = results + .map((r) => { + const p = JSON.parse(r.payload); + const summary = p.metrics + ? `decode ${p.metrics.decode_tok_s ?? "?"} tok/s · ${p.metrics.peak_mem_mb ?? "?"} MB (${p.metrics.mem_method ?? "?"})` + : p.test + ? `${p.test.passed ?? 0} passed / ${p.test.failed ?? 0} failed` + : ""; + return ` + ${esc(r.job_id)} + ${esc(r.device_id)} + ${esc(r.iter)}${p.final ? " (final)" : ""} + ${esc(summary)} + ${esc(r.created_at)} + `; + }) + .join(""); + + return ` +Fleet Collector + + +

Fleet Collector

+

Devices (${devices.length})

+${deviceRows}
IDHardwarePoolsBatteryThermalLast seen
+

Jobs

+${jobRows}
JobWorkloadExecutorStatusClaimed byUpdated
+

Recent results

+${resultRows}
JobDeviceIterSummaryAt
+`; +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..56c6b49 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,56 @@ +import Database from "better-sqlite3"; +import { mkdirSync } from "node:fs"; +import path from "node:path"; + +const DATA_DIR = process.env.FLEET_DATA_DIR ?? path.resolve("data"); +mkdirSync(DATA_DIR, { recursive: true }); + +export const db = new Database(path.join(DATA_DIR, "fleet.db")); +db.pragma("journal_mode = WAL"); + +db.exec(` +CREATE TABLE IF NOT EXISTS devices ( + device_id TEXT PRIMARY KEY, + descriptor TEXT NOT NULL, -- JSON: model, soc, ram_mb, os, app_ver + pools TEXT NOT NULL, -- JSON array of pool tags + last_seen TEXT NOT NULL, + last_beacon TEXT -- JSON: most recent beacon sample +); + +CREATE TABLE IF NOT EXISTS jobs ( + job_id TEXT PRIMARY KEY, + executor TEXT NOT NULL CHECK (executor IN ('device','host')), + workload TEXT NOT NULL, + spec TEXT NOT NULL, -- full JSON job spec + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued','claimed','done','failed')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + claimed_by TEXT, + claimed_at TEXT, + finished_at TEXT +); + +CREATE TABLE IF NOT EXISTS results ( + job_id TEXT NOT NULL, + device_id TEXT NOT NULL, + iter INTEGER NOT NULL DEFAULT 0, + payload TEXT NOT NULL, -- full JSON result row + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (job_id, device_id, iter) +); + +CREATE TABLE IF NOT EXISTS beacon_samples ( + device_id TEXT NOT NULL, + ts TEXT NOT NULL DEFAULT (datetime('now')), + job_id TEXT, + sample TEXT NOT NULL -- JSON beacon payload +); +CREATE INDEX IF NOT EXISTS idx_beacon_device_ts ON beacon_samples (device_id, ts); + +CREATE TABLE IF NOT EXISTS artifacts ( + sha256 TEXT PRIMARY KEY, + name TEXT, + size INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +`); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..fc7043f --- /dev/null +++ b/src/server.ts @@ -0,0 +1,216 @@ +import Fastify from "fastify"; +import { createHash } from "node:crypto"; +import { createReadStream, statSync, existsSync, mkdirSync } from "node:fs"; +import { writeFile, rename } from "node:fs/promises"; +import path from "node:path"; +import { db } from "./db.js"; +import { renderDash } from "./dash.js"; + +const PORT = Number(process.env.FLEET_PORT ?? 8787); +const ARTIFACT_DIR = process.env.FLEET_ARTIFACT_DIR ?? path.resolve("artifacts/store"); +mkdirSync(ARTIFACT_DIR, { recursive: true }); + +const LONG_POLL_S = 25; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const app = Fastify({ logger: { level: process.env.FLEET_LOG ?? "info" } }); + +// Artifact uploads arrive as raw bytes. Buffered in memory for Phase 0; +// streaming upload is a Phase 2 concern (app builds are ~100 MB, models a few GB). +app.addContentTypeParser( + "application/octet-stream", + { parseAs: "buffer", bodyLimit: 4 * 1024 * 1024 * 1024 }, + (_req, body, done) => done(null, body), +); + +type JobSpec = { + schema: number; + job_id: string; + workload: string; + executor: "device" | "host"; + targets?: { pool?: string; match?: string; exclusive?: boolean }; + [k: string]: unknown; +}; + +const WORKLOADS = new Set(["benchmark", "batch", "pipeline", "install", "ui-test", "drain", "soak"]); + +function touchDevice(deviceId: string) { + db.prepare("UPDATE devices SET last_seen = datetime('now') WHERE device_id = ?").run(deviceId); +} + +// Atomically claim the oldest queued job this claimant is eligible for. +const claimTx = db.transaction((executor: string, claimant: string, devicePools: string[]): JobSpec | null => { + const rows = db + .prepare("SELECT job_id, spec FROM jobs WHERE status = 'queued' AND executor = ? ORDER BY created_at") + .all(executor) as { job_id: string; spec: string }[]; + for (const row of rows) { + const spec = JSON.parse(row.spec) as JobSpec; + const pool = spec.targets?.pool; + if (executor === "device" && pool && !devicePools.includes(pool)) continue; + db.prepare( + "UPDATE jobs SET status = 'claimed', claimed_by = ?, claimed_at = datetime('now') WHERE job_id = ?", + ).run(claimant, row.job_id); + return spec; + } + return null; +}); + +async function longPollClaim(executor: "device" | "host", claimant: string, pools: string[]) { + for (let i = 0; i < LONG_POLL_S; i++) { + const spec = claimTx(executor, claimant, pools); + if (spec) return spec; + await sleep(1000); + } + return null; +} + +// --- devices --- + +app.post("/devices/register", async (req, reply) => { + const b = req.body as { device_id?: string; descriptor?: object; pools?: string[] }; + if (!b?.device_id) return reply.code(400).send({ error: "device_id required" }); + db.prepare( + `INSERT INTO devices (device_id, descriptor, pools, last_seen) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(device_id) DO UPDATE SET + descriptor = excluded.descriptor, pools = excluded.pools, last_seen = excluded.last_seen`, + ).run(b.device_id, JSON.stringify(b.descriptor ?? {}), JSON.stringify(b.pools ?? [])); + return { ok: true }; +}); + +app.get("/devices/:id/next-job", async (req, reply) => { + const { id } = req.params as { id: string }; + const dev = db.prepare("SELECT pools FROM devices WHERE device_id = ?").get(id) as + | { pools: string } + | undefined; + if (!dev) return reply.code(404).send({ error: "unknown device; register first" }); + touchDevice(id); + const spec = await longPollClaim("device", id, JSON.parse(dev.pools)); + if (!spec) return reply.code(204).send(); + return spec; +}); + +app.get("/executor/next-job", async (req, reply) => { + const claimant = ((req.query as Record).name ?? "host-executor"); + const spec = await longPollClaim("host", claimant, []); + if (!spec) return reply.code(204).send(); + return spec; +}); + +// --- jobs --- + +app.post("/jobs", async (req, reply) => { + const spec = req.body as JobSpec; + if (spec?.schema !== 1) return reply.code(400).send({ error: "schema must be 1" }); + if (!spec.job_id) return reply.code(400).send({ error: "job_id required" }); + if (!WORKLOADS.has(spec.workload)) return reply.code(400).send({ error: `unknown workload: ${spec.workload}` }); + if (spec.executor !== "device" && spec.executor !== "host") + return reply.code(400).send({ error: "executor must be 'device' or 'host'" }); + try { + db.prepare("INSERT INTO jobs (job_id, executor, workload, spec) VALUES (?, ?, ?, ?)").run( + spec.job_id, spec.executor, spec.workload, JSON.stringify(spec), + ); + } catch (e: unknown) { + if ((e as { code?: string }).code === "SQLITE_CONSTRAINT_PRIMARYKEY") + return reply.code(409).send({ error: "job_id already exists" }); + throw e; + } + return reply.code(201).send({ ok: true, job_id: spec.job_id }); +}); + +app.get("/jobs/:id", async (req, reply) => { + const { id } = req.params as { id: string }; + const job = db.prepare("SELECT * FROM jobs WHERE job_id = ?").get(id); + if (!job) return reply.code(404).send({ error: "not found" }); + return job; +}); + +// --- results --- + +app.post("/results", async (req, reply) => { + const b = req.body as { + schema?: number; kind?: string; job_id?: string; device_id?: string; + iter?: number; final?: boolean; ok?: boolean; + }; + if (b?.schema !== 1) return reply.code(400).send({ error: "schema must be 1" }); + if (!b.device_id) return reply.code(400).send({ error: "device_id required" }); + + if (b.kind === "beacon") { + db.prepare("INSERT INTO beacon_samples (device_id, job_id, sample) VALUES (?, ?, ?)").run( + b.device_id, b.job_id ?? null, JSON.stringify(b), + ); + db.prepare("UPDATE devices SET last_beacon = ?, last_seen = datetime('now') WHERE device_id = ?").run( + JSON.stringify(b), b.device_id, + ); + return { ok: true }; + } + + if (b.kind !== "result") return reply.code(400).send({ error: "kind must be 'result' or 'beacon'" }); + if (!b.job_id) return reply.code(400).send({ error: "job_id required for kind=result" }); + // Idempotent by (job_id, device_id, iter): retried posts overwrite, never duplicate. + db.prepare( + "INSERT OR REPLACE INTO results (job_id, device_id, iter, payload) VALUES (?, ?, ?, ?)", + ).run(b.job_id, b.device_id, b.iter ?? 0, JSON.stringify(b)); + touchDevice(b.device_id); + + if (b.final) { + db.prepare( + "UPDATE jobs SET status = ?, finished_at = datetime('now') WHERE job_id = ?", + ).run(b.ok === false ? "failed" : "done", b.job_id); + } + return { ok: true }; +}); + +// --- artifacts (models and app builds, addressed by sha256) --- + +app.post("/artifacts", async (req, reply) => { + const body = req.body as Buffer; + if (!Buffer.isBuffer(body) || body.length === 0) + return reply.code(400).send({ error: "raw application/octet-stream body required" }); + const sha256 = createHash("sha256").update(body).digest("hex"); + const dest = path.join(ARTIFACT_DIR, sha256); + if (!existsSync(dest)) { + const tmp = `${dest}.tmp-${process.pid}`; + await writeFile(tmp, body); + await rename(tmp, dest); + } + const name = (req.headers["x-artifact-name"] as string | undefined) ?? null; + db.prepare( + "INSERT OR IGNORE INTO artifacts (sha256, name, size) VALUES (?, ?, ?)", + ).run(sha256, name, body.length); + return reply.code(201).send({ ok: true, sha256, size: body.length }); +}); + +app.get("/artifacts/:sha256", async (req, reply) => { + const { sha256 } = req.params as { sha256: string }; + if (!/^[a-f0-9]{64}$/.test(sha256)) return reply.code(400).send({ error: "bad sha256" }); + const file = path.join(ARTIFACT_DIR, sha256); + if (!existsSync(file)) return reply.code(404).send({ error: "not found" }); + const size = statSync(file).size; + reply.header("accept-ranges", "bytes").header("content-type", "application/octet-stream"); + + const range = req.headers.range; + const m = range ? /^bytes=(\d*)-(\d*)$/.exec(range) : null; + if (m && (m[1] !== "" || m[2] !== "")) { + const start = m[1] === "" ? size - Number(m[2]) : Number(m[1]); + const end = m[1] !== "" && m[2] !== "" ? Math.min(Number(m[2]), size - 1) : size - 1; + if (start < 0 || start > end) + return reply.code(416).header("content-range", `bytes */${size}`).send(); + return reply + .code(206) + .header("content-range", `bytes ${start}-${end}/${size}`) + .send(createReadStream(file, { start, end })); + } + return reply.header("content-length", size).send(createReadStream(file)); +}); + +// --- dashboard --- + +app.get("/dash", async (_req, reply) => { + reply.type("text/html").send(renderDash()); +}); +app.get("/", async (_req, reply) => reply.redirect("/dash")); + +app.listen({ port: PORT, host: "0.0.0.0" }).then(() => { + app.log.info(`fleet-collector listening on :${PORT}`); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..402964e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src", "scripts"] +} From e8c5cf8ac762dced76db8ccece2f64de3b441324 Mon Sep 17 00:00:00 2001 From: Taylor Addison Date: Thu, 13 Aug 2026 16:30:36 -0400 Subject: [PATCH 02/90] Placeholder: Phase 3 Swift runner, protocol contract documented Co-Authored-By: Claude Fable 5 --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d9d625 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# fleet-runner-ios + +Swift runner app for the Fleet Runner device fleet — **Phase 3, not started**. + +This repo is a placeholder created alongside [`fleet-runner-android`](https://github.com/addisdev/fleet-runner-android) +so the fleet repos exist under one naming convention from day one. The Android +runner is being built first (Phase 1); this app mirrors it when Phase 3 starts. + +## What it will be + +A thin Swift 6 + SwiftUI app that mirrors the Android runner's JSON protocol +(shared protocol, not shared code): + +- Agent loop long-polling the collector (`GET /devices/:id/next-job`) +- Telemetry beacon (battery, thermal state, memory pressure) +- `ModelBackend` implementations: llama.cpp via SPM (GGUF, Metal), then Core ML + (`.mlmodelc`), possibly MLX later +- Benchmark workload engine with warmups, prefill/decode split, and + `phys_footprint` memory reporting (labeled, never compared to Android PSS) +- Distribution via TestFlight internal + +## Related + +- Collector + protocol schemas: [`fleet-collector`](https://github.com/addisdev/fleet-collector) + — `schemas/job.schema.json` and `schemas/result.schema.json` are the contract. +- Plan: `~/Desktop/Fleet Runner/fleet-runner-plan.html` From e2baa228ed2b4547b52137567ec68c74747a8a2d Mon Sep 17 00:00:00 2001 From: Taylor Addison Date: Thu, 13 Aug 2026 16:39:51 -0400 Subject: [PATCH 03/90] =?UTF-8?q?Phase=201:=20Android=20runner=20MVP=20?= =?UTF-8?q?=E2=80=94=20agent=20loop,=20beacon,=20synthetic=20benchmark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foreground service registers with the collector, long-polls for device-executor jobs, and reports results plus a 60 s telemetry beacon (battery/charging/thermal). ModelBackend abstraction with a synthetic SHA-256 throughput backend (real, device-comparable CPU numbers) and an honest fail-fast llama.cpp stub for Phase 1b. Benchmark engine: timed load, mandatory warmups, per-iteration rows, final summary closes the job. Verified end-to-end on an SM-X930 against a live collector. Unit tests cover protocol wire format and backend scaling. Co-Authored-By: Claude Fable 5 --- .gitignore | 9 + README.md | 47 ++++ app/build.gradle.kts | 48 ++++ app/src/main/AndroidManifest.xml | 31 +++ .../com/taylab/fleetrunner/MainActivity.kt | 60 +++++ .../com/taylab/fleetrunner/RunnerService.kt | 138 ++++++++++ .../fleetrunner/backend/LlamaCppBackend.kt | 22 ++ .../fleetrunner/backend/ModelBackend.kt | 29 ++ .../fleetrunner/backend/SyntheticBackend.kt | 65 +++++ .../taylab/fleetrunner/net/CollectorClient.kt | 57 ++++ .../taylab/fleetrunner/protocol/Protocol.kt | 101 +++++++ .../taylab/fleetrunner/telemetry/Telemetry.kt | 61 +++++ .../fleetrunner/workload/BenchmarkEngine.kt | 83 ++++++ app/src/main/res/drawable/ic_launcher.xml | 16 ++ app/src/main/res/drawable/ic_stat.xml | 14 + app/src/main/res/layout/activity_main.xml | 58 ++++ app/src/main/res/values/strings.xml | 9 + .../com/taylab/fleetrunner/ProtocolTest.kt | 72 +++++ .../fleetrunner/SyntheticBackendTest.kt | 51 ++++ build.gradle.kts | 5 + gradle.properties | 3 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 248 ++++++++++++++++++ gradlew.bat | 82 ++++++ settings.gradle.kts | 16 ++ 26 files changed, 1334 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/taylab/fleetrunner/MainActivity.kt create mode 100644 app/src/main/java/com/taylab/fleetrunner/RunnerService.kt create mode 100644 app/src/main/java/com/taylab/fleetrunner/backend/LlamaCppBackend.kt create mode 100644 app/src/main/java/com/taylab/fleetrunner/backend/ModelBackend.kt create mode 100644 app/src/main/java/com/taylab/fleetrunner/backend/SyntheticBackend.kt create mode 100644 app/src/main/java/com/taylab/fleetrunner/net/CollectorClient.kt create mode 100644 app/src/main/java/com/taylab/fleetrunner/protocol/Protocol.kt create mode 100644 app/src/main/java/com/taylab/fleetrunner/telemetry/Telemetry.kt create mode 100644 app/src/main/java/com/taylab/fleetrunner/workload/BenchmarkEngine.kt create mode 100644 app/src/main/res/drawable/ic_launcher.xml create mode 100644 app/src/main/res/drawable/ic_stat.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/test/java/com/taylab/fleetrunner/ProtocolTest.kt create mode 100644 app/src/test/java/com/taylab/fleetrunner/SyntheticBackendTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0804a66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +build/ +local.properties +.DS_Store +*.iml +.idea/ +captures/ +.externalNativeBuild/ +.cxx/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d77b235 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# fleet-runner-android + +Kotlin runner app for the Fleet Runner device fleet — Phase 1 of the plan. + +A foreground service turns any Android device (minSdk 24) into a fleet agent: +it registers with the [collector](https://github.com/addisdev/fleet-collector), +long-polls for `executor: "device"` jobs, runs them, and reports results plus a +60-second telemetry beacon (battery / charging / thermal). + +## Architecture + +| Component | File | Role | +|---|---|---| +| Agent loop | `RunnerService.kt` | register → long-poll → run → report, with error backoff | +| Telemetry beacon | `RunnerService.kt` / `telemetry/Telemetry.kt` | battery, thermal (shared enum), PSS; also runs during host-driven jobs | +| Protocol | `protocol/Protocol.kt` | Kotlin mirror of `fleet-collector/schemas` (`schema: 1`) | +| Backends | `backend/` | `ModelBackend` interface; `synthetic` (SHA-256 CPU throughput — real, device-comparable numbers, never LLM numbers) and `llama.cpp` (Phase 1b stub: fails fast with an honest error) | +| Benchmark engine | `workload/BenchmarkEngine.kt` | timed load, mandatory warmups, per-iteration rows, final summary closes the job | + +## Run against a local collector + +``` +cd ../fleet-collector && npm start # collector on :8787 +./gradlew :app:installDebug +adb reverse tcp:8787 tcp:8787 # device reaches the Mac via USB +``` + +Open the app, keep the default `http://127.0.0.1:8787`, tap **Start agent**, +then enqueue a job: + +``` +curl -X POST http://127.0.0.1:8787/jobs -H 'content-type: application/json' -d '{ + "schema": 1, "job_id": "bench-synthetic-1", "workload": "benchmark", + "executor": "device", "backend": "synthetic", + "params": { "prompt_tokens": 256, "gen_tokens": 64, "warmup_iters": 1, "measure_iters": 3 }, + "targets": { "pool": "ml-capable" } +}' +``` + +Results appear on the collector dashboard (`/dash`). Over Tailscale, set the +collector URL to the Mac mini's tailnet address instead — no adb needed. + +## Phase 1 status + +- [x] Agent loop, beacon, benchmark engine, synthetic backend +- [ ] Phase 1b: llama.cpp backend (NDK + JNI, GGUF artifact download/cache) +- [ ] Offline result queue (results currently retry only via the loop's backoff) diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..b518fbd --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "com.taylab.fleetrunner" + compileSdk = 35 + + defaultConfig { + applicationId = "com.taylab.fleetrunner" + // Old devices are the point of the fleet: keep minSdk as low as the + // HTTP + foreground-service core allows. llama.cpp will raise the + // floor for LLM jobs only, via backend capabilities, not app minSdk. + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..33963dd --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/taylab/fleetrunner/MainActivity.kt b/app/src/main/java/com/taylab/fleetrunner/MainActivity.kt new file mode 100644 index 0000000..321257f --- /dev/null +++ b/app/src/main/java/com/taylab/fleetrunner/MainActivity.kt @@ -0,0 +1,60 @@ +package com.taylab.fleetrunner + +import android.Manifest +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class MainActivity : AppCompatActivity() { + + private val prefs by lazy { getSharedPreferences("fleet", MODE_PRIVATE) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + if (Build.VERSION.SDK_INT >= 33) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1) + } + + val urlField = findViewById(R.id.collector_url) + val idField = findViewById(R.id.device_id) + val statusView = findViewById(R.id.status) + + urlField.setText(prefs.getString("base_url", "http://127.0.0.1:8787")) + idField.setText(prefs.getString("device_id", defaultDeviceId())) + + findViewById