diff --git a/lib/harness-qm.ts b/lib/harness-qm.ts index e49093506..01923d0f4 100644 --- a/lib/harness-qm.ts +++ b/lib/harness-qm.ts @@ -30,11 +30,12 @@ const tooLarge = (what: string) => new HarnessError('HARNESS_QM_PACK_TOO_LARGE', /** Files and bytes QM would read from the repository at ref. */ export function packSize(checkout: string, ref: string): { files: number; bytes: number } { const r = spawnSync('git', ['-C', checkout, 'ls-tree', '-r', '-l', ref], { encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 }); + if (r.status !== 0 || !ref) throw new HarnessError('HARNESS_QM_API', 'could not read the gstack checkout at ' + (ref || 'HEAD') + ' to size the pack (QM imports a git commit, so register qm needs a git checkout of gstack)'); let files = 0; let bytes = 0; for (const line of (r.stdout ?? '').split('\n')) { - const size = Number(line.split(/\s+/)[3]); - if (line.split(/\s+/)[1] !== 'blob' || !Number.isFinite(size)) continue; - files++; bytes += size; + const [mode, type, , size] = line.split(/\s+/); + if (type !== 'blob' || mode === '120000' || !Number.isFinite(Number(size))) continue; + files++; bytes += Number(size); } return { files, bytes }; } @@ -71,8 +72,6 @@ async function call(auth: QmAuth, method: string, pathname: string, payload?: un headers: { 'content-type': 'application/json', ...auth.headers(method, pathname, body) }, }); const text = await res.text(); - const cap = /pack exceeds max (bytes|files) \(\d+\)/.exec(text); - if (!res.ok && cap) throw tooLarge('QM refused the pack: ' + cap[0]); if (!res.ok) throw new HarnessError('HARNESS_QM_API', method + ' ' + pathname + ' returned HTTP ' + res.status + ': ' + text.slice(0, 200)); return text ? JSON.parse(text) : {}; } catch (e) { @@ -111,7 +110,11 @@ export async function registerQm(opts: { checkout: string; stateRoot: string; en const pack = existing ?? (await call(auth, 'POST', '/v1/admin/skill-packs', { url: GSTACK_REPO_URL, ref, config: QM_PACK_CONFIG, trustTier: 'third-party' })).pack; if (!pack?.id) throw new HarnessError('HARNESS_QM_API', 'QM did not return a pack id'); writeLedger(lfile, { meta: { id, target: 'qm', dest: auth.url, mode: 'all', checkout: opts.checkout, version: opts.version, qm_pack: pack.id, qm_ref: ref }, files: [], dirs: [], blocks: [] }); - const imported = await call(auth, 'POST', '/v1/admin/skill-packs/' + encodeURIComponent(pack.id) + '/import', { selected: 'all' }); + const imported = await call(auth, 'POST', '/v1/admin/skill-packs/' + encodeURIComponent(pack.id) + '/import', { selected: 'all' }).catch(async (e) => { + const recorded = ((await call(auth, 'GET', '/v1/admin/skill-packs').catch(() => ({}))).packs ?? []).find((p: any) => p.id === pack.id)?.lastImport; + const cap = /pack exceeds max (bytes|files) \(\d+\)/.exec(String(recorded?.error ?? '')); + throw cap ? tooLarge('QM refused the pack: ' + cap[0]) : e; + }); return { pack, existing: !!existing, imported }; }; let out; diff --git a/test/harness-register.test.ts b/test/harness-register.test.ts index e9f89b36f..4efc45b2a 100644 --- a/test/harness-register.test.ts +++ b/test/harness-register.test.ts @@ -311,7 +311,7 @@ describe('gstack-harness QM (org skill pack, against a fake admin API)', () => { return dir; } - test('refuses before sending anything when the repository is over QM\'s pack cap, and names the cap when QM refuses', async () => { + test('refuses before sending anything when the repository is over QM\'s pack cap, and names the cap QM recorded when it refuses, without a retry', async () => { const q = fakeQm(); try { const d = tmp(); @@ -322,14 +322,26 @@ describe('gstack-harness QM (org skill pack, against a fake admin API)', () => { expect(q.calls).toEqual([]); expect(fs.existsSync(path.join(d, 'state', 'harness'))).toBe(false); } finally { q.server.stop(true); } - const capped = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname.endsWith('/import') - ? new Response('{"error":"internal","message":"pack exceeds max bytes (33554432)"}', { status: 500 }) - : req.method === 'GET' ? Response.json({ packs: [] }) : Response.json({ pack: { id: 'pk1' } }) }); + const recorded: Array> = []; + const imports: string[] = []; + const capped = Bun.serve({ port: 0, fetch: async (req) => { + const u = new URL(req.url); + if (req.method === 'GET') return Response.json({ packs: recorded }); + if (u.pathname.endsWith('/import')) { + imports.push(u.pathname); + recorded[0].lastImport = { status: 'error', error: 'pack exceeds max bytes (33554432)' }; + return Response.json({ error: 'internal', message: 'internal server error' }, { status: 500 }); + } + const b = await req.json() as Record; + recorded.push({ id: 'pk1', url: b.url, ref: b.ref }); + return Response.json({ pack: recorded[0] }); + } }); try { const d = tmp(); const err = await registerQm({ checkout: smallCheckout(), stateRoot: path.join(d, 'state'), env: { GSTACK_QM_URL: 'http://127.0.0.1:' + capped.port, GSTACK_QM_ADMIN_TOKEN: 't0k' }, version: VERSION, yes: true }).catch(e => e); expect(err.code).toBe('HARNESS_QM_PACK_TOO_LARGE'); expect(err.message).toContain('pack exceeds max bytes (33554432)'); + expect(imports).toHaveLength(1); } finally { capped.stop(true); } });