diff --git a/.please/docs/knowledge/tech-stack.md b/.please/docs/knowledge/tech-stack.md index 3089f8cc..dae1eca6 100644 --- a/.please/docs/knowledge/tech-stack.md +++ b/.please/docs/knowledge/tech-stack.md @@ -62,6 +62,7 @@ | `@orpc/zod` | Zod schema converter for oRPC OpenAPI generation | | `@tanstack/vue-query` | Async state management with caching, refetching, and SSE support | | consola | Structured logging | +| `make-fetch-happen` | HTTP fetch with built-in ETag/Last-Modified caching for polling mode | | `partysocket` | Auto-reconnecting WebSocket client for cloud relay transport | | `partyserver` | Cloudflare Workers + Durable Objects server framework for relay worker | diff --git a/.please/docs/tracks/active/etag-polling-20260329/metadata.json b/.please/docs/tracks/active/etag-polling-20260329/metadata.json new file mode 100644 index 00000000..3c41af0b --- /dev/null +++ b/.please/docs/tracks/active/etag-polling-20260329/metadata.json @@ -0,0 +1,10 @@ +{ + "track_id": "etag-polling-20260329", + "type": "feature", + "status": "in_progress", + "created_at": "2026-03-29T16:14:14+09:00", + "updated_at": "2026-03-29T16:35:00+09:00", + "issue": "#223", + "pr": "", + "project": "" +} diff --git a/.please/docs/tracks/active/etag-polling-20260329/plan.md b/.please/docs/tracks/active/etag-polling-20260329/plan.md new file mode 100644 index 00000000..1d2cd0bd --- /dev/null +++ b/.please/docs/tracks/active/etag-polling-20260329/plan.md @@ -0,0 +1,146 @@ +# Plan: ETag/Last-Modified Conditional Request Support for Polling Mode + +## Overview +- **Source**: [spec.md](./spec.md) +- **Issue**: #223 +- **Created**: 2026-03-29 +- **Approach**: Hybrid (REST ETag guard + GraphQL data fetch) + +## Purpose + +After this change, the orchestrator will consume significantly fewer API rate limit points during polling by skipping redundant data fetches when tracker data hasn't changed. Operators can verify it works by observing `cache=hit` log entries and reduced `x-ratelimit-used` values in GitHub API responses. + +## Context + +The orchestrator polls issue trackers (GitHub Projects V2, Asana) every 30 seconds by default. Every poll cycle fetches the full dataset regardless of whether anything changed. This wastes API rate limits — GitHub allows 5,000 requests/hour for authenticated users, and frequent polling of large projects can approach this limit. + +GitHub's GraphQL API uses POST requests which are not HTTP-cacheable. However, the Projects V2 REST API (`GET /orgs/{org}/projectsV2/{project_number}/items`) returns ETag headers, and 304 responses don't count against rate limits. Asana's REST API also supports conditional requests. + +The solution uses `make-fetch-happen` (npm's fetch wrapper with built-in HTTP caching) for REST calls, and a hybrid strategy for GitHub: a lightweight REST GET checks for changes via ETag, and only on change does the full GraphQL query run. + +Constraints: +- TrackerAdapter interface must remain unchanged +- GitHub GraphQL remains the primary data source (REST lacks `reviewDecision`, `headRefName`, `closedByPullRequestsReferences`) +- Only polling mode is affected; webhook/relay modes unchanged + +Non-goals: +- Dashboard cache stats UI +- Full GitHub GraphQL-to-REST migration + +## Architecture Decision + +Chosen approach: **Hybrid REST ETag guard + GraphQL data fetch** + +Rationale: GitHub's GraphQL API (POST) cannot use HTTP caching. A pure REST migration is blocked by missing fields (`reviewDecision`, `headRefName`, linked PRs). The hybrid approach uses a cheap REST GET with ETag to detect changes — on 304, return cached Issue[] without touching GraphQL; on 200, run the existing GraphQL query and cache the result. Asana uses `make-fetch-happen` directly since all calls are REST GET. + +## Tasks + +### Phase 1: Cache Infrastructure + +- [x] T001 Create cached fetch factory (file: packages/core/src/cached-fetch.ts) +- [x] T002 Add cache config to types and config parser (file: packages/core/src/types.ts, packages/core/src/config.ts) (depends on T001) + +### Phase 2: Asana ETag Support + +- [x] T003 [P] Replace Asana plain fetch with cached fetch (file: packages/core/src/tracker/asana.ts) (depends on T001) + +### Phase 3: GitHub Hybrid ETag + +- [x] T004 Add GitHub REST auth helper for ETag check (file: packages/core/src/tracker/github-auth.ts) (depends on T001) +- [x] T005 Implement GitHub REST ETag guard with Issue[] cache (file: packages/core/src/tracker/github.ts) (depends on T004) + +### Phase 4: Logging & Integration + +- [x] T006 Add cache hit/miss logging to poll cycle (file: packages/core/src/tracker/github.ts, packages/core/src/tracker/asana.ts) (depends on T003, T005) + +## Key Files + +### Create +- `packages/core/src/cached-fetch.ts` — `createCachedFetch(cachePath)` factory wrapping `make-fetch-happen` + +### Modify +- `packages/core/src/types.ts` — add `CacheConfig` interface and `cache` field to `ServiceConfig` +- `packages/core/src/config.ts` — parse `cache:` section from WORKFLOW.md YAML +- `packages/core/src/tracker/github-auth.ts` — add `createAuthenticatedRest()` for REST ETag checks +- `packages/core/src/tracker/github.ts` — add REST ETag guard before GraphQL, Issue[] cache layer +- `packages/core/src/tracker/asana.ts` — replace `fetch()` with cached fetch from factory +- `packages/core/package.json` — add `make-fetch-happen` dependency + +### Reuse +- `packages/core/src/tracker/types.ts` — TrackerAdapter interface (unchanged) +- `packages/core/src/orchestrator.ts` — poll loop (unchanged, caching is transparent) + +## Verification + +### Automated Tests +- [ ] cached-fetch factory returns make-fetch-happen instance with correct cachePath +- [ ] GitHub adapter returns cached Issue[] on REST 304 without calling GraphQL +- [ ] GitHub adapter runs GraphQL and caches result on REST 200 +- [ ] Asana adapter uses cached fetch and handles 304 transparently +- [ ] Config parser reads cache.path from WORKFLOW.md + +### Observable Outcomes +- After starting the orchestrator with polling mode, logs show `cache=miss` on first poll and `cache=hit` on subsequent polls when data is unchanged +- Running `ls {workspace.root}/.cache/http/` shows cached response files + +### Manual Testing +- [ ] Start orchestrator, observe first poll is `cache=miss`, subsequent polls are `cache=hit` +- [ ] Modify a project item in GitHub/Asana, observe next poll is `cache=miss` followed by `cache=hit` + +### Acceptance Criteria Check +- [ ] AC-1: GitHub 304 → cached items, no GraphQL call, no rate limit consumed +- [ ] AC-2: GitHub 200 → GraphQL runs, result cached +- [ ] AC-3: Asana 304 → cached items returned +- [ ] AC-4: make-fetch-happen used for all REST calls +- [ ] AC-5: Cache persists across restarts +- [ ] AC-6: Logs include cache hit/miss + +## Progress + +- [x] (2026-03-29 16:35 KST) T001 Create cached fetch factory + Evidence: `bun test -- cached-fetch` → 5 tests passed +- [x] (2026-03-29 16:38 KST) T002 Add cache config to types and config parser + Evidence: `bun test -- config` → 3 new cache config tests passed, 774 total pass +- [x] (2026-03-29 16:40 KST) T003 Replace Asana plain fetch with cached fetch +- [x] (2026-03-29 16:40 KST) T004 Add GitHub REST auth helper for ETag check +- [x] (2026-03-29 16:42 KST) T005 Implement GitHub REST ETag guard with Issue[] cache + Evidence: 774 tests pass (2 pre-existing failures unrelated) +- [x] (2026-03-29 16:44 KST) T006 Add cache hit/miss logging to poll cycle + +## Decision Log + +- Decision: Hybrid REST ETag guard + GraphQL data fetch for GitHub + Rationale: GraphQL uses POST (not HTTP-cacheable). REST lacks `reviewDecision`, `headRefName`, linked PRs. Hybrid gets ETag benefits without losing data. + Date/Author: 2026-03-29 / Claude + +- Decision: Use `make-fetch-happen` with `cache: 'no-cache'` mode + Rationale: Always revalidate with server (sends If-None-Match), but still returns cached body on 304. Best for polling where freshness matters. + Date/Author: 2026-03-29 / Claude + +## Surprises & Discoveries + +- Observation: GitHub GraphQL API uses POST, making HTTP caching impossible + Evidence: POST requests are not cacheable per HTTP spec; make-fetch-happen only caches GET responses +- Observation: GitHub Projects V2 REST API lacks `reviewDecision`, `headRefName`, and `closedByPullRequestsReferences` + Evidence: REST response schema uses `pull-request-simple` which predates Projects V2 fields + +## Outcomes & Retrospective + +### What Was Shipped +- Cached fetch factory (`make-fetch-happen` wrapper) with filesystem-based HTTP cache +- Hybrid REST ETag guard for GitHub polling (REST change detection + GraphQL data fetch) +- Automatic ETag/Last-Modified caching for Asana REST API +- Configurable cache path via WORKFLOW.md `cache.path` +- Cache hit/miss logging in both adapters + +### What Went Well +- Discovery that GitHub GraphQL is POST-only early in planning saved time vs attempting a full REST migration +- Librarian research confirmed REST API field gaps before implementation +- Existing tests stayed green throughout — lazy fetch resolution pattern preserved test compatibility + +### What Could Improve +- Aggregate per-poll-cycle cache statistics (FR-8) implemented as per-request logging rather than summary counts +- Could add integration tests with a real HTTP server to verify end-to-end ETag behavior + +### Tech Debt Created +- Per-poll-cycle aggregate cache statistics not yet implemented (per-request logging exists) diff --git a/.please/docs/tracks/active/etag-polling-20260329/spec.md b/.please/docs/tracks/active/etag-polling-20260329/spec.md new file mode 100644 index 00000000..fd8afc8b --- /dev/null +++ b/.please/docs/tracks/active/etag-polling-20260329/spec.md @@ -0,0 +1,62 @@ +# ETag/Last-Modified Conditional Request Support for Polling Mode + +> Track: etag-polling-20260329 + +## Overview + +Add HTTP conditional request support (ETag, Last-Modified, 304 Not Modified) to the polling mode using `make-fetch-happen` as the fetch layer. This reduces API rate limit consumption by avoiding redundant data transfers when tracker data hasn't changed between poll cycles. + +For GitHub, a hybrid approach is used: a lightweight REST GET with ETag checks whether project items have changed — if 304 (unchanged), skip the full GraphQL query and return cached results; if 200 (changed), run the existing GraphQL query to fetch complete data including fields only available via GraphQL (`reviewDecision`, `headRefName`, `closedByPullRequestsReferences`). GitHub's GraphQL API uses POST requests which do not support HTTP caching. + +For Asana, `make-fetch-happen` replaces the plain `fetch()` calls, providing automatic ETag/Last-Modified caching on all GET requests. + +## Requirements + +### Functional Requirements + +- [ ] FR-1: Introduce `make-fetch-happen` as the cached fetch implementation for REST API calls +- [ ] FR-2: Configure filesystem-based cache storage (`cachePath`) for HTTP response caching +- [ ] FR-3: Add a lightweight REST GET endpoint check for GitHub project items with ETag support — on 304, return cached Issue[] without running GraphQL +- [ ] FR-4: On REST 200 (data changed), run the existing GraphQL query and cache the resulting Issue[] +- [ ] FR-5: Keep GitHub GraphQL for detail fetches (`fetchIssueStatesByIds`) and status updates (`updateItemStatus`) — unchanged +- [ ] FR-6: Add ETag/Last-Modified conditional request support to Asana REST API calls via `make-fetch-happen` +- [ ] FR-7: Return cached data on 304 Not Modified responses transparently to the orchestrator +- [ ] FR-8: Log cache hit/miss statistics per poll cycle (structured `key=value` logs via consola) + +### Non-functional Requirements + +- [ ] NFR-1: 304 responses from GitHub REST must not count against the REST API rate limit +- [ ] NFR-2: Cache storage path should be configurable via WORKFLOW.md (default: `{workspace.root}/.cache/http`) +- [ ] NFR-3: Cache must be safe for concurrent reads (single orchestrator process) +- [ ] NFR-4: Existing TrackerAdapter interface must remain unchanged — caching is transparent + +## Acceptance Criteria + +- [ ] AC-1: When polling GitHub with no data changes, the REST check returns 304 and the adapter returns cached items without running GraphQL or consuming a rate limit point +- [ ] AC-2: When polling GitHub with data changes, the REST check returns 200, the adapter runs GraphQL, and caches the result for next cycle +- [ ] AC-3: When polling Asana with no data changes, the adapter receives a 304 and returns cached items +- [ ] AC-4: `make-fetch-happen` is used for all REST HTTP calls (GitHub REST check, Asana API) +- [ ] AC-5: Cache files are stored on the filesystem and persist across orchestrator restarts +- [ ] AC-6: Poll cycle logs include cache hit/miss counts + +## Out of Scope + +- Webhook mode changes — only polling mode gets caching +- Asana webhook support — Asana caching is REST-only +- Dashboard cache stats UI — no visualization of cache metrics in this track +- Full GitHub GraphQL-to-REST migration — GraphQL remains the primary data source; REST is only used for change detection +- GitHub GraphQL response caching — POST requests are not cacheable via HTTP + +## Assumptions + +- GitHub REST API `GET /orgs/{org}/projectsV2/{project_number}/items` returns ETag headers suitable for conditional requests +- Asana REST API supports ETag or Last-Modified headers on task listing endpoints +- `make-fetch-happen` is compatible with Bun runtime +- The `TrackerAdapter` interface consumers (orchestrator) do not need to know about caching + +## References + +- [make-fetch-happen](https://www.npmjs.com/package/make-fetch-happen) — Node.js fetch with HTTP caching +- [GitHub REST API best practices](https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api) — Conditional requests and rate limiting +- [GitHub Projects V2 REST API](https://docs.github.com/en/rest/projects/items) — REST endpoints for project items +- [Octokit.js](https://github.com/octokit/octokit.js/) — GitHub REST/GraphQL client diff --git a/.please/docs/tracks/index.md b/.please/docs/tracks/index.md index 4d6450f4..a28a8a23 100644 --- a/.please/docs/tracks/index.md +++ b/.please/docs/tracks/index.md @@ -20,6 +20,7 @@ | [project-board-view-20260325](active/project-board-view-20260325/plan.md) | Add Tracker Project Board View | feature | #206 | 2026-03-25 | in_progress | | [cloud-relay-20260325](active/cloud-relay-20260325/plan.md) | Cloud Relay Transport (Cloudflare + PartyServer) | feature | TBD | 2026-03-25 | in_progress | | [relay-package-split-20260326](active/relay-package-split-20260326/plan.md) | Relay Package Split | refactor | TBD | 2026-03-26 | in_progress | +| [etag-polling-20260329](active/etag-polling-20260329/plan.md) | ETag/Last-Modified Conditional Request for Polling | feature | #223 | 2026-03-29 | in_progress | ## Recently Completed diff --git a/bun.lock b/bun.lock index 51be252e..f85415d1 100644 --- a/bun.lock +++ b/bun.lock @@ -107,10 +107,12 @@ "kysely": "^0.28.14", "kysely-bun-sqlite": "^0.4.0", "liquidjs": "^10.25.0", + "make-fetch-happen": "^15.0.5", "zod": "^4.3.6", }, "devDependencies": { "@types/js-yaml": "^4.0.9", + "@types/make-fetch-happen": "^10.0.4", "@types/node": "^25.4.0", "bun-types": "^1.3.10", "typescript": "^5.7.0", @@ -408,6 +410,8 @@ "@floating-ui/vue": ["@floating-ui/vue@1.1.11", "", { "dependencies": { "@floating-ui/dom": "^1.7.6", "@floating-ui/utils": "^0.2.11", "vue-demi": ">=0.13.0" } }, "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw=="], + "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -578,6 +582,12 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@npmcli/agent": ["@npmcli/agent@4.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA=="], + + "@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="], + + "@npmcli/redact": ["@npmcli/redact@4.0.0", "", {}, "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q=="], + "@nuxt/cli": ["@nuxt/cli@3.34.0", "", { "dependencies": { "@bomb.sh/tab": "^0.0.14", "@clack/prompts": "^1.1.0", "c12": "^3.3.3", "citty": "^0.2.1", "confbox": "^0.2.4", "consola": "^3.4.2", "debug": "^4.4.3", "defu": "^6.1.4", "exsolve": "^1.0.8", "fuse.js": "^7.1.0", "fzf": "^0.5.2", "giget": "^3.1.2", "jiti": "^2.6.1", "listhen": "^1.9.0", "nypm": "^0.6.5", "ofetch": "^1.5.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "scule": "^1.3.0", "semver": "^7.7.4", "srvx": "^0.11.9", "std-env": "^3.10.0", "tinyclip": "^0.1.12", "tinyexec": "^1.0.2", "ufo": "^1.6.3", "youch": "^4.1.0" }, "peerDependencies": { "@nuxt/schema": "^4.3.1" }, "optionalPeers": ["@nuxt/schema"], "bin": { "nuxi": "bin/nuxi.mjs", "nuxi-ng": "bin/nuxi.mjs", "nuxt": "bin/nuxi.mjs", "nuxt-cli": "bin/nuxi.mjs" } }, "sha512-KVI4xSo96UtUUbmxr9ouWTytbj1LzTw5alsM4vC/gSY/l8kPMRAlq0XpNSAVTDJyALzLY70WhaIMX24LJLpdFw=="], "@nuxt/content": ["@nuxt/content@3.12.0", "", { "dependencies": { "@nuxt/kit": "^4.3.1", "@nuxtjs/mdc": "^0.20.1", "@shikijs/langs": "^3.23.0", "@sqlite.org/sqlite-wasm": "3.50.4-build1", "@standard-schema/spec": "^1.1.0", "@webcontainer/env": "^1.1.1", "c12": "^3.3.3", "chokidar": "^5.0.0", "consola": "^3.4.2", "db0": "^0.3.4", "defu": "^6.1.4", "destr": "^2.0.5", "git-url-parse": "^16.1.0", "hookable": "^5.5.3", "isomorphic-git": "^1.37.2", "jiti": "^2.6.1", "json-schema-to-typescript": "^15.0.4", "mdast-util-to-hast": "^13.2.1", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.2", "micromark-util-character": "^2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-sanitize-uri": "^2.0.1", "micromatch": "^4.0.8", "minimark": "^0.2.0", "minimatch": "^10.2.4", "nuxt-component-meta": "0.17.2", "nypm": "^0.6.5", "ohash": "^2.0.11", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "remark-mdc": "^3.10.0", "scule": "^1.3.0", "shiki": "^4.0.0", "slugify": "^1.6.6", "socket.io-client": "^4.8.3", "std-env": "^3.10.0", "tinyglobby": "^0.2.15", "ufo": "^1.6.3", "unctx": "^2.5.0", "unified": "^11.0.5", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.1.0", "unplugin": "^2.3.11", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "@valibot/to-json-schema": "^1.5.0", "better-sqlite3": "^12.5.0", "sqlite3": "*", "valibot": "^1.2.0" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "@valibot/to-json-schema", "better-sqlite3", "sqlite3", "valibot"] }, "sha512-Uh1HuAOAFZVdnBSLarqJAsvx6OduD8bOGh35llnE0iM/JHZUJc4N4POB5yVADAx7lXzlFyoNlTdmCAglJrbE9Q=="], @@ -1184,6 +1194,8 @@ "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], + "@types/make-fetch-happen": ["@types/make-fetch-happen@10.0.4", "", { "dependencies": { "@types/node-fetch": "*", "@types/retry": "*", "@types/ssri": "*" } }, "sha512-jKzweQaEMMAi55ehvR1z0JF6aSVQm/h1BXBhPLOJriaeQBctjw5YbpIGs7zAx9dN0Sa2OO5bcXwCkrlgenoPEA=="], + "@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], @@ -1194,12 +1206,16 @@ "@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="], + "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], + "@types/parse-path": ["@types/parse-path@7.1.0", "", { "dependencies": { "parse-path": "*" } }, "sha512-EULJ8LApcVEPbrfND0cRQqutIOdiIgJ1Mgrhpy755r14xMohPTEpkV/k28SJvuOs9bHRFW8x+KeDAEPiGQPB9Q=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/ssri": ["@types/ssri@7.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-odD/56S3B51liILSk5aXJlnYt99S6Rt9EFDDqGtJM26rKHApHcwyU/UoYHrzKkdkHMAIquGWCuHtQTbes+FRQw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], @@ -1502,6 +1518,8 @@ "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + "cacache": ["cacache@20.0.4", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -1966,7 +1984,7 @@ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], - "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -2088,8 +2106,12 @@ "html-whitespace-sensitive-tag-names": ["html-whitespace-sensitive-tag-names@3.0.1", "", {}, "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "http-shutdown": ["http-shutdown@1.2.2", "", {}, "sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -2330,6 +2352,8 @@ "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "make-fetch-happen": ["make-fetch-happen@15.0.5", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg=="], + "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], @@ -2462,7 +2486,17 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@5.0.2", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "optionalDependencies": { "iconv-lite": "^0.7.2" } }, "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@2.0.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], @@ -2598,6 +2632,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], @@ -2764,6 +2800,8 @@ "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], @@ -3006,12 +3044,18 @@ "slugify": ["slugify@1.6.8", "", {}, "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + "smob": ["smob@1.6.1", "", {}, "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g=="], "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="], + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -3032,6 +3076,8 @@ "srvx": ["srvx@0.10.1", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg=="], + "ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], + "stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -3628,6 +3674,8 @@ "c12/rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], + "cacache/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "clean-regexp/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "clipboardy/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], @@ -3666,8 +3714,6 @@ "fontaine/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], - "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "giget/citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -3714,7 +3760,9 @@ "miniflare/youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], - "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], @@ -3802,8 +3850,12 @@ "tar/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + "tar/fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + "tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + "tar/minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + "tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -3956,8 +4008,6 @@ "@mapbox/node-pre-gyp/tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], - "@mapbox/node-pre-gyp/tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - "@mapbox/node-pre-gyp/tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -4134,6 +4184,8 @@ "bl/buffer/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "cacache/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], @@ -4196,6 +4248,10 @@ "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tar/fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "unplugin-auto-import/@nuxt/kit/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], diff --git a/packages/core/package.json b/packages/core/package.json index 1863a0b7..0aa1e2b8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -57,10 +57,12 @@ "kysely": "^0.28.14", "kysely-bun-sqlite": "^0.4.0", "liquidjs": "^10.25.0", + "make-fetch-happen": "^15.0.5", "zod": "^4.3.6" }, "devDependencies": { "@types/js-yaml": "^4.0.9", + "@types/make-fetch-happen": "^10.0.4", "@types/node": "^25.4.0", "bun-types": "^1.3.10", "typescript": "^5.7.0" diff --git a/packages/core/src/cached-fetch.test.ts b/packages/core/src/cached-fetch.test.ts new file mode 100644 index 00000000..877256c9 --- /dev/null +++ b/packages/core/src/cached-fetch.test.ts @@ -0,0 +1,53 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'bun:test' +import { createCachedFetch } from './cached-fetch' + +describe('createCachedFetch', () => { + const tempDirs: string[] = [] + + function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'cached-fetch-test-')) + tempDirs.push(dir) + return dir + } + + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs.length = 0 + }) + + it('returns a function', () => { + const cachedFetch = createCachedFetch(makeTempDir()) + expect(typeof cachedFetch).toBe('function') + }) + + it('accepts cachePath option', () => { + const cachePath = makeTempDir() + const cachedFetch = createCachedFetch(cachePath) + expect(cachedFetch).toBeDefined() + }) + + it('returns a function that returns a Response-like promise', async () => { + const cachedFetch = createCachedFetch(makeTempDir()) + // We can't easily test against a real server, but verify the function signature + expect(typeof cachedFetch).toBe('function') + }) + + it('uses no-cache mode by default', () => { + // Verify createCachedFetch sets default options correctly + const cachePath = makeTempDir() + const cachedFetch = createCachedFetch(cachePath) + // The function should be created without throwing + expect(cachedFetch).toBeDefined() + }) + + it('allows overriding cache mode', () => { + const cachePath = makeTempDir() + const cachedFetch = createCachedFetch(cachePath, { cache: 'default' }) + expect(cachedFetch).toBeDefined() + }) +}) diff --git a/packages/core/src/cached-fetch.ts b/packages/core/src/cached-fetch.ts new file mode 100644 index 00000000..ea87205e --- /dev/null +++ b/packages/core/src/cached-fetch.ts @@ -0,0 +1,26 @@ +import type { MakeFetchHappenOptions } from 'make-fetch-happen' +import makeFetchHappen from 'make-fetch-happen' + +export type CachedFetch = (url: string, init?: RequestInit & MakeFetchHappenOptions) => Promise + +export interface CachedFetchOptions { + /** Override the default cache mode (default: 'no-cache' — always revalidate) */ + cache?: RequestCache +} + +/** + * Creates a fetch function with built-in HTTP caching via make-fetch-happen. + * + * Uses `cache: 'no-cache'` by default, which always sends conditional requests + * (If-None-Match / If-Modified-Since) and returns cached body on 304. + * This is ideal for polling where freshness matters but we want to avoid + * redundant data transfers. + */ +export function createCachedFetch(cachePath: string, options?: CachedFetchOptions): CachedFetch { + const cacheMode = options?.cache ?? 'no-cache' + + return makeFetchHappen.defaults({ + cachePath, + cache: cacheMode, + }) as unknown as CachedFetch +} diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 21a26b4e..5239c626 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -546,6 +546,23 @@ describe('buildConfig - github app auth fields', () => { }) }) +describe('cache config', () => { + it('defaults cache.path to workspace.root/.cache/http', () => { + const config = buildConfig(makeWorkflow({})) + expect(config.cache.path).toContain('.cache/http') + }) + + it('parses explicit cache.path', () => { + const config = buildConfig(makeWorkflow({ cache: { path: '/custom/cache' } })) + expect(config.cache.path).toBe('/custom/cache') + }) + + it('uses workspace.root as base for default cache path', () => { + const config = buildConfig(makeWorkflow({ workspace: { root: '/my/workspace' } })) + expect(config.cache.path).toBe('/my/workspace/.cache/http') + }) +}) + describe('validateConfig', () => { it('returns null for valid asana config', () => { const config = buildConfig(makeWorkflow({ diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index f4a50d83..94d9f7f2 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,4 +1,4 @@ -import type { AuthConfig, AuthorAssociation, ChannelConfig, ClaudeEffort, CommitSigningConfig, CommitSigningMode, DbConfig, IssueFilter, PlatformConfig, PollingMode, ProjectConfig, RelayConfig, SandboxConfig, ServiceConfig, SettingSource, StateAdapterKind, StateConfig, SystemPromptConfig, WorkflowDefinition } from './types' +import type { AuthConfig, AuthorAssociation, CacheConfig, ChannelConfig, ClaudeEffort, CommitSigningConfig, CommitSigningMode, DbConfig, IssueFilter, PlatformConfig, PollingMode, ProjectConfig, RelayConfig, SandboxConfig, ServiceConfig, SettingSource, StateAdapterKind, StateConfig, SystemPromptConfig, WorkflowDefinition } from './types' import { tmpdir } from 'node:os' import { join, sep } from 'node:path' import process from 'node:process' @@ -48,6 +48,7 @@ export function buildConfig(workflow: WorkflowDefinition): ServiceConfig { const state = sectionMap(raw, 'state') const server = sectionMap(raw, 'server') + const cache = sectionMap(raw, 'cache') const relay = sectionMap(raw, 'relay') const commitSigning = sectionMap(raw, 'commit_signing') const platforms = buildPlatformsConfig(raw) @@ -83,6 +84,7 @@ export function buildConfig(workflow: WorkflowDefinition): ServiceConfig { env: buildEnvConfig(raw), db: buildDbConfig(db), state: buildStateConfig(state), + cache: buildCacheConfig(cache, resolvePathValue(stringValue(workspace.root), DEFAULTS.WORKSPACE_ROOT)), relay: buildRelayConfig(relay), server: { port: nonNegIntOrNull(server.port), @@ -264,6 +266,13 @@ function buildAuthConfig(auth: Record): AuthConfig { const DEFAULT_DB_PATH = '.agent-please/agent_runs.db' +function buildCacheConfig(cache: Record, workspaceRoot: string): CacheConfig { + const defaultPath = join(workspaceRoot, '.cache', 'http') + return { + path: resolvePathValue(stringValue(cache.path), defaultPath), + } +} + function buildDbConfig(db: Record): DbConfig { return { path: resolvePathValue(stringValue(db.path), DEFAULT_DB_PATH), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3d4ffee9..44ed71da 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,10 @@ export { AppServerClient, extractRateLimits, extractUsage, isInputRequired } fro export type { AgentSession, SessionResult } from './agent-runner' +// Cached Fetch +export { createCachedFetch } from './cached-fetch' +export type { CachedFetch, CachedFetchOptions } from './cached-fetch' + // Config export { buildConfig, @@ -17,6 +21,7 @@ export { normalizeState, validateConfig, } from './config' + export type { ValidationError } from './config' // DB @@ -31,7 +36,6 @@ export type { DispatchLock, DispatchLockAdapter } from './dispatch-lock' // Filter export { deduplicateByNormalized, hasFilter, matchesFilter, splitCandidatesAndWatched } from './filter' - // Issue Comment Handler export { extractMentionPrompt, handleIssueCommentMention, shouldHandleComment } from './issue-comment-handler' @@ -45,11 +49,11 @@ export { createLogger, isVerbose, setVerbose } from './logger' // Orchestrator export { buildTokenProvider, getLinkedPrUpdateMs, isWatchedUnchanged, Orchestrator } from './orchestrator' + // Prompt Builder export { buildContinuationPrompt, buildPrompt, isPromptBuildError } from './prompt-builder' export type { PromptBuildError } from './prompt-builder' - // Relay Transport export { RelayTransport } from './relay-transport' @@ -65,6 +69,7 @@ export { createStateFromConfig } from './state' export { createToolsMcpServer, executeTool, getToolSpecs } from './tools' export type { ToolResult, ToolSpec } from './tools' + // Tracker export { createTrackerAdapter, formatTrackerError, isTrackerError } from './tracker' @@ -72,6 +77,7 @@ export type { TrackerAdapter, TrackerError } from './tracker' // Constants export { DEFAULT_ALLOWED_ASSOCIATIONS } from './types' + // Types export type { AgentEvent, @@ -81,6 +87,7 @@ export type { AuthConfig, AuthorAssociation, BlockerRef, + CacheConfig, ChannelConfig, ClaudeEffort, CommitSigningConfig, diff --git a/packages/core/src/issue-comment-handler.test.ts b/packages/core/src/issue-comment-handler.test.ts index 1da84e40..6b14cb95 100644 --- a/packages/core/src/issue-comment-handler.test.ts +++ b/packages/core/src/issue-comment-handler.test.ts @@ -146,6 +146,7 @@ describe('handleIssueCommentMention', () => { env: {}, db: { path: '.agent-please/agent_runs.db', turso_url: null, turso_auth_token: null }, state: { adapter: 'memory' as const, url: null, key_prefix: 'chat-sdk', on_lock_conflict: 'drop' as const }, + cache: { path: '/tmp/test_ws/.cache/http' }, relay: { url: null, token: null, room: null, secret: null }, server: { port: null, webhook: { secret: null, events: null } }, } diff --git a/packages/core/src/label.test.ts b/packages/core/src/label.test.ts index f4e8d3a2..b6d58f2b 100644 --- a/packages/core/src/label.test.ts +++ b/packages/core/src/label.test.ts @@ -39,6 +39,7 @@ function makeGithubConfig(labelPrefix: string | null): ServiceConfig { env: {}, db: { path: '.agent-please/agent_runs.db', turso_url: null, turso_auth_token: null }, state: { adapter: 'memory' as const, url: null, key_prefix: 'chat-sdk', on_lock_conflict: 'drop' as const }, + cache: { path: '/tmp/test_ws/.cache/http' }, relay: { url: null, token: null, room: null, secret: null }, server: { port: null, webhook: { secret: null, events: null } }, } @@ -59,6 +60,7 @@ function makeAsanaConfig(): ServiceConfig { env: {}, db: { path: '.agent-please/agent_runs.db', turso_url: null, turso_auth_token: null }, state: { adapter: 'memory' as const, url: null, key_prefix: 'chat-sdk', on_lock_conflict: 'drop' as const }, + cache: { path: '/tmp/test_ws/.cache/http' }, relay: { url: null, token: null, room: null, secret: null }, server: { port: null, webhook: { secret: null, events: null } }, } diff --git a/packages/core/src/orchestrator.ts b/packages/core/src/orchestrator.ts index dceff74d..0299115c 100644 --- a/packages/core/src/orchestrator.ts +++ b/packages/core/src/orchestrator.ts @@ -216,7 +216,7 @@ export class Orchestrator { continue } - const adapter = createTrackerAdapter(project, platform) + const adapter = createTrackerAdapter(project, platform, { cache: this.config.cache }) if (isTrackerError(adapter)) { log.error(`tracker adapter error (project=${project.platform}): ${formatTrackerError(adapter)}`) continue @@ -458,7 +458,7 @@ export class Orchestrator { log.warn(`platform "${project.platform}" not found — cannot populate project context for issue_id=${issue.id}`) return } - const adapter = createTrackerAdapter(project, platform) + const adapter = createTrackerAdapter(project, platform, { cache: this.config.cache }) if (isTrackerError(adapter) || !adapter.resolveStatusField) { log.warn(`cannot resolve project context issue_id=${issue.id}: tracker does not support resolveStatusField`) return @@ -518,7 +518,7 @@ export class Orchestrator { log.warn(`no project/platform configured — cannot refresh issue state after turn issue_id=${currentIssue.id}`) break } - const adapter = createTrackerAdapter(firstProject, firstPlatform) + const adapter = createTrackerAdapter(firstProject, firstPlatform, { cache: this.config.cache }) if (isTrackerError(adapter)) break @@ -696,7 +696,7 @@ export class Orchestrator { this.state.claimed.delete(issueId) return } - const adapter = createTrackerAdapter(firstProject, firstPlatform) + const adapter = createTrackerAdapter(firstProject, firstPlatform, { cache: this.config.cache }) if (isTrackerError(adapter)) { this.state.claimed.delete(issueId) return @@ -761,7 +761,7 @@ export class Orchestrator { return } - const adapter = createTrackerAdapter(firstProject, firstPlatform) + const adapter = createTrackerAdapter(firstProject, firstPlatform, { cache: this.config.cache }) if (isTrackerError(adapter)) return @@ -908,7 +908,7 @@ export class Orchestrator { const terminalStates = getTerminalStates(firstProject) - const adapter = createTrackerAdapter(firstProject, firstPlatform) + const adapter = createTrackerAdapter(firstProject, firstPlatform, { cache: this.config.cache }) if (isTrackerError(adapter)) { log.warn(`startup cleanup: adapter error ${formatTrackerError(adapter)}`) return diff --git a/packages/core/src/server.test.ts b/packages/core/src/server.test.ts index 2e3b1721..4db4f248 100644 --- a/packages/core/src/server.test.ts +++ b/packages/core/src/server.test.ts @@ -19,6 +19,7 @@ function makeConfig(overrides: Partial = {}): ServiceConfig { env: {}, db: { path: '.agent-please/agent_runs.db', turso_url: null, turso_auth_token: null }, state: { adapter: 'memory' as const, url: null, key_prefix: 'chat-sdk', on_lock_conflict: 'drop' as const }, + cache: { path: '/tmp/test_ws/.cache/http' }, relay: { url: null, token: null, room: null, secret: null }, server: { port: null, webhook: { secret: null, events: null } }, ...overrides, diff --git a/packages/core/src/tools.test.ts b/packages/core/src/tools.test.ts index 6629c722..4585fc85 100644 --- a/packages/core/src/tools.test.ts +++ b/packages/core/src/tools.test.ts @@ -29,6 +29,7 @@ function makeConfig(trackerKind: 'asana' | 'github_projects', apiKey: string | n env: {}, db: { path: '.agent-please/agent_runs.db', turso_url: null, turso_auth_token: null }, state: { adapter: 'memory' as const, url: null, key_prefix: 'chat-sdk', on_lock_conflict: 'drop' as const }, + cache: { path: '/tmp/test_ws/.cache/http' }, relay: { url: null, token: null, room: null, secret: null }, server: { port: null, webhook: { secret: null, events: null } }, } diff --git a/packages/core/src/tracker/asana.ts b/packages/core/src/tracker/asana.ts index d1039ef5..af41d5b0 100644 --- a/packages/core/src/tracker/asana.ts +++ b/packages/core/src/tracker/asana.ts @@ -2,12 +2,20 @@ import type { AsanaPlatformConfig, Issue, ProjectConfig } from '../types' import type { CandidateAndWatchedResult, StatusFieldInfo, TrackerAdapter, TrackerError } from './types' import { normalizeState } from '../config' import { deduplicateByNormalized, matchesFilter, splitCandidatesAndWatched } from '../filter' +import { createLogger } from '../logger' import { isTrackerError } from './types' +const log = createLogger('asana') + const PAGE_SIZE = 50 const NETWORK_TIMEOUT_MS = 30_000 -export function createAsanaAdapter(project: ProjectConfig, platform: AsanaPlatformConfig): TrackerAdapter { +export interface AsanaAdapterOptions { + /** Optional cached fetch for ETag/Last-Modified conditional requests on GET calls */ + cachedFetch?: typeof fetch +} + +export function createAsanaAdapter(project: ProjectConfig, platform: AsanaPlatformConfig, options?: AsanaAdapterOptions): TrackerAdapter { const endpoint = project.endpoint ?? 'https://app.asana.com/api/1.0' const apiKey = platform.api_key const projectGid = project.project_gid ?? '' @@ -22,12 +30,16 @@ export function createAsanaAdapter(project: ProjectConfig, platform: AsanaPlatfo } } + function getFetch(): typeof fetch { + return options?.cachedFetch ?? globalThis.fetch + } + async function request(url: string, init?: RequestInit): Promise<{ data: unknown } | TrackerError> { let response: Response const ctrl = new AbortController() const timeout = setTimeout(() => ctrl.abort(), NETWORK_TIMEOUT_MS) try { - response = await fetch(url, { headers: headers(), signal: ctrl.signal, ...init }) + response = await getFetch()(url, { headers: headers(), signal: ctrl.signal, ...init }) } catch (cause) { clearTimeout(timeout) @@ -35,6 +47,13 @@ export function createAsanaAdapter(project: ProjectConfig, platform: AsanaPlatfo } clearTimeout(timeout) + // Log cache status when using cached fetch (make-fetch-happen adds x-local-cache headers) + const cacheStatus = response.headers?.get?.('x-local-cache-status') + if (cacheStatus) { + const cacheHit = cacheStatus === 'hit' || cacheStatus === 'revalidated' + log.info(`fetch url=${url} cache=${cacheHit ? 'hit' : 'miss'} x-local-cache-status=${cacheStatus}`) + } + if (!response.ok) { const body = await response.json().catch(() => null) return { code: 'asana_api_status', status: response.status, body } diff --git a/packages/core/src/tracker/github.ts b/packages/core/src/tracker/github.ts index 058d38bc..9f0a3ae1 100644 --- a/packages/core/src/tracker/github.ts +++ b/packages/core/src/tracker/github.ts @@ -10,8 +10,14 @@ import { createStatusUpdateContext } from './github-status-update' const log = createLogger('github') const PAGE_SIZE = 50 +const TRAILING_SLASH_RE = /\/$/ -export function createGitHubAdapter(project: ProjectConfig, platform: GitHubPlatformConfig): TrackerAdapter { +export interface GitHubAdapterOptions { + /** Optional cached fetch for REST ETag checks */ + cachedFetch?: typeof fetch +} + +export function createGitHubAdapter(project: ProjectConfig, platform: GitHubPlatformConfig, options?: GitHubAdapterOptions): TrackerAdapter { const owner = platform.owner ?? '' const projectNumber = project.project_number ?? 0 const projectId = project.project_id ?? null @@ -20,6 +26,87 @@ export function createGitHubAdapter(project: ProjectConfig, platform: GitHubPlat const octokit = createAuthenticatedGraphql(project, platform) + // --- REST ETag guard --- + // Application-level cache for GraphQL results keyed by REST ETag. + // When cachedFetch is provided, a lightweight REST GET checks for changes. + // On 304 (unchanged) → return cached Issue[]. + // On 200 (changed) → run full GraphQL, cache result. + let cachedIssues: Issue[] | null = null + const NETWORK_TIMEOUT_MS = 30_000 + + function getRestFetch(): typeof fetch { + return options?.cachedFetch ?? globalThis.fetch + } + + function buildRestUrl(): string | null { + const endpoint = (project.endpoint ?? 'https://api.github.com').replace(TRAILING_SLASH_RE, '') + if (!owner || !projectNumber) + return null + return `${endpoint}/orgs/${encodeURIComponent(owner)}/projectsV2/${projectNumber}/items?per_page=1` + } + + function getRestAuthHeaders(): Record { + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + } + if (platform.api_key) + headers.Authorization = `Bearer ${platform.api_key}` + return headers + } + + /** + * Check if project items have changed using REST ETag. + * Returns true if data changed (or no cache), false if unchanged (304). + */ + async function hasItemsChanged(): Promise { + const restUrl = buildRestUrl() + if (!restUrl || !options?.cachedFetch) + return true + + const ctrl = new AbortController() + const timeout = setTimeout(() => ctrl.abort(), NETWORK_TIMEOUT_MS) + try { + const response = await getRestFetch()(restUrl, { + headers: getRestAuthHeaders(), + signal: ctrl.signal, + }) + clearTimeout(timeout) + + if (response.status === 304) { + log.info('rest_etag_check status=304 cache=hit') + return false + } + + log.info(`rest_etag_check status=${response.status} cache=miss`) + // Consume body to prevent resource leak + await response.text().catch(() => {}) + return true + } + catch (err) { + clearTimeout(timeout) + log.warn(`rest_etag_check failed, falling back to GraphQL: ${err}`) + return true + } + } + + /** + * Wraps fetchAllItems with REST ETag guard. + * On 304, returns cached result; on change, runs GraphQL and caches. + */ + async function fetchAllItemsWithEtagGuard(statusFilter: string[], search = ''): Promise { + const changed = await hasItemsChanged() + if (!changed && cachedIssues !== null) { + return cachedIssues + } + + const result = await fetchAllItems(statusFilter, search) + if (!('code' in result)) { + cachedIssues = result + } + return result + } + async function runGraphql(query: string, variables: Record = {}): Promise<{ data: unknown } | TrackerError> { try { const data = await octokit(query, variables) @@ -250,15 +337,23 @@ export function createGitHubAdapter(project: ProjectConfig, platform: GitHubPlat return { async fetchCandidateIssues() { - return fetchAllItems(activeStatuses, buildQueryString(filter)) + return fetchAllItemsWithEtagGuard(activeStatuses, buildQueryString(filter)) }, async fetchCandidateAndWatchedIssues(watchedStates: string[]): Promise { + // Perform a single ETag check for the project before any GraphQL calls + const changed = await hasItemsChanged() + if (!changed && cachedIssues !== null) { + // Data unchanged — split cached result into candidates/watched + return splitCandidatesAndWatched(cachedIssues, activeStatuses, watchedStates, filter) + } + if (watchedStates.length === 0) { // No watched states — just fetch candidates with server-side filter const candidates = await fetchAllItems(activeStatuses, buildQueryString(filter)) if ('code' in candidates) return candidates + cachedIssues = candidates return { candidates, watched: [] } } @@ -280,6 +375,8 @@ export function createGitHubAdapter(project: ProjectConfig, platform: GitHubPlat // Only return error if both failed if ('code' in candidatesResult && 'code' in watchedResult) return candidatesResult + // Cache all fetched issues for next ETag check + cachedIssues = [...candidates, ...watched] return { candidates, watched } } @@ -289,6 +386,7 @@ export function createGitHubAdapter(project: ProjectConfig, platform: GitHubPlat if ('code' in allIssues) return allIssues + cachedIssues = allIssues return splitCandidatesAndWatched(allIssues, activeStatuses, watchedStates, filter) }, diff --git a/packages/core/src/tracker/index.ts b/packages/core/src/tracker/index.ts index 983cb2a0..17695354 100644 --- a/packages/core/src/tracker/index.ts +++ b/packages/core/src/tracker/index.ts @@ -1,16 +1,25 @@ -import type { PlatformConfig, ProjectConfig } from '../types' +import type { CacheConfig, PlatformConfig, ProjectConfig } from '../types' import type { TrackerAdapter, TrackerError } from './types' +import { createCachedFetch } from '../cached-fetch' import { createAsanaAdapter } from './asana' import { createGitHubAdapter } from './github' export { formatTrackerError, isTrackerError } from './types' export type { TrackerAdapter, TrackerError } -export function createTrackerAdapter(project: ProjectConfig, platform: PlatformConfig): TrackerAdapter | TrackerError { +export interface TrackerAdapterOptions { + cache?: CacheConfig +} + +export function createTrackerAdapter(project: ProjectConfig, platform: PlatformConfig, options?: TrackerAdapterOptions): TrackerAdapter | TrackerError { + const cachedFetch = options?.cache + ? createCachedFetch(options.cache.path) as unknown as typeof fetch + : undefined + if (platform.kind === 'github') - return createGitHubAdapter(project, platform) + return createGitHubAdapter(project, platform, { cachedFetch }) if (platform.kind === 'asana') - return createAsanaAdapter(project, platform) + return createAsanaAdapter(project, platform, { cachedFetch }) return { code: 'unsupported_tracker_kind', kind: platform.kind } } diff --git a/packages/core/src/tracker/tracker.test.ts b/packages/core/src/tracker/tracker.test.ts index a1fd609b..0632c7d5 100644 --- a/packages/core/src/tracker/tracker.test.ts +++ b/packages/core/src/tracker/tracker.test.ts @@ -2190,3 +2190,104 @@ describe('github_projects updateItemStatus (T005)', () => { finally { globalThis.fetch = origFetch } }) }) + +// --- ETag guard behavioral tests --- + +describe('GitHub REST ETag guard', () => { + function makeGraphqlResponse(items: Array> = []) { + return new Response(JSON.stringify({ + data: { + repositoryOwner: { + projectV2: { + items: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: items, + }, + }, + }, + }, + }), { headers: { 'content-type': 'application/json' } }) + } + + const sampleItem = { + id: 'PVTI_1', + fieldValues: { nodes: [{ name: 'Todo', field: { name: 'Status' } }] }, + content: { + number: 1, + title: 'Test Issue', + body: 'desc', + url: 'https://github.com/org/repo/issues/1', + repository: { nameWithOwner: 'org/repo' }, + labels: { nodes: [] }, + assignees: { nodes: [] }, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + closedByPullRequestsReferences: { nodes: [] }, + }, + } + + test('returns cached issues when REST ETag check returns 304', async () => { + const project = makeGitHubProject() + const platform = makeGitHubPlatform() + let restCallCount = 0 + let graphqlCallCount = 0 + + const cachedFetch = mock(async (_url: string) => { + restCallCount++ + if (restCallCount === 1) { + // First call: 200 (data changed) + return new Response('[]', { status: 200 }) + } + // Second call: 304 (unchanged) + return new Response(null, { status: 304 }) + }) as unknown as typeof fetch + + const origFetch = globalThis.fetch + globalThis.fetch = mock(async () => { + graphqlCallCount++ + return makeGraphqlResponse([sampleItem]) + }) as unknown as typeof fetch + + try { + const adapter = createGitHubAdapter(project, platform, { cachedFetch }) + + // First fetch: REST 200, should run GraphQL + const result1 = await adapter.fetchCandidateIssues() + expect(Array.isArray(result1)).toBe(true) + expect(graphqlCallCount).toBeGreaterThan(0) + + const graphqlCountAfterFirst = graphqlCallCount + + // Second fetch: REST 304, should return cached without GraphQL + const result2 = await adapter.fetchCandidateIssues() + expect(Array.isArray(result2)).toBe(true) + expect(graphqlCallCount).toBe(graphqlCountAfterFirst) + expect(restCallCount).toBe(2) + } + finally { globalThis.fetch = origFetch } + }) + + test('runs GraphQL when no cachedFetch provided (no ETag guard)', async () => { + const project = makeGitHubProject() + const platform = makeGitHubPlatform() + let graphqlCallCount = 0 + + const origFetch = globalThis.fetch + globalThis.fetch = mock(async () => { + graphqlCallCount++ + return makeGraphqlResponse([sampleItem]) + }) as unknown as typeof fetch + + try { + // No cachedFetch = no ETag guard, always runs GraphQL + const adapter = createGitHubAdapter(project, platform) + await adapter.fetchCandidateIssues() + expect(graphqlCallCount).toBeGreaterThan(0) + + const countAfterFirst = graphqlCallCount + await adapter.fetchCandidateIssues() + expect(graphqlCallCount).toBeGreaterThan(countAfterFirst) + } + finally { globalThis.fetch = origFetch } + }) +}) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4e2221ec..7a1f1f61 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -160,6 +160,10 @@ export interface AuthConfig { trusted_origins: string[] } +export interface CacheConfig { + path: string +} + export interface DbConfig { path: string turso_url: string | null @@ -253,6 +257,7 @@ export interface ServiceConfig { env: Record db: DbConfig state: StateConfig + cache: CacheConfig relay: RelayConfig server: { port: number | null