price changes of existing products need --allow-price-changes (play products, play/apple subscriptions, prices import): --yes and flow run --apply --yes confirm creates, first prices and other updates only; without the flag the run writes the rest, names each held change and exits 3 (declined), dry runs mark them owner, flows refuse the flag; lib/price-gate.ts, tests/price-gate.test.ts. CLI wording: console fill leaves what it cannot open or fill to the calling agent's browser tool (console plan / verify) instead of 'yourself'; other 'by hand' output points at the config; do-it-dont-ask test scans cli/ output

This commit is contained in:
CRTkafa 2026-10-12 09:38:56 +08:00
parent b5b8f2da9d
commit e01a27d7b7
33 changed files with 414 additions and 117 deletions

View file

@ -781,13 +781,18 @@ export async function resumeAt(page: Page, plan: NavStep[]): Promise<number | nu
return null;
}
/** Where the calling agent picks up when storebound's own fill cannot: its browser tool, with the plan and the read-back. */
export function agentPart(form: string): string {
return `the calling agent with its browser tool (\`storebound console plan ${form}\` has the steps and answers, \`storebound console verify ${form}\` checks the page)`;
}
function printText(res: ConsoleResult): void {
console.log(`${res.title}\n${res.url}\n`);
for (const a of res.answers) {
const tag = a.agreement ? '[you] ' : a.ownerCheck ? '[owner]' : a.certain ? '[ok] ' : '[check]';
console.log(` ${tag} ${a.label}: ${a.agreement ? '(you tick it; storebound never does)' : valueText(a)} (${a.source})${(a.certain && !a.ownerCheck) || !a.note ? '' : `\n ${a.note}`}`);
}
console.log(`\n${res.uncertain} answer(s) to check yourself.\n\nSteps:\n${res.guidance.map(g => ` - ${g}`).join('\n')}`);
console.log(`\n${res.uncertain} answer(s) the config does not prove ([check] / [owner]): ask the owner for them in one batch, put them in storebound.config.ts, run again.\n\nSteps:\n${res.guidance.map(g => ` - ${g}`).join('\n')}`);
}
export async function run(ctx: Ctx): Promise<void> {
@ -1012,7 +1017,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
if (r.signIn) return;
// refused / ambiguous: not retried on this page; "not found" / "did not appear" is retried when the step shows up
if (!/^(not found|did not appear)$/.test(r.why ?? '')) tried = `${(active() ?? b.page).url()}#${plan.findIndex(s => s.label === r.failed)}`;
warn(TAG, `could not open the form by itself at step "${r.failed}" (${r.why}). Open it yourself; storebound keeps watching and fills it when it appears:`);
warn(TAG, `could not open the form by itself at step "${r.failed}" (${r.why}). Opening it is left to ${agentPart(form)}; storebound keeps watching and fills it when it appears:`);
if (r.candidates) say(r.failed === 'the developer account' ? `developer accounts: ${r.candidates.join(' | ')} (set play.developerAccount or play.developerId to pick one)`
: `apps that match: ${r.candidates.join(' | ')} (set apple.appId / play.packageName in the config to pick one)`);
if (first) for (const s of meta.open) say(`open the form: ${s}`);
@ -1100,7 +1105,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
const open = async (key: string, what: string, loc: Locator | null): Promise<void> => {
if (!loc) return;
const n = opened.get(key) ?? 0;
if (n === 2) { opened.set(key, 3); warn(TAG, `${what} did not open after two clicks: the console UI may have changed. Open it yourself; storebound fills it when it appears`); }
if (n === 2) { opened.set(key, 3); warn(TAG, `${what} did not open after two clicks: the console UI may have changed. Opening it is left to ${agentPart(form)}; storebound fills it when it appears`); }
if (n >= 2) return;
opened.set(key, n + 1);
await flowClick(loc, form, dataTypes, say, clicked);
@ -1198,8 +1203,8 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
const own: string[] = inDialog ? await dl.getByRole('button').filter({ visible: true }).evaluateAll((els: any[]) => els.map(e => (e.getAttribute('aria-label') || e.textContent || '').trim().replace(/\s+/g, ' ')).filter(Boolean).slice(0, 10)).catch(() => []) : [];
const top = own.length ? own : dbg.topButtons(d?.summary ?? await dbg.summarize(p).catch(() => null));
say(`${expecting} is not a page storebound knows (nothing it can fill or click after ${mins(Date.now() - progressAt)}). Buttons ${own.length ? 'in the dialog' : 'there'}: ${top.length ? top.map(t => `"${t}"`).join(', ') : '(none)'}${d ? `. Debug saved to ${d.dir}` : ''}`);
if (currentType && inDialog) say(`paused on "${currentType}": this step of its dialog is new to storebound. Answer it in the browser and go on in the dialog; storebound fills the next step it knows (it stops after ${mins(idleMs)} without progress)`);
say(`do this part yourself; storebound keeps watching and goes on when it sees a page it knows (it stops after ${mins(idleMs)} without progress):`);
if (currentType && inDialog) say(`paused on "${currentType}": this step of its dialog is new to storebound. It is left to ${agentPart(form)}: answer it and go on in the dialog; storebound fills the next step it knows (it stops after ${mins(idleMs)} without progress)`);
say(`this part is left to ${agentPart(form)}; storebound keeps watching and goes on when it sees a page it knows (it stops after ${mins(idleMs)} without progress):`);
for (const g of res.guidance) say(` ${g}`);
};
/** Every round: a heartbeat line, the step watchdog (not while the owner answers or signs in), the idle limit. */
@ -1254,10 +1259,10 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
misses = 0;
// the miss lasts: fill every field that was found, leave the missing ones to the owner (only all-missing is a UI change)
if (s.fallback) { try { s = await fillPage(p, def, pageAnswers, done, true, currentType); } catch { await Bun.sleep(tick); continue; } }
if (!s.fallback && s.missing.length) warn(TAG, `${s.title}: could not find ${s.missing.map(id => labels.get(id) ?? id).join(', ')} on the page (the console UI may have changed): fill ${s.missing.length === 1 ? 'it' : 'them'} yourself`);
if (!s.fallback && s.missing.length) warn(TAG, `${s.title}: could not find ${s.missing.map(id => labels.get(id) ?? id).join(', ')} on the page (the console UI may have changed): filling ${s.missing.length === 1 ? 'it' : 'them'} is left to ${agentPart(form)}`);
if (s.fallback) {
fallback = true;
warn(TAG, `the page does not match what storebound expects (${s.missing.join(', ')}): the console UI changed. Nothing more is filled; follow these steps:`);
warn(TAG, `the page does not match what storebound expects (${s.missing.join(', ')}): the console UI changed. Nothing more is filled; the rest is left to ${agentPart(form)}, following these steps:`);
for (const g of res.guidance) say(` ${g}`);
break;
}