desktop reads everything by itself: one background read scheduler (4 per store, starts spaced per account, App Store Connect X-Rate-Limit slows it below 20 % and pauses at 100 left, backoff with jitter on 429 / RESOURCE_EXHAUSTED, visible app first, one read per key), per-method TTLs over the saved reads, Settings auto-read (on) and interval (15 min, paused while hidden), Refresh all, a refresh icon and 'read 3 min ago' / 'couldn't refresh' per card, writes invalidate, status line with progress and quota; quota reaches the window through ToolResult.quota (cli/lib/quota.ts); Health top issues open with versions, sample stack (health --issue / --signature, read health_issue), Open in Play Console, Copy for AI and likely-cause hints (cli/lib/crash-hints.ts); tests/scheduler.test.ts, tests/quota-crash-hints.test.ts, boot check auto-read case

This commit is contained in:
CRTkafa 2026-10-13 23:36:30 +08:00
parent 89256f1e80
commit 0fb4ce0001
46 changed files with 1531 additions and 141 deletions

View file

@ -0,0 +1,77 @@
// How long a saved read stays fresh, which store lanes a read uses, and the reads the background reader does for
// every app. Pure (tests/scheduler.test.ts).
import type { Lane } from './scheduler';
const MIN = 60_000;
/** Time to live of a saved read per method: status and submission state 5 min, reviews 15 min, catalog / prices /
* listings 30 min, health and analytics 6 h (the stores update them daily). */
export const TTL: Record<string, number> = {
'portfolio.status': 5 * MIN,
'apple.status': 5 * MIN,
'play.status': 5 * MIN,
'apple.testflight_status': 5 * MIN,
'reviews.list': 15 * MIN,
'catalog.diff': 30 * MIN,
'prices.current': 30 * MIN,
'project.listings': 30 * MIN,
'apple.ppo_status': 30 * MIN,
validate: 30 * MIN,
scan: 30 * MIN,
health: 6 * 60 * MIN,
'health.issue': 6 * 60 * MIN,
'analytics.compare': 6 * 60 * MIN,
'aso.audit': 6 * 60 * MIN,
};
export const DEFAULT_TTL = 30 * MIN;
export const ttlOf = (method: string): number => TTL[method] ?? DEFAULT_TTL;
/** True when a read saved at `at` (ISO) is older than its TTL, or there is none. */
export function isStale(method: string, at: string | null | undefined, now = Date.now()): boolean {
if (!at) return true;
const t = Date.parse(at);
return !Number.isFinite(t) || now - t >= ttlOf(method);
}
/** The store lanes a read takes a slot in. */
export function lanesOf(method: string, o: { ios: boolean; android: boolean }): Lane[] {
if (method.startsWith('apple.')) return ['apple'];
if (method.startsWith('play.')) return ['play'];
if (['project.listings', 'scan', 'analytics.compare', 'aso.audit', 'reviews.drafts', 'project.declarations', 'project.renders', 'project.icon'].includes(method)) return ['local'];
const l: Lane[] = [];
if (o.ios) l.push('apple');
if (o.android) l.push('play');
return l.length ? l : ['local'];
}
export interface PlannedRead { method: string; args: Record<string, unknown> }
/** The reads the background reader does for one app (the ones its tabs show), by what the app has. */
export function readsFor(o: { folder: boolean; ios: boolean; android: boolean }): PlannedRead[] {
const r: PlannedRead[] = [];
const add = (method: string, args: Record<string, unknown> = {}) => r.push({ method, args });
if (o.ios) { add('apple.status'); add('apple.testflight_status'); }
if (o.android) add('play.status');
if (o.ios || o.android) { add('reviews.list', { days: 30 }); add('health'); }
if (o.folder) {
add('scan'); add('project.listings');
if (o.ios || o.android) { add('catalog.diff'); add('prices.current'); add('validate'); }
add('analytics.compare'); add('aso.audit');
}
if (o.ios) add('apple.ppo_status');
return r;
}
/** Key of one read in the scheduler and the read bus. */
export const readKey = (target: string, method: string, args: Record<string, unknown> = {}): string => `${target}\u001f${method}\u001f${JSON.stringify(args)}`;
/** "429", "RESOURCE_EXHAUSTED", "rate limit": the store refused for its quota (not the read itself). */
export const looksThrottled = (text: string | undefined): boolean => !!text && /\b429\b|RESOURCE_EXHAUSTED|rate.?limit|quota exceeded/i.test(text);
/** The one-line reason of a failed read, for "couldn't refresh: <reason>". */
export function oneLine(text: string, max = 140): string {
const l = text.trim().split('\n').map(s => s.trim()).filter(Boolean).at(-1) ?? '';
const s = l.replace(/^\S+ \[[^\]]+\] /, '');
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
}