Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .please/docs/knowledge/tech-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
10 changes: 10 additions & 0 deletions .please/docs/tracks/active/etag-polling-20260329/metadata.json
Original file line number Diff line number Diff line change
@@ -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": ""
}
146 changes: 146 additions & 0 deletions .please/docs/tracks/active/etag-polling-20260329/plan.md
Original file line number Diff line number Diff line change
@@ -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)
62 changes: 62 additions & 0 deletions .please/docs/tracks/active/etag-polling-20260329/spec.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .please/docs/tracks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading