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

@ -14,7 +14,7 @@ import type { Ctx } from './lib/ctx';
import { REPORTING_SCOPE } from './lib/google-auth';
import {
type AppleHealth, ANR_METRICS, betaCrashCounts, CRASH_METRICS, deviceBreakdown, healthFindings, healthMarkdown, type HealthSnapshot, healthTable,
issuesQuery, latestDailyEnd, metricQuery, parseIssues, parseMetricRows, parsePerfPower, type PlayHealth, ymd, ymdString,
type IssueSample, issuesQuery, latestDailyEnd, metricQuery, parseDiagnosticLogs, parseErrorReports, parseIssues, parseMetricRows, parsePerfPower, type PlayHealth, reportsQuery, ymd, ymdString,
} from './lib/health';
import { done, log, printJson, warn } from './lib/log';
import { plainError, storeReadError } from './lib/google-errors';
@ -55,7 +55,7 @@ export async function collectAppleHealth(root: string, bundleId: string, appIdHi
for (const b of builds) {
log('health', `apple: diagnostic signatures of build ${b.attributes.version}`);
for (const s of ((await c.asc('GET', `/v1/builds/${b.id}/diagnosticSignatures?limit=10`).catch(none({ data: [] }))).data ?? []) as J[])
signatures.push({ build: b.attributes.version, type: s.attributes.diagnosticType, signature: s.attributes.signature ?? '', weight: Number(s.attributes.weight ?? 0) });
signatures.push({ build: b.attributes.version, type: s.attributes.diagnosticType, signature: s.attributes.signature ?? '', weight: Number(s.attributes.weight ?? 0), ...(s.id ? { id: String(s.id) } : {}) });
}
signatures.sort((a, b) => b.weight - a.weight);
log('health', 'apple: TestFlight crash reports');
@ -65,6 +65,24 @@ export async function collectAppleHealth(root: string, bundleId: string, appIdHi
return { metrics, signatures: signatures.slice(0, 15), betaCrashes: betaCrashCounts(subs, buildNo, since) };
}
/** One sample report of a Play error issue (`--issue <id>`, errorReports:search over the last `days`). */
export async function playIssueSample(root: string, pkg: string, issueId: string, days: number): Promise<IssueSample | null> {
const g = googleClient(root, REPORTING_SCOPE);
const app = `${REPORTING_API}/apps/${encodeURIComponent(pkg)}`;
const set = await g.json<Parameters<typeof latestDailyEnd>[0]>('GET', `${app}/crashRateMetricSet`);
const end = latestDailyEnd(set) ?? ymd(new Date(), 2);
log('health', `play: a sample report of issue ${issueId}`);
return parseErrorReports(await g.json('GET', `${app}/errorReports:search?${reportsQuery(end, days, issueId)}`));
}
/** The logs of one App Store diagnostic signature (`--signature <id>`). */
export async function appleSignatureSample(root: string, bundleId: string, appIdHint: string | undefined, id: string): Promise<IssueSample | null> {
if (!/^[A-Za-z0-9_-]{1,200}$/.test(id)) throw new Error('--signature: a diagnostic signature id');
const c = await ascClient(root, bundleId, appIdHint);
log('health', `apple: logs of diagnostic signature ${id}`);
return parseDiagnosticLogs(await c.getAs(`/v1/diagnosticSignatures/${encodeURIComponent(id)}/logs?limit=1`, 'application/vnd.apple.diagnostic-logs+json'));
}
/** Both stores (or the chosen one); a store that cannot be read becomes { error } instead of failing the run. */
export async function collectHealth(ctx: Ctx, o: { apple: boolean; play: boolean; days: number }): Promise<HealthSnapshot> {
const { config, root } = ctx.loaded;
@ -91,10 +109,23 @@ export async function run(ctx: Ctx): Promise<void> {
const weekly = a.has('weekly');
const days = a.num('days', 7);
if (!(days >= 1 && days <= 90)) throw new Error('--days: 1-90');
const issue = a.get('issue'), signature = a.get('signature');
if (issue || signature) {
// one issue's sample stack (the desktop's Health detail): --issue <Play error issue id> or --signature <App Store id>
const { config, root } = ctx.loaded;
if (issue && !config.play) throw new Error('--issue: the config has no play section');
if (signature && !config.apple) throw new Error('--signature: the config has no apple section');
const sample = issue ? await playIssueSample(root, config.play!.packageName, issue, Math.max(days, 28)) : await appleSignatureSample(root, config.apple!.bundleId, config.apple!.appId, signature!);
if (a.has('json')) { printJson({ sample }); return; }
console.log(sample ? [`${sample.store} ${sample.type ?? ''} ${sample.version ?? ''} ${sample.device ?? ''} ${sample.os ?? ''}`.replace(/\s+/g, ' ').trim(), ...sample.stack].join('\n') : 'no sample report in the window');
return;
}
const h = await collectHealth(ctx, { apple: both || a.has('apple'), play: both || a.has('play'), days });
if (a.has('json')) { printJson(h); return; }
console.log(healthTable(h));
for (const [s, x] of [['Google Play', h.play], ['App Store', h.apple]] as const) if (x && 'error' in x) console.log(`\n${s}: ${x.error}`);
const play = h.play && !('error' in h.play) ? h.play : null;
for (const i of play?.issues.filter(x => x.hint) ?? []) console.log(`\nlikely cause of ${i.cause.slice(0, 80)}: ${i.hint!.cause}\nfix: ${i.hint!.fix}`);
console.log('\n' + formatFindings(healthFindings(h)));
if (weekly) {
const dir = ctx.loaded.resolve(a.get('out') ?? '.storebound/health');