auto-read after the rebase: results through usable / failureText, issuesError notice kept, scheduler 'accounts' (no credentials word in the webview), fixtures and generated tables for health_issue

This commit is contained in:
CRTkafa 2026-10-13 23:41:36 +08:00
parent 0fb4ce0001
commit 7b05e4b396
8 changed files with 34 additions and 27 deletions

View file

@ -42,8 +42,8 @@ export interface Outcome {
export interface ReadTask<R> {
key: string;
lanes: Lane[];
/** Keys of the store credentials it uses ("apple:<account>", "play:<account>"). */
credentials: string[];
/** Keys of the store accounts it uses ("apple:<account>", "play:<account>"). */
accounts: string[];
priority: Priority;
run: () => Promise<R>;
}
@ -175,7 +175,7 @@ export class ReadScheduler {
private ready(i: Item<unknown>, now: number): number {
// 0 = can start now; otherwise the time it can (Infinity: waits on a lane slot)
let at = i.notBefore;
for (const c of i.task.credentials) at = Math.max(at, this.nextAt.get(c) ?? 0);
for (const c of i.task.accounts) at = Math.max(at, this.nextAt.get(c) ?? 0);
if (i.task.lanes.some(l => this.busy[l] >= Math.max(1, this.o.limits[l]))) return Infinity;
return at <= now ? 0 : at;
}
@ -204,7 +204,7 @@ export class ReadScheduler {
this.queue = this.queue.filter(x => x !== i);
this.running.set(i.task.key, i);
for (const l of i.task.lanes) this.busy[l]++;
for (const c of i.task.credentials) this.nextAt.set(c, now + this.gap(c, now));
for (const c of i.task.accounts) this.nextAt.set(c, now + this.gap(c, now));
i.attempt++;
this.emit();
i.task.run().then(r => this.finish(i, r, undefined, true), e => this.finish(i, undefined, e, false));
@ -227,7 +227,7 @@ export class ReadScheduler {
const exp = Math.min(this.o.backoffMaxMs, this.o.backoffBaseMs * 2 ** (i.attempt - 1));
const delay = Math.round(exp / 2 + this.clock.random() * exp / 2); // "equal jitter"
i.notBefore = now + delay;
for (const c of i.task.credentials) this.nextAt.set(c, Math.max(this.nextAt.get(c) ?? 0, now + delay));
for (const c of i.task.accounts) this.nextAt.set(c, Math.max(this.nextAt.get(c) ?? 0, now + delay));
this.queue.push(i);
} else {
this.done++;

View file

@ -46,7 +46,7 @@ export function classifyRead(result: unknown, error: unknown, task: ReadTask<unk
const q = r?.quota;
const out: Outcome = {};
if (q?.store === 'apple' && typeof q.limit === 'number' && typeof q.remaining === 'number')
out.quota = { credential: task.credentials.find(c => c.startsWith('apple:')) ?? 'apple:default', limit: q.limit, remaining: q.remaining };
out.quota = { credential: task.accounts.find(c => c.startsWith('apple:')) ?? 'apple:default', limit: q.limit, remaining: q.remaining };
if (r && !r.ok && (q?.throttled || looksThrottled(r.output))) out.throttled = true;
if (error !== undefined && looksThrottled(errorText(error))) out.throttled = true;
return out;
@ -241,7 +241,7 @@ export function DataProvider({ children }: { children: ReactNode }) {
const focusRef = useRef<string | null>(null);
const setFocus = useCallback((key: string | null) => { focusRef.current = key; }, []);
const credentialsOf = (a: UnifiedApp | undefined, lanes: string[]): string[] => {
const accountsOf = (a: UnifiedApp | undefined, lanes: string[]): string[] => {
const c: string[] = [];
if (lanes.includes('apple')) c.push(`apple:${a?.project?.appleAccount ?? a?.ios?.account ?? 'default'}`);
if (lanes.includes('play')) c.push(`play:${a?.project?.playAccount ?? a?.android?.account ?? 'default'}`);
@ -254,7 +254,7 @@ export function DataProvider({ children }: { children: ReactNode }) {
const app = appsRef.current.find(a => a.target === target);
const pf = platformsOf(app, app ? statusRef.current(app) : undefined);
const lanes = lanesOf(method, pf);
const p = sched.enqueue<ToolResult>({ key, lanes, credentials: credentialsOf(app, lanes), priority, run: () => api.call<ToolResult>(method, target, { args }, newJob(method)) });
const p = sched.enqueue<ToolResult>({ key, lanes, accounts: accountsOf(app, lanes), priority, run: () => api.call<ToolResult>(method, target, { args }, newJob(method)) });
emitRead(key, { kind: 'start' });
p.then(result => emitRead(key, { kind: 'done', result }), e => emitRead(key, { kind: 'error', error: errorText(e) }));
return p;
@ -263,7 +263,7 @@ export function DataProvider({ children }: { children: ReactNode }) {
const readAll = useCallback(async (force = false) => {
if (!force && !settingsRef.current.refresh.autoRead) return;
if (force || isStale('portfolio.status', liveRef.current?.at)) {
sched.enqueue({ key: '*\u001fportfolio.status', lanes: ['apple', 'play'], credentials: [], priority: 1, run: () => refreshPortfolio(true, true) }).catch(() => {});
sched.enqueue({ key: '*\u001fportfolio.status', lanes: ['apple', 'play'], accounts: [], priority: 1, run: () => refreshPortfolio(true, true) }).catch(() => {});
}
const list = [...appsRef.current].filter(a => !a.ignored && !a.project?.missing)
.sort((a, b) => Number(b.key === focusRef.current) - Number(a.key === focusRef.current));

View file

@ -36,7 +36,7 @@ const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve()
function controlled() {
const runs: { key: string; done: (v: unknown) => void; fail: (e: unknown) => void }[] = [];
const task = (key: string, o: Partial<ReadTask<unknown>> = {}): ReadTask<unknown> => ({
key, lanes: ['apple'], credentials: ['apple:k1'], priority: 2, ...o,
key, lanes: ['apple'], accounts: ['apple:k1'], priority: 2, ...o,
run: () => new Promise((done, fail) => { runs.push({ key, done, fail }); }),
});
return { runs, task };
@ -48,8 +48,8 @@ describe('ReadScheduler', () => {
const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 2, play: 1 } });
const { runs, task } = controlled();
for (const k of ['a', 'b', 'c', 'd']) s.enqueue(task(k)).catch(() => {});
s.enqueue(task('p1', { lanes: ['play'], credentials: ['play:k'] })).catch(() => {});
s.enqueue(task('p2', { lanes: ['play'], credentials: ['play:k'] })).catch(() => {});
s.enqueue(task('p1', { lanes: ['play'], accounts: ['play:k'] })).catch(() => {});
s.enqueue(task('p2', { lanes: ['play'], accounts: ['play:k'] })).catch(() => {});
await clock.advance(0);
expect(runs.map(r => r.key)).toEqual(['a', 'b', 'p1']);
expect(s.stats()).toMatchObject({ running: 3, queued: 3, total: 6 });
@ -60,8 +60,8 @@ describe('ReadScheduler', () => {
const both = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1, play: 1 } });
const c2 = controlled();
both.enqueue(c2.task('apple-only')).catch(() => {});
both.enqueue(c2.task('both', { lanes: ['apple', 'play'], credentials: [] })).catch(() => {});
both.enqueue(c2.task('play-only', { lanes: ['play'], credentials: [] })).catch(() => {});
both.enqueue(c2.task('both', { lanes: ['apple', 'play'], accounts: [] })).catch(() => {});
both.enqueue(c2.task('play-only', { lanes: ['play'], accounts: [] })).catch(() => {});
await clock.advance(0);
expect(c2.runs.map(r => r.key)).toEqual(['apple-only', 'play-only']);
});
@ -100,12 +100,12 @@ describe('ReadScheduler', () => {
expect(runs).toHaveLength(2);
});
test('starts on one credential are spaced; other credentials are not held up', async () => {
test('starts on one credential are spaced; other accounts are not held up', async () => {
const clock = new FakeClock();
const s = new ReadScheduler({ clock, spacingMs: 400, limits: { apple: 8 } });
const { runs, task } = controlled();
for (const k of ['a', 'b', 'c']) s.enqueue(task(k)).catch(() => {});
s.enqueue(task('other', { credentials: ['apple:k2'] })).catch(() => {});
s.enqueue(task('other', { accounts: ['apple:k2'] })).catch(() => {});
await clock.advance(0);
expect(runs.map(r => r.key)).toEqual(['a', 'other']);
await clock.advance(399);
@ -122,10 +122,10 @@ describe('ReadScheduler', () => {
const classify = (r: unknown): Outcome => ({ throttled: (r as { status: number }).status === 429 });
const s = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 1000, backoffMaxMs: 60_000, maxAttempts: 4, classify });
const starts: number[] = [];
const p = s.enqueue({ key: 'x', lanes: ['play'], credentials: ['play:k'], priority: 0, run: async () => { starts.push(clock.now()); attempts++; return { status: attempts < 3 ? 429 : 200 }; } });
const p = s.enqueue({ key: 'x', lanes: ['play'], accounts: ['play:k'], priority: 0, run: async () => { starts.push(clock.now()); attempts++; return { status: attempts < 3 ? 429 : 200 }; } });
let other = false;
await clock.advance(0);
s.enqueue({ key: 'y', lanes: ['play'], credentials: ['play:k'], priority: 2, run: async () => { other = true; return { status: 200 }; } }).catch(() => {});
s.enqueue({ key: 'y', lanes: ['play'], accounts: ['play:k'], priority: 2, run: async () => { other = true; return { status: 200 }; } }).catch(() => {});
await clock.advance(0);
// first try at 0; jitter (rand 0.5) -> 0.75 x the exponential step: 750 ms, then 1500 ms
expect(starts).toEqual([0]);
@ -143,7 +143,7 @@ describe('ReadScheduler', () => {
// always 429: resolved with the last answer after maxAttempts, counted as failed
const s2 = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 10, maxAttempts: 3, classify });
let n = 0;
const q = s2.enqueue({ key: 'z', lanes: ['play'], credentials: ['play:k'], priority: 0, run: async () => { n++; return { status: 429 }; } });
const q = s2.enqueue({ key: 'z', lanes: ['play'], accounts: ['play:k'], priority: 0, run: async () => { n++; return { status: 429 }; } });
await clock.advance(1000);
expect(await q).toEqual({ status: 429 });
expect(n).toBe(3);
@ -152,11 +152,11 @@ describe('ReadScheduler', () => {
test('App Store Connect quota: spreads the rest of the hour when it runs low, pauses at the reserve', async () => {
const clock = new FakeClock();
let remaining = 3412;
const classify = (_r: unknown, _e: unknown, t: ReadTask<unknown>): Outcome => ({ quota: { credential: t.credentials[0]!, limit: 3600, remaining } });
const classify = (_r: unknown, _e: unknown, t: ReadTask<unknown>): Outcome => ({ quota: { credential: t.accounts[0]!, limit: 3600, remaining } });
const s = new ReadScheduler({ clock, spacingMs: 100, reserve: 100, slowBelow: 0.2, pauseMs: 600_000, limits: { apple: 4 }, classify });
const starts: number[] = [];
const run = async () => { starts.push(clock.now()); return {}; };
const add = (k: string) => s.enqueue({ key: k, lanes: ['apple'], credentials: ['apple:k1'], priority: 2, run }).catch(() => {});
const add = (k: string) => s.enqueue({ key: k, lanes: ['apple'], accounts: ['apple:k1'], priority: 2, run }).catch(() => {});
add('a'); add('b');
await clock.advance(0);
await clock.advance(100);

View file

@ -124,6 +124,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'customerReviewResponses_createInstance': 'lib/reviews',
'devices_createInstance': 'apple/adhoc',
'devices_getCollection': 'apple/adhoc',
'diagnosticSignatures_logs_getToManyRelated': 'health',
'endAppAvailabilityPreOrders_createInstance': 'lib/offers',
'financeReports_getCollection': 'lib/reports',
'inAppPurchaseAppStoreReviewScreenshots_createInstance': 'apple/iap-review',
@ -241,6 +242,6 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'playdeveloperreporting.vitals.crashrate.get': 'health',
'playdeveloperreporting.vitals.crashrate.query': 'health',
'playdeveloperreporting.vitals.errors.issues.search': 'health triage',
'playdeveloperreporting.vitals.errors.reports.search': 'triage',
'playdeveloperreporting.vitals.errors.reports.search': 'health triage',
},
};

View file

@ -402,8 +402,10 @@
"health": [
"apple",
"days",
"issue",
"out",
"play",
"signature",
"weekly"
],
"icons": [],
@ -746,6 +748,7 @@
"flutter",
"godot",
"include-drafts",
"issue",
"jobs",
"lang",
"no-health",
@ -755,6 +758,7 @@
"play",
"prune",
"react-native",
"signature",
"src",
"unity",
"variant",
@ -865,6 +869,7 @@
"internal-only",
"invite",
"ios",
"issue",
"issues",
"jobs",
"keep",
@ -943,6 +948,7 @@
"sdk-dirs",
"search",
"seconds",
"signature",
"since",
"size",
"sku",

View file

@ -12,7 +12,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| API | operations | GET | other | wrapped by a command | owner only |
|---|---:|---:|---:|---:|---:|
| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 199 | 166 |
| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 200 | 166 |
| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 33 | 49 |
| Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 |
@ -757,7 +757,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `devices_getInstance` | GET | `/v1/devices/{id}` | read | |
| `devices_updateInstance` | PATCH | `/v1/devices/{id}` | write (dry by default) | |
| **diagnosticSignatures** (1) | | | | |
| `diagnosticSignatures_logs_getToManyRelated` | GET | `/v1/diagnosticSignatures/{id}/logs` | read | |
| `diagnosticSignatures_logs_getToManyRelated` | GET | `/v1/diagnosticSignatures/{id}/logs` | read | health |
| **endAppAvailabilityPreOrders** (1) | | | | |
| `endAppAvailabilityPreOrders_createInstance` | POST | `/v1/endAppAvailabilityPreOrders` | write (dry by default) | lib/offers |
| **endUserLicenseAgreements** (6) | | | | |
@ -1750,7 +1750,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| **playdeveloperreporting.vitals.errors.issues** (1) | | | | |
| `playdeveloperreporting.vitals.errors.issues.search` | GET | `v1beta1/{+parent}/errorIssues:search` | read | health, triage |
| **playdeveloperreporting.vitals.errors.reports** (1) | | | | |
| `playdeveloperreporting.vitals.errors.reports.search` | GET | `v1beta1/{+parent}/errorReports:search` | read | triage |
| `playdeveloperreporting.vitals.errors.reports.search` | GET | `v1beta1/{+parent}/errorReports:search` | read | health, triage |
| **playdeveloperreporting.vitals.excessivewakeuprate** (2) | | | | |
| `playdeveloperreporting.vitals.excessivewakeuprate.get` | GET | `v1beta1/{+name}` | read | |
| `playdeveloperreporting.vitals.excessivewakeuprate.query` | POST | `v1beta1/{+name}:query` | read | |

View file

@ -238,7 +238,7 @@ describe('serve: store-only apps (virtual)', () => {
const r = await s.call('reviews.list', { virtual: V, args: { days: 30 }, credentials: APPLE });
expect(r.error).toBeUndefined();
expect(existsSync(join(s.calls[0]!.o.cwd, 'storebound.config.json'))).toBe(true);
expect([...VIRTUAL_READS].sort()).toEqual(['apple_ppo_status', 'apple_status', 'apple_testflight_status', 'health', 'play_status', 'reviews_list']);
expect([...VIRTUAL_READS].sort()).toEqual(['apple_ppo_status', 'apple_status', 'apple_testflight_status', 'health', 'health_issue', 'play_status', 'reviews_list']);
for (const m of ['play.listing', 'catalog.diff', 'project.listings', 'check', 'reviews.approve'])
expect((await s.call(m, { virtual: V, args: m === 'reviews.approve' ? { file: 'apple-1.md' } : {} })).error).toMatchObject({ code: ERR.invalidParams });
expect((await s.call('health', { virtual: V, project: '/tmp' })).error).toMatchObject({ code: ERR.invalidParams });

View file

@ -187,7 +187,7 @@ describe('health with no field data yet', () => {
}) as typeof fetch;
const h = await collectAppleHealth(mkdtempSync(join(tmpdir(), 'sb-real-')), 'com.example.cutter', '6400000401', 7);
expect(h.metrics).toEqual([]);
expect(h.signatures).toEqual([{ build: '10', type: 'HANGS', signature: 'main thread', weight: 0.4 }]);
expect(h.signatures).toEqual([{ build: '10', type: 'HANGS', signature: 'main thread', weight: 0.4, id: 's1' }]);
});
test('other errors still make the store unreadable', async () => {
ascKey();