From d405de3bdd71c6b8825cc0565b106d55d89d3614 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Thu, 10 Sep 2026 13:48:42 +0800 Subject: [PATCH 1/3] perf(runtime-host): budget paginated JSON incrementally Replace repeated candidate-page copies and full-page serialization with a shared incremental UTF-8 JSON budget across 16 pagination builders. Preserve projection, cursor reservation, limits and protocol validation. Cover exact byte boundaries, escaped Unicode and complete pagination in shared budget tests and existing coordinator and protocol test suites. Refs #5038 Generated-by: OpenAI Codex --- .../__tests__/artifact-coordinator.test.ts | 76 ++++++++++ .../external-session-coordinator.test.ts | 30 ++++ .../src/__tests__/fixtures/json-pages.ts | 48 ++++++ .../__tests__/json-array-page-budget.test.ts | 70 +++++++++ .../src/__tests__/memory-coordinator.test.ts | 73 +++++++++ .../src/__tests__/plan-two-client-uds.test.ts | 91 +++++++++++ .../src/__tests__/plugin-platform.test.ts | 61 +++++++- .../project-catalog-coordinator.test.ts | 88 +++++++++++ .../project-directory-authority.test.ts | 69 +++++++++ .../runtime-policy-coordinator.test.ts | 23 +++ .../runtime-resource-coordinator.test.ts | 60 ++++++++ ...cheduled-task-coordinator-recovery.test.ts | 89 +++++++++++ .../session-catalog-coordinator.test.ts | 81 +++++++--- .../skill-catalog-repository.test.ts | 77 ++++++++++ .../usage-pricing-two-client-uds.test.ts | 141 ++++++++++++++++++ .../src/server/artifact-coordinator.ts | 21 +-- .../server/external-session-coordinator.ts | 15 +- .../src/server/json-array-page-budget.ts | 52 +++++++ .../src/server/memory-projection.ts | 30 ++-- .../src/server/plan-coordinator.ts | 17 ++- .../src/server/plugin-platform-coordinator.ts | 20 ++- .../src/server/project-catalog-coordinator.ts | 24 ++- .../src/server/project-directory-authority.ts | 13 +- .../src/server/runtime-policy-coordinator.ts | 28 ++-- .../src/server/runtime-resource-projection.ts | 19 +-- .../src/server/scheduled-task-coordinator.ts | 19 +-- .../src/server/session-catalog-coordinator.ts | 16 +- .../src/server/skill-catalog-repository.ts | 34 ++--- .../src/server/usage-pricing-coordinator.ts | 64 +++----- 29 files changed, 1264 insertions(+), 185 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/fixtures/json-pages.ts create mode 100644 packages/runtime-host/src/__tests__/json-array-page-budget.test.ts create mode 100644 packages/runtime-host/src/server/json-array-page-budget.ts diff --git a/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts b/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts index 8e0dd1e7a5..51f2f5e4a7 100644 --- a/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts @@ -17,6 +17,15 @@ * under the License. */ +import { encodeArtifactProjection } from '../protocol/artifact.js'; +import { assertMaximalJsonPages } from './fixtures/json-pages.js'; +import { + ARTIFACT_PAGE_MAX_ITEMS, + ARTIFACT_RESULT_MAX_BYTES, + type ArtifactQueryInput, + type ArtifactQueryResult, +} from '../protocol/index.js'; + import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { mkdtemp, rm } from 'node:fs/promises'; @@ -514,3 +523,70 @@ test('Session Guests can read only shared attachment Artifacts from their grante function digest(bytes: Uint8Array): `sha256:${string}` { return `sha256:${createHash('sha256').update(bytes).digest('hex')}`; } + +test('Artifact listing preserves the maximal byte-limited prefix across continuations', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-artifact-list-pages-')); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const store = await openInteractiveArtifactStoreForWrite(owner.lease); + try { + for (let index = 0; index < 20; index += 1) { + await store.create({ + id: `artifact-${index}`, + sessionId: 'session-1', + turnId: 'turn-1', + name: `้™„ไปถ-${index}.txt`, + kind: 'file', + content: Buffer.from('content'), + summary: 'ๆ–‡"\\๐Ÿ™‚'.repeat(600), + source: 'tool_result', + now: index, + }); + } + const expected = (await store.listPage('session-1', { offset: 0, limit: 128 })).records.map( + encodeArtifactProjection, + ); + const coordinator = new HostArtifactCoordinator( + store, + () => assert.fail('query must not drain'), + new SessionAdmissionGate(), + { probeSessionRemoval: async () => ({ kind: 'present' }) }, + ); + const pages: Extract[] = []; + let input: ArtifactQueryInput = { kind: 'list_start', sessionId: 'session-1' }; + let end = 0; + do { + const outcome = await coordinator.handlers['artifact.query'](input, connectionContext); + assert.ok(outcome.ok && outcome.result.kind === 'page'); + const page = outcome.result; + assert.ok(page.artifacts.length > 0); + pages.push(page); + end += page.artifacts.length; + assert.equal(page.nextCursor, end < expected.length ? String(end) : null); + if (page.nextCursor === null) break; + input = { + kind: 'list_continue', + sessionId: 'session-1', + revision: page.revision, + cursor: page.nextCursor, + }; + } while (end < expected.length); + assert.ok(pages.length > 1); + assert.ok(pages[0]!.artifacts.length < ARTIFACT_PAGE_MAX_ITEMS); + assertMaximalJsonPages(pages, expected, { + maxBytes: ARTIFACT_RESULT_MAX_BYTES, + maxItems: ARTIFACT_PAGE_MAX_ITEMS, + items: (page) => page.artifacts, + candidate: (page, artifacts, end) => ({ + ...page, + artifacts, + nextCursor: end < expected.length ? String(end) : null, + }), + }); + } finally { + store.close(); + await owner.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts b/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts index 8a90651563..7016ac99fb 100644 --- a/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts @@ -17,6 +17,9 @@ * under the License. */ +import { assertMaximalJsonPages } from './fixtures/json-pages.js'; +import { EXTERNAL_SESSION_PAGE_MAX_ITEMS } from '../protocol/index.js'; + import assert from 'node:assert/strict'; import { test } from 'node:test'; import { @@ -240,6 +243,33 @@ test('stops catalog pages before the encoded result limit', async () => { ); assert.equal(fixture.lookupCalls.length, 1); assert.equal(fixture.lookupCalls[0]?.sourceSessionIds.length, 16); + const pages = [outcome.result]; + let cursor: string | null = outcome.result.nextCursor; + while (cursor !== null) { + const next = await fixture.coordinator.handlers['external-session.catalog.query']( + { adapterId: 'codex', cursor }, + context, + ); + assert.ok(next.ok && next.result.sessions.length > 0); + pages.push(next.result); + assert.ok(pages.length <= 20); + cursor = next.result.nextCursor; + } + const items = pages.flatMap((page) => page.sessions); + assert.deepEqual( + items.map((item) => item.id), + Array.from({ length: 20 }, (_, index) => `source-${index}`), + ); + assertMaximalJsonPages(pages, items, { + maxBytes: EXTERNAL_SESSION_RESULT_MAX_BYTES, + maxItems: EXTERNAL_SESSION_PAGE_MAX_ITEMS, + items: (page) => page.sessions, + candidate: (page, sessions, end) => ({ + ...page, + sessions, + nextCursor: end < items.length ? String(end) : null, + }), + }); }); test('imports through the generic importer and treats repeats as independent copies', async () => { diff --git a/packages/runtime-host/src/__tests__/fixtures/json-pages.ts b/packages/runtime-host/src/__tests__/fixtures/json-pages.ts new file mode 100644 index 0000000000..bb00d1378d --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/json-pages.ts @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; + +/** Independently checks page selection using complete JSON, not the budget helper. */ +export function assertMaximalJsonPages( + pages: readonly Page[], + expectedItems: readonly Item[], + options: { + maxBytes: number; + maxItems: number; + items: (page: Page) => readonly Item[]; + candidate: (page: Page, items: readonly Item[], end: number) => object; + }, +): void { + let offset = 0; + for (const page of pages) { + const limit = Math.min(expectedItems.length, offset + options.maxItems); + let end = offset; + while (end < limit) { + const candidate = options.candidate(page, expectedItems.slice(offset, end + 1), end + 1); + if (Buffer.byteLength(JSON.stringify(candidate), 'utf8') > options.maxBytes) break; + end += 1; + } + assert.ok(end > offset, 'each page must make progress'); + assert.deepEqual(options.items(page), expectedItems.slice(offset, end)); + assert.ok(Buffer.byteLength(JSON.stringify(page), 'utf8') <= options.maxBytes); + offset = end; + } + assert.equal(offset, expectedItems.length, 'continuations must return every item exactly once'); +} diff --git a/packages/runtime-host/src/__tests__/json-array-page-budget.test.ts b/packages/runtime-host/src/__tests__/json-array-page-budget.test.ts new file mode 100644 index 0000000000..d8173d50b7 --- /dev/null +++ b/packages/runtime-host/src/__tests__/json-array-page-budget.test.ts @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { JsonArrayPageBudget } from '../server/json-array-page-budget.js'; + +const bytes = (value: unknown) => Buffer.byteLength(JSON.stringify(value), 'utf8'); + +for (const cursorKey of ['nextCursor', 'nextOffset']) { + test(`incremental ${cursorKey} budgets match whole-page JSON at exact boundaries`, () => { + const items = [ + { text: 'ไธญๆ–‡๐Ÿ™‚ \"quoted\" \\path\n', optional: undefined }, + { values: [null, true, 3.5], nested: { text: '\ud800' } }, + undefined, + ]; + const cursors = [null, 9, 10, 99, 100, '็›ฎๅฝ•\"\\๐Ÿ™‚', { part: 'model', index: 100 }]; + const empty = { kind: 'page', revision: '็‰ˆๆœฌ', items: [], [cursorKey]: null }; + for (const cursor of cursors) { + for (const count of [1, 2, 3]) { + const limit = bytes({ ...empty, items: items.slice(0, count), [cursorKey]: cursor }); + for (const delta of [-1, 0, 1]) { + const budget = new JsonArrayPageBudget(limit + delta, empty); + const accepted: unknown[] = []; + for (const item of items) { + const fits = + bytes({ ...empty, items: [...accepted, item], [cursorKey]: cursor }) <= limit + delta; + assert.equal(budget.tryAppend(item, cursor), fits); + if (fits) accepted.push(item); + } + } + } + } + }); +} + +test('a rejected candidate does not consume item bytes or a comma', () => { + const empty = { items: [], nextCursor: null }; + const budget = new JsonArrayPageBudget(bytes({ items: ['a', 'b'], nextCursor: null }), empty); + assert.equal(budget.tryAppend('too large'.repeat(20), 99), false); + assert.equal(budget.tryAppend('a', 9), true); + assert.equal(budget.tryAppend('too large'.repeat(20), 100), false); + assert.equal(budget.tryAppend('b', null), true); + assert.equal(budget.tryAppend('c', null), false); +}); + +test('cursor growth and final null are charged to the candidate being tested', () => { + const empty = { items: [], nextCursor: null }; + for (const cursor of [9, 10, 99, 100, null]) { + const budget = new JsonArrayPageBudget(bytes({ items: ['a', 'b'], nextCursor: cursor }), empty); + assert.equal(budget.tryAppend('a', 9), true); + assert.equal(budget.tryAppend('b', cursor), true); + } +}); diff --git a/packages/runtime-host/src/__tests__/memory-coordinator.test.ts b/packages/runtime-host/src/__tests__/memory-coordinator.test.ts index 3f663bf500..90b48c876a 100644 --- a/packages/runtime-host/src/__tests__/memory-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/memory-coordinator.test.ts @@ -34,12 +34,85 @@ import { MemoryMutateResult, MemoryQueryInput, MemoryQueryResult, + MEMORY_ENTRY_PAGE_MAX_ITEMS, + MEMORY_RESULT_MAX_BYTES, + type MemoryEntriesPage, + type MemoryEntryProjection, } from '../protocol/index.js'; +import { assertMaximalJsonPages } from './fixtures/json-pages.js'; import { HostMemoryCoordinator } from '../server/memory-coordinator.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js'; describe('Host Memory coordinator', () => { + test('entry queries preserve the maximal byte-limited prefix across continuations', async () => { + await withCoordinator(async ({ coordinator, memoryStore, context }) => { + await coordinator.recover(); + const initial = await memoryStore.read(); + const entries: MemoryEntryProjection[] = Array.from({ length: 70 }, (_, index) => ({ + id: `entry-${index}`, + source: 'user_authored', + status: 'active', + title: 'ๆ ‡้ข˜๐Ÿ™‚ "quoted" \\path', + content: 'ๆ–‡"\\\t๐Ÿ™‚'.repeat(80), + scope: 'workspace', + tags: [], + })); + await memoryStore.commit({ + expectedRevision: initial.revision, + memory: Buffer.from( + '# Maka Memory\n\n' + + entries + .map( + (entry) => + `## ${entry.title}\n\n${entry.content}\n`, + ) + .join('\n'), + ), + pending: null, + }); + const pages: MemoryEntriesPage[] = []; + let input: MemoryQueryInput = { kind: 'entries_start', view: 'active' }; + do { + const page = await query(coordinator, input, context); + assert.ok(page.kind === 'entries_page'); + assert.ok(page.items.length > 0); + pages.push(page); + assert.ok(pages.length <= entries.length); + assert.deepEqual( + decodeHostFrame({ + requestId: 'memory-page', + operation: 'memory.query', + ok: true, + result: page, + }), + { requestId: 'memory-page', operation: 'memory.query', ok: true, result: page }, + ); + const end = pages.reduce((count, current) => count + current.items.length, 0); + assert.equal(page.nextCursor, end < entries.length ? end : null); + if (page.nextCursor === null) break; + input = { + kind: 'entries_continue', + view: 'active', + revision: page.revision, + cursor: page.nextCursor, + }; + } while (true); + assert.ok(pages.length > 1); + assert.ok(pages[0]!.items.length < MEMORY_ENTRY_PAGE_MAX_ITEMS); + assertMaximalJsonPages(pages, entries, { + maxBytes: MEMORY_RESULT_MAX_BYTES, + maxItems: MEMORY_ENTRY_PAGE_MAX_ITEMS, + items: (page) => page.items, + candidate: (page, items, end) => ({ + ...page, + items, + nextCursor: end < entries.length ? end : null, + }), + }); + }); + }); + test('initializes only when current policy permits Memory access', async () => { await withCoordinator(async ({ coordinator, memoryStore, policyStores, context }) => { await setIncognito(policyStores, true); diff --git a/packages/runtime-host/src/__tests__/plan-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/plan-two-client-uds.test.ts index 40b7283b5d..51baf5d0ca 100644 --- a/packages/runtime-host/src/__tests__/plan-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/plan-two-client-uds.test.ts @@ -17,6 +17,17 @@ * under the License. */ +import { assertMaximalJsonPages } from './fixtures/json-pages.js'; +import { HostPlanCoordinator } from '../server/plan-coordinator.js'; +import { SessionAdmissionGate } from '../server/session-admission-gate.js'; +import { + decodePlanQueryResult, + PLAN_PAGE_MAX_ITEMS, + PLAN_RESULT_MAX_BYTES, + type PlanQueryInput, + type PlanQueryResult, +} from '../protocol/index.js'; + import { waitFor, withTimeout } from '@maka/core/test-only/async-primitives'; import { defineInteractiveRuntimeHostComposition } from '../server/host-composition.js'; import assert from 'node:assert/strict'; @@ -269,3 +280,83 @@ const deterministicBackendComposition: RuntimeHostCompositionFactory = (context) {}, { primaryBackendFactory: (backendContext) => new FakeBackend(backendContext) }, ); + +test('Plan queries include their state header when selecting byte-limited continuation pages', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plan-pages-')); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const store = await openInteractivePlanStoreForWrite(owner.lease); + let sessions: Awaited> | undefined; + try { + sessions = await openInteractiveExecutionStoresForWrite(owner.lease); + const session = await sessions.sessionStore.create({ + cwd: root, + llmConnectionSlug: 'test', + model: 'test-model', + permissionMode: 'explore', + collaborationMode: 'plan', + }); + for (let index = 0; index < 17; index += 1) { + await store.submitProposal({ + operationId: `submit-${index}`, + sessionId: session.id, + turnId: `turn-${index}`, + title: `Proposal ${index}`, + steps: [{ id: 'step-1', title: 'Review', description: 'ๆ–‡"\\๐Ÿ™‚'.repeat(1000) }], + }); + } + const state = await store.readState(session.id); + const expected = state.proposals.map((proposal) => ({ kind: 'proposal' as const, proposal })); + const coordinator = new HostPlanCoordinator({ + store, + sessions: sessions.sessionStore, + sessionAdmission: new SessionAdmissionGate(), + runtime: null as never, + root: null as never, + isSessionActive: () => false, + refreshContinuity: async () => {}, + onProjectionChanged: () => {}, + requestDrain: () => assert.fail('query must not drain'), + }); + const pages: Extract[] = []; + let input: PlanQueryInput = { kind: 'list_start', sessionId: session.id }; + let end = 0; + do { + const outcome = await coordinator.handlers['plan.query'](input, null as never); + assert.ok(outcome.ok && outcome.result.kind === 'page'); + const page = outcome.result; + assert.deepEqual(decodePlanQueryResult(page), page); + assert.equal(page.latestProposalId, state.latestProposalId); + assert.equal(page.storeVersion, state.storeVersion); + assert.ok(page.items.length > 0); + pages.push(page); + end += page.items.length; + assert.equal(page.nextCursor, end < expected.length ? String(end) : null); + if (page.nextCursor === null) break; + input = { + kind: 'list_continue', + sessionId: session.id, + storeVersion: page.storeVersion, + cursor: page.nextCursor, + }; + } while (end < expected.length); + assert.ok(pages.length > 1); + assert.ok(pages[0]!.items.length < PLAN_PAGE_MAX_ITEMS); + assertMaximalJsonPages(pages, expected, { + maxBytes: PLAN_RESULT_MAX_BYTES, + maxItems: PLAN_PAGE_MAX_ITEMS, + items: (page) => page.items, + candidate: (page, items, end) => ({ + ...page, + items, + nextCursor: end < expected.length ? String(end) : null, + }), + }); + } finally { + store.close(); + await sessions?.sessionStore.close?.(); + await owner.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-host/src/__tests__/plugin-platform.test.ts b/packages/runtime-host/src/__tests__/plugin-platform.test.ts index 286799d43a..c250197926 100644 --- a/packages/runtime-host/src/__tests__/plugin-platform.test.ts +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -17,6 +17,8 @@ * under the License. */ +import { assertMaximalJsonPages } from './fixtures/json-pages.js'; + import assert from 'node:assert/strict'; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -694,13 +696,13 @@ test('Plugin Platform query projects scoped Command contributions for clients', } }); -test('Plugin Platform query pages share the protocol byte budget across multiple items', async () => { +test('Plugin Platform query pages reserve a cursor even when the complete final result fits', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-query-budget-')); try { const platform = createPlatform(join(root, 'control')); const coordinator = new HostPluginPlatformCoordinator(platform); await platform.recover(); - for (let index = 0; index < 12; index += 1) { + for (let index = 0; index < 8; index += 1) { await platform.apply({ operations: [ { @@ -714,6 +716,30 @@ test('Plugin Platform query pages share the protocol byte budget across multiple }); } + // Make the complete null-cursor result fit exactly. Admission still reserves + // a non-null candidate cursor, so the last item must move to a second page. + let expected = platform.inspect().map((item) => ({ ...item, children: [] })); + const completeBytes = () => + Buffer.byteLength( + JSON.stringify({ view: 'entries', items: expected, nextCursor: null }), + 'utf8', + ); + const excess = completeBytes() - PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES; + assert.ok(excess > 0 && excess < 60 * 1024); + await platform.apply({ + operations: [ + { + type: 'update', + entryId: 'large-query-entry-7', + patch: { + config: { payload: `7:${'x'.repeat(60 * 1024 - excess)}` }, + }, + }, + ], + }); + expected = platform.inspect().map((item) => ({ ...item, children: [] })); + assert.equal(completeBytes(), PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES); + const queried = await coordinator.handlers['plugin.platform.query']( { view: 'entries', limit: 64 }, null as never, @@ -721,7 +747,7 @@ test('Plugin Platform query pages share the protocol byte budget across multiple assert.equal(queried.ok, true); if (!queried.ok || queried.result.view !== 'entries') throw new Error('Expected Entry page'); assert.ok(queried.result.items.length > 1); - assert.ok(queried.result.items.length < 12); + assert.ok(queried.result.items.length < 8); assert.notEqual(queried.result.nextCursor, null); assert.ok( Buffer.byteLength(JSON.stringify(queried.result), 'utf8') <= @@ -735,6 +761,35 @@ test('Plugin Platform query pages share the protocol byte budget across multiple result: queried.result, }), ); + const pages = [queried.result]; + assert.ok(queried.result.nextCursor); + const cursorFields = JSON.parse( + Buffer.from(queried.result.nextCursor, 'base64url').toString('utf8'), + ); + let cursor = queried.result.nextCursor as string | null; + while (cursor !== null) { + const next = await coordinator.handlers['plugin.platform.query']( + { view: 'entries', limit: 64, cursor }, + null as never, + ); + assert.ok(next.ok && next.result.view === 'entries' && next.result.items.length > 0); + pages.push(next.result); + assert.ok(pages.length <= expected.length); + cursor = next.result.nextCursor; + } + assert.equal(pages.length, 2); + assertMaximalJsonPages(pages, expected, { + maxBytes: PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES, + maxItems: 64, + items: (page) => page.items, + candidate: (page, items, end) => ({ + ...page, + items, + nextCursor: Buffer.from(JSON.stringify({ ...cursorFields, offset: end })).toString( + 'base64url', + ), + }), + }); await platform.close(); } finally { await rm(root, { recursive: true, force: true }); diff --git a/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts index 9495589165..3588b42299 100644 --- a/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts @@ -17,6 +17,16 @@ * under the License. */ +import { assertMaximalJsonPages } from './fixtures/json-pages.js'; +import { + decodeProjectCatalogQueryResult, + PROJECT_CATALOG_PAGE_MAX_BYTES, + PROJECT_CATALOG_PAGE_MAX_ITEMS, + type ProjectCatalogQueryInput, + type ProjectCatalogQueryResult, + type ProjectCatalogPageItem, +} from '../protocol/index.js'; + import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { mkdir, mkdtemp, realpath, rename, rm, writeFile } from 'node:fs/promises'; @@ -275,3 +285,81 @@ function connection(): ConnectionContext { acquireResidency: () => ({ release: () => {} }), }; } + +test('Project catalog includes mixed item kinds and its header in byte-limited pages', async (context) => { + const root = await mkdtemp(join(tmpdir(), 'maka-project-pages-')); + const catalog = createProjectCatalog(join(root, 'storage')); + try { + const seed = await catalog.register(root); + const records = Array.from({ length: 20 }, (_, index) => ({ + ...seed, + id: `project-${index}`, + name: `Project ${index} ${'ๆ–‡"\\๐Ÿ™‚'.repeat(700)}`, + aliases: [`old-${index}`], + })); + context.mock.method(catalog, 'list', async () => records); + const expected: ProjectCatalogPageItem[] = records.flatMap((record, projectIndex) => [ + { + kind: 'project' as const, + projectIndex, + id: record.id, + name: record.name, + aliasCount: 1, + locationCount: record.locations.length, + preferredLocationIndex: 0, + archivedAt: null, + available: record.available, + }, + { kind: 'alias' as const, projectIndex, itemIndex: 0, alias: record.aliases[0]! }, + ...record.locations.map((location, itemIndex) => ({ + kind: 'location' as const, + projectIndex, + itemIndex, + location: { path: location.path, isWorktree: location.isWorktree }, + })), + ]); + const coordinator = new HostProjectCatalogCoordinator( + catalog, + { publish() {} }, + { publish() {} }, + new HostProjectMembershipGate(), + () => assert.fail('query must not drain'), + ); + const pages: Extract[] = []; + let input: ProjectCatalogQueryInput = { kind: 'list_start', view: 'locations' }; + let end = 0; + do { + const outcome = await coordinator.handlers['project.catalog.query'](input, null as never); + assert.ok(outcome.ok && outcome.result.kind === 'page'); + const page = outcome.result; + assert.deepEqual(decodeProjectCatalogQueryResult(page), page); + assert.equal(page.projectCount, records.length); + assert.ok(page.items.length > 0); + pages.push(page); + end += page.items.length; + assert.equal(page.nextCursor, end < expected.length ? String(end) : null); + if (page.nextCursor === null) break; + input = { + kind: 'list_continue', + view: 'locations', + revision: page.revision, + cursor: page.nextCursor, + }; + } while (end < expected.length); + assert.ok(pages.length > 1); + assert.ok(pages[0]!.items.length < PROJECT_CATALOG_PAGE_MAX_ITEMS); + assertMaximalJsonPages(pages, expected, { + maxBytes: PROJECT_CATALOG_PAGE_MAX_BYTES, + maxItems: PROJECT_CATALOG_PAGE_MAX_ITEMS, + items: (page) => page.items, + candidate: (page, items, end) => ({ + ...page, + items, + nextCursor: end < expected.length ? String(end) : null, + }), + }); + } finally { + catalog.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-host/src/__tests__/project-directory-authority.test.ts b/packages/runtime-host/src/__tests__/project-directory-authority.test.ts index 7ceb8edae2..d65d6da44d 100644 --- a/packages/runtime-host/src/__tests__/project-directory-authority.test.ts +++ b/packages/runtime-host/src/__tests__/project-directory-authority.test.ts @@ -17,6 +17,14 @@ * under the License. */ +import { assertMaximalJsonPages } from './fixtures/json-pages.js'; +import { + PROJECT_DIRECTORY_PAGE_MAX_BYTES, + PROJECT_DIRECTORY_PAGE_MAX_ITEMS, + type ProjectDirectoryQueryResult, + type ProjectDirectoryQueryInput, +} from '../protocol/index.js'; + import assert from 'node:assert/strict'; import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -151,3 +159,64 @@ test('Project directory continuation returns each contained folder once', async await rm(base, { recursive: true, force: true }); } }); + +test('Project directory reserves the candidate name even when a null-cursor final page would fit', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-directory-budget-')); + try { + const authority = new HostProjectDirectoryAuthority([{ label: 'Root', path: root }]); + const roots = await authority.query({ kind: 'directory_roots' }); + assert.ok(roots.kind === 'directory_roots'); + const rootId = roots.roots[0]!.id; + const names = Array.from( + { length: 128 }, + (_, index) => `folder-${String(index).padStart(3, '0')}-${'x'.repeat(230)}`, + ); + const finalPage = () => ({ + kind: 'directory_page', + rootId, + segments: [], + entries: names.map((name) => ({ name })), + nextCursor: null, + }); + let padding = + PROJECT_DIRECTORY_PAGE_MAX_BYTES - Buffer.byteLength(JSON.stringify(finalPage()), 'utf8'); + assert.ok(padding > 0); + for (let index = 0; index < names.length; index += 1) { + const added = Math.min(padding, 255 - names[index]!.length); + names[index] += 'x'.repeat(added); + padding -= added; + } + assert.equal(padding, 0); + assert.equal( + Buffer.byteLength(JSON.stringify(finalPage()), 'utf8'), + PROJECT_DIRECTORY_PAGE_MAX_BYTES, + ); + await Promise.all(names.map((name) => mkdir(join(root, name)))); + const pages: Extract[] = []; + let input: ProjectDirectoryQueryInput = { kind: 'directory_list_start', rootId, segments: [] }; + let end = 0; + do { + const page = await authority.query(input); + assert.ok(page.kind === 'directory_page'); + assert.ok(page.entries.length > 0); + pages.push(page); + end += page.entries.length; + assert.equal(page.nextCursor, end < names.length ? names[end - 1] : null); + if (page.nextCursor === null) break; + input = { kind: 'directory_list_continue', rootId, segments: [], cursor: page.nextCursor }; + } while (end < names.length); + assert.equal(pages.length, 2); + assertMaximalJsonPages( + pages, + names.map((name) => ({ name })), + { + maxBytes: PROJECT_DIRECTORY_PAGE_MAX_BYTES, + maxItems: PROJECT_DIRECTORY_PAGE_MAX_ITEMS, + items: (page) => page.entries, + candidate: (page, entries, end) => ({ ...page, entries, nextCursor: names[end - 1] }), + }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts index 703f386f9d..197c34aa75 100644 --- a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts @@ -17,6 +17,8 @@ * under the License. */ +import { assertMaximalJsonPages } from './fixtures/json-pages.js'; + import { deferred } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; @@ -1231,6 +1233,27 @@ test('reconstructs a large catalog with revision-pinned pages and rejects stale expectedCatalogItems(snapshot), ); + const expectedItems = expectedCatalogItems(snapshot); + assertMaximalJsonPages(pages, expectedItems, { + maxBytes: CONNECTION_CATALOG_PAGE_MAX_BYTES, + maxItems: CONNECTION_CATALOG_PAGE_MAX_ITEMS, + items: (page) => page.items, + candidate: (page, items, end) => { + const next = expectedItems[end]; + const nextCursor = + next === undefined + ? null + : next.kind === 'connection' + ? { connectionIndex: next.connectionIndex, part: 'connection' } + : { + connectionIndex: next.connectionIndex, + part: next.kind, + itemIndex: next.itemIndex, + }; + return { ...page, items, nextCursor }; + }, + }); + const staleCursor = first.result.nextCursor; assert.ok(staleCursor); if (!staleCursor) return; diff --git a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts index 53f499143c..6e7f657d26 100644 --- a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts @@ -17,6 +17,13 @@ * under the License. */ +import { assertMaximalJsonPages } from './fixtures/json-pages.js'; +import { + RUNTIME_RESOURCE_PAGE_MAX_ITEMS, + type RuntimeResourceQueryInput, + type RuntimeResourceQueryResult, +} from '../protocol/index.js'; + import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { SHELL_RUN_SOURCE_TOOL_CALL_ID_MAX_BYTES } from '@maka/core/shell-run'; @@ -1054,3 +1061,56 @@ function pipeOutput(stdout: string): Extract { + const harness = createHarness(); + harness.updates = Array.from({ length: 20 }, (_, index) => + resourceUpdate(index, { + result: pipeSnapshot(index, 'ๆ–‡"\\๐Ÿ™‚'.repeat(600)), + }), + ); + const expected = []; + for (const update of harness.updates) { + const outcome = await harness.coordinator.handlers['runtime.resource.query']( + { kind: 'get', sessionId: SESSION_ID, ref: update.result.ref }, + connection('connection-1'), + ); + assert.ok(outcome.ok && outcome.result.kind === 'resource' && outcome.result.resource); + expected.push(outcome.result.resource); + } + expected.sort((a, b) => a.result.ref.localeCompare(b.result.ref)); + const pages: Extract[] = []; + let input: RuntimeResourceQueryInput = { kind: 'list_start', sessionId: SESSION_ID }; + let end = 0; + do { + const outcome = await harness.coordinator.handlers['runtime.resource.query']( + input, + connection('connection-1'), + ); + assert.ok(outcome.ok && outcome.result.kind === 'page'); + const page = outcome.result; + assert.ok(page.resources.length > 0); + pages.push(page); + end += page.resources.length; + assert.equal(page.nextCursor, end < expected.length ? String(end) : null); + if (page.nextCursor === null) break; + input = { + kind: 'list_continue', + sessionId: SESSION_ID, + revision: page.revision, + cursor: page.nextCursor, + }; + } while (end < expected.length); + assert.ok(pages.length > 1); + assert.ok(pages[0]!.resources.length < RUNTIME_RESOURCE_PAGE_MAX_ITEMS); + assertMaximalJsonPages(pages, expected, { + maxBytes: RUNTIME_RESOURCE_RESULT_MAX_BYTES, + maxItems: RUNTIME_RESOURCE_PAGE_MAX_ITEMS, + items: (page) => page.resources, + candidate: (page, resources, end) => ({ + ...page, + resources, + nextCursor: end < expected.length ? String(end) : null, + }), + }); +}); diff --git a/packages/runtime-host/src/__tests__/scheduled-task-coordinator-recovery.test.ts b/packages/runtime-host/src/__tests__/scheduled-task-coordinator-recovery.test.ts index a9e1940a63..f8fe67669d 100644 --- a/packages/runtime-host/src/__tests__/scheduled-task-coordinator-recovery.test.ts +++ b/packages/runtime-host/src/__tests__/scheduled-task-coordinator-recovery.test.ts @@ -17,6 +17,15 @@ * under the License. */ +import { assertMaximalJsonPages } from './fixtures/json-pages.js'; +import { + decodeScheduledTaskQueryResult, + SCHEDULED_TASK_PAGE_MAX_ITEMS, + SCHEDULED_TASK_RESULT_MAX_BYTES, + type ScheduledTaskQueryInput, + type ScheduledTaskQueryResult, +} from '../protocol/index.js'; + import assert from 'node:assert/strict'; import { deferred, waitFor } from '@maka/core/test-only/async-primitives'; import { mkdtemp, rm } from 'node:fs/promises'; @@ -518,3 +527,83 @@ function admission( admittedAt: 1_000, }; } + +test('ScheduledTask catalog returns every task through maximal byte-limited pages', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-scheduled-task-pages-')); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const store = await openInteractiveScheduledTaskStoreForWrite(owner.lease); + const coordinator = new HostScheduledTaskCoordinator({ + store, + sessions: null as never, + runtime: null as never, + root: null as never, + runtimePolicy: null as never, + nativeEffects: null as never, + createSession: async () => {}, + changes: { publish() {} }, + acquireResidency: () => ({ release() {} }), + requestDrain: () => assert.fail('query must not drain'), + }); + try { + for (let index = 0; index < 12; index += 1) { + await store.create( + { + title: `Task ${index}`, + intentBody: 'ๆ–‡"\\๐Ÿ™‚'.repeat(700), + schedule: { kind: 'interval', everySeconds: 60 }, + effect: { + kind: 'agent_run', + execution: { + cwd: '/workspace', + backend: 'ai-sdk', + llmConnectionId: 'connection-default', + llmConnectionSlug: 'default', + model: 'test-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + createdBy: { kind: 'user' }, + }, + 1000 + index, + ); + } + await coordinator.prepareRecovery(); + const expected = await store.list(); + const pages: Extract[] = []; + let input: ScheduledTaskQueryInput = { kind: 'list' }; + let end = 0; + do { + const outcome = await coordinator.handlers['scheduled-task.query'](input, null as never); + assert.ok(outcome.ok && outcome.result.kind === 'page'); + const page = outcome.result; + assert.deepEqual(decodeScheduledTaskQueryResult(page), page); + assert.ok(page.tasks.length > 0); + pages.push(page); + end += page.tasks.length; + assert.equal(page.nextCursor, end < expected.length ? String(end) : null); + if (page.nextCursor === null) break; + input = { kind: 'list', expectedRevision: page.revision, cursor: page.nextCursor }; + } while (end < expected.length); + assert.ok(pages.length > 1); + assert.ok(pages[0]!.tasks.length < SCHEDULED_TASK_PAGE_MAX_ITEMS); + assertMaximalJsonPages(pages, expected, { + maxBytes: SCHEDULED_TASK_RESULT_MAX_BYTES, + maxItems: SCHEDULED_TASK_PAGE_MAX_ITEMS, + items: (page) => page.tasks, + candidate: (page, tasks, end) => ({ + ...page, + tasks, + nextCursor: end < expected.length ? String(end) : null, + }), + }); + } finally { + await coordinator.close(); + store.close(); + await owner.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index bab0bd5ceb..16f1c35190 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -17,6 +17,13 @@ * under the License. */ +import { assertMaximalJsonPages } from './fixtures/json-pages.js'; +import { + SESSION_CATALOG_PAGE_MAX_ITEMS, + type SessionCatalogQueryResult, + type SessionCatalogQueryInput, +} from '../protocol/index.js'; + import assert from 'node:assert/strict'; import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -1492,11 +1499,11 @@ test('same-workspace relocation still enters Runtime eligibility authority', asy }); }); -test('catalog paging stops before the encoded 48 KiB result boundary', async () => { - const records = Array.from({ length: 32 }, (_, index) => { +test('catalog paging preserves the byte-limited prefix and storage continuation cursor', async () => { + const records = Array.from({ length: 40 }, (_, index) => { const header = { ...sessionHeader( - `session-${index}`, + `session-${String(index).padStart(3, '0')}`, Array.from({ length: 32 }, (_, label) => `label-${label}-${'x'.repeat(110)}`), ), name: `Session ${index} ${'n'.repeat(280)}`, @@ -1505,30 +1512,60 @@ test('catalog paging stops before the encoded 48 KiB result boundary', async () }); const fixture = createFixture({ stores: { - listCatalogPage: async () => ({ - kind: 'page', - revision: 'sha256:test', - records, - hasMore: false, - }), + listCatalogPage: async (_filter, cursor, limit) => { + const offset = cursor + ? records.findIndex((record) => record.header.id === cursor.sessionId) + 1 + : 0; + return { + kind: 'page', + revision: 'sha256:test', + records: records.slice(offset, offset + limit), + hasMore: offset + limit < records.length, + }; + }, }, }); - - const outcome = await fixture.coordinator.handlers['session.catalog.query']( - { kind: 'list_start' }, - context, + const pages: Extract[] = []; + let input: SessionCatalogQueryInput = { kind: 'list_start' }; + let end = 0; + const cursorAt = (end: number) => + end === records.length + ? null + : Buffer.from( + JSON.stringify({ + version: 1, + activityAt: records[end - 1]!.activityAt, + sessionId: records[end - 1]!.header.id, + }), + ).toString('base64url'); + do { + const outcome = await fixture.coordinator.handlers['session.catalog.query'](input, context); + assert.ok(outcome.ok && outcome.result.kind === 'page'); + const page = outcome.result; + assert.ok(page.sessions.length > 0); + pages.push(page); + end += page.sessions.length; + assert.equal(page.nextCursor, cursorAt(end)); + if (page.nextCursor === null) break; + input = { kind: 'list_continue', revision: page.revision, cursor: page.nextCursor }; + } while (end < records.length); + const items = pages.flatMap((page) => page.sessions); + assert.deepEqual( + items.map((item) => item.id), + records.map((record) => record.header.id), ); - - assert.equal(outcome.ok, true); - if (!outcome.ok || outcome.result.kind !== 'page') { - assert.fail('Catalog query did not return a page'); - } - assert.ok(outcome.result.sessions.length > 0); - assert.ok(outcome.result.sessions.length < records.length); - assert.ok(outcome.result.nextCursor); assert.ok( - Buffer.byteLength(JSON.stringify(outcome.result), 'utf8') <= SESSION_CATALOG_RESULT_MAX_BYTES, + items.every((item) => !('kind' in item)), + 'fixture must exercise ordinary Session projections', ); + assert.ok(pages.length > 1); + assert.ok(pages[0]!.sessions.length < SESSION_CATALOG_PAGE_MAX_ITEMS); + assertMaximalJsonPages(pages, items, { + maxBytes: SESSION_CATALOG_RESULT_MAX_BYTES, + maxItems: SESSION_CATALOG_PAGE_MAX_ITEMS, + items: (page) => page.sessions, + candidate: (page, sessions, end) => ({ ...page, sessions, nextCursor: cursorAt(end) }), + }); }); test('rejects a legacy cursor that carries a Session catalog filter', async () => { diff --git a/packages/runtime-host/src/__tests__/skill-catalog-repository.test.ts b/packages/runtime-host/src/__tests__/skill-catalog-repository.test.ts index d2e6367dd3..b3ceba861e 100644 --- a/packages/runtime-host/src/__tests__/skill-catalog-repository.test.ts +++ b/packages/runtime-host/src/__tests__/skill-catalog-repository.test.ts @@ -17,6 +17,13 @@ * under the License. */ +import { assertMaximalJsonPages } from './fixtures/json-pages.js'; +import { + SKILL_CATALOG_PAGE_MAX_BYTES, + SKILL_CATALOG_PAGE_MAX_ITEMS, + type SkillCatalogInvocableQueryResult, +} from '../protocol/index.js'; + import { RuntimeHostProtocolError } from '../protocol/errors.js'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; @@ -1399,3 +1406,73 @@ function governanceItem( function sha256(content: string | Uint8Array): SkillCatalogRevision { return `sha256:${createHash('sha256').update(content).digest('hex')}`; } + +for (const view of ['governance', 'invocable'] as const) { + test(`${view} Skill pages preserve every entry when metadata reaches the byte budget`, async () => { + const fixture = await createFixture(); + const ids = Array.from({ length: 80 }, (_, index) => `skill-${String(index).padStart(3, '0')}`); + const description = 'ๆ–‡๐Ÿ™‚'.repeat(120); + await Promise.all( + ids.map((id) => + createSkill(join(fixture.project, '.maka', 'skills'), id, skillBody(id, description)), + ), + ); + const repository = fixture.repository(); + type Page = Extract< + Awaited> | SkillCatalogInvocableQueryResult, + { kind: 'page' } + >; + const pages: Page[] = []; + let cursor: string | null = null; + let revision: SkillCatalogRevision | undefined; + do { + const continuation: + | { kind: 'start' } + | { kind: 'continue'; revision: SkillCatalogRevision; cursor: string } = + revision === undefined + ? { kind: 'start' as const } + : { kind: 'continue' as const, revision, cursor: cursor! }; + const page: Awaited> | SkillCatalogInvocableQueryResult = + view === 'invocable' + ? await repository.queryInvocable( + continuation, + { projectRoot: fixture.project }, + { toolNames: new Set(['Read']) }, + ) + : await repository.query({ ...continuation, view }); + assert.ok(page.kind === 'page'); + assert.ok(page.items.length > 0); + pages.push(page); + assert.ok(pages.length <= ids.length); + revision = page.revision; + cursor = page.nextCursor; + } while (cursor !== null); + const items = pages.flatMap((page) => [...page.items]); + assert.deepEqual( + items.map((item) => item.id), + ids, + ); + assert.ok(items.every((item) => item.description === description)); + assert.ok(pages.length > 1); + assert.ok(pages[0]!.items.length < SKILL_CATALOG_PAGE_MAX_ITEMS); + assertMaximalJsonPages(pages, items, { + maxBytes: SKILL_CATALOG_PAGE_MAX_BYTES, + maxItems: SKILL_CATALOG_PAGE_MAX_ITEMS, + items: (page) => page.items, + candidate: (page, items, end) => ({ + ...page, + items, + nextCursor: + end === ids.length + ? null + : Buffer.from( + JSON.stringify( + view === 'invocable' + ? { v: 1, kind: 'invocable', offset: end } + : { v: 1, view, offset: end }, + ), + ).toString('base64url'), + }), + }); + }); +} diff --git a/packages/runtime-host/src/__tests__/usage-pricing-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/usage-pricing-two-client-uds.test.ts index 83abf10185..429a7a6bd0 100644 --- a/packages/runtime-host/src/__tests__/usage-pricing-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/usage-pricing-two-client-uds.test.ts @@ -17,6 +17,15 @@ * under the License. */ +import { assertMaximalJsonPages } from './fixtures/json-pages.js'; +import { + USAGE_PAGE_MAX_BYTES, + USAGE_PAGE_MAX_ITEMS, + PRICING_PAGE_MAX_BYTES, + PRICING_PAGE_MAX_ITEMS, + type UsageQueryResult, +} from '../protocol/index.js'; + import { deferred } from '@maka/core/test-only/async-primitives'; import { defineInteractiveRuntimeHostComposition } from '../server/host-composition.js'; import assert from 'node:assert/strict'; @@ -900,6 +909,7 @@ async function readPricing(client: RuntimeHostConnection): Promise<{ await client.request('pricing.query', { kind: 'start' }, REQUEST_TIMEOUT_MS), ); const entries = [...first.entries]; + const pages = [first]; let nextOffset = first.nextOffset; let pageCount = 1; while (nextOffset !== null) { @@ -912,10 +922,22 @@ async function readPricing(client: RuntimeHostConnection): Promise<{ ); assert.equal(page.revision, first.revision); assert.equal(page.offset, nextOffset); + assert.ok(page.entries.length > 0); entries.push(...page.entries); + pages.push(page); nextOffset = page.nextOffset; pageCount += 1; } + assertMaximalJsonPages(pages, entries, { + maxBytes: PRICING_PAGE_MAX_BYTES, + maxItems: PRICING_PAGE_MAX_ITEMS, + items: (page) => page.entries, + candidate: (page, items, end) => ({ + ...page, + entries: items, + nextOffset: end < entries.length ? end : null, + }), + }); return { revision: first.revision, entries, pageCount }; } @@ -1054,3 +1076,122 @@ async function withUsageAuthority( await rm(base, { recursive: true, force: true }); } } + +test('Usage bucket pages account for provenance and preserve every group across byte-limited pages', async () => { + await withUsageAuthority('bucket-pages', async ({ stores }) => { + const providers = Array.from( + { length: 60 }, + (_, index) => `provider-${String(index).padStart(3, '0')}-${'ๆ–‡'.repeat(270)}`, + ); + for (const [index, provider] of providers.entries()) { + await stores.telemetry.recordLlmCall( + usageRecord(`usage-${index}`, index, provider, 'test-model'), + ); + } + const coordinator = new HostUsagePricingCoordinator( + stores, + () => {}, + new RuntimePolicyActivationGate(), + ); + const pages: Extract[] = []; + let offset = 0; + do { + const outcome = await coordinator.handlers['usage.query']( + { + kind: 'buckets', + query: { range: 'all' }, + groupBy: 'provider', + offset, + limit: USAGE_PAGE_MAX_ITEMS, + }, + CONNECTION_CONTEXT, + ); + assert.ok(outcome.ok && outcome.result.kind === 'buckets'); + const page = outcome.result; + assert.equal(page.offset, offset); + assert.equal(page.total, providers.length); + assert.ok(page.buckets.length > 0); + pages.push(page); + offset += page.buckets.length; + assert.equal(page.nextOffset, offset < providers.length ? offset : null); + if (page.nextOffset === null) break; + } while (offset < providers.length); + const items = pages.flatMap((page) => page.buckets); + assert.deepEqual(items.map((item) => item.key).sort(), [...providers].sort()); + assert.ok(pages.length > 1); + assertMaximalJsonPages(pages, items, { + maxBytes: USAGE_PAGE_MAX_BYTES, + maxItems: USAGE_PAGE_MAX_ITEMS, + items: (page) => page.buckets, + candidate: (page, buckets, end) => ({ + ...page, + buckets, + nextOffset: end < items.length ? end : null, + }), + }); + }); +}); + +for (const source of ['llm', 'tool'] as const) { + test(`${source} Usage log pages preserve byte-limited continuations with the source-specific header`, async () => { + await withUsageAuthority(`${source}-log-pages`, async ({ stores }) => { + const ids = Array.from( + { length: 60 }, + (_, index) => `${source}-${String(index).padStart(3, '0')}`, + ); + for (const [index, id] of ids.entries()) { + if (source === 'llm') { + await stores.telemetry.recordLlmCall( + usageRecord(id, index, 'ๆ–‡'.repeat(270), 'ๆจก'.repeat(270)), + ); + } else { + await stores.telemetry.recordToolInvocation({ + ...toolRecord(id, index), + argsSummary: 'ๆ–‡"\\๐Ÿ™‚'.repeat(110), + toolName: 'ๅ…ท'.repeat(270), + }); + } + } + const coordinator = new HostUsagePricingCoordinator( + stores, + () => {}, + new RuntimePolicyActivationGate(), + ); + const pages: Extract[] = []; + let offset = 0; + do { + const outcome = await coordinator.handlers['usage.query']( + { kind: 'logs', source, query: { range: 'all' }, offset, limit: USAGE_PAGE_MAX_ITEMS }, + CONNECTION_CONTEXT, + ); + assert.ok(outcome.ok && outcome.result.kind === 'logs'); + const page = outcome.result; + assert.equal(page.source, source); + assert.equal('provenance' in page, source === 'llm'); + assert.equal(page.offset, offset); + assert.equal(page.total, ids.length); + assert.ok(page.rows.length > 0); + pages.push(page); + offset += page.rows.length; + assert.equal(page.nextOffset, offset < ids.length ? offset : null); + if (page.nextOffset === null) break; + } while (offset < ids.length); + const items = pages.flatMap((page) => [...page.rows]); + assert.deepEqual( + items.map((item) => item.id), + [...ids].reverse(), + ); + assert.ok(pages.length > 1); + assertMaximalJsonPages(pages, items, { + maxBytes: USAGE_PAGE_MAX_BYTES, + maxItems: USAGE_PAGE_MAX_ITEMS, + items: (page) => page.rows, + candidate: (page, rows, end) => ({ + ...page, + rows, + nextOffset: end < items.length ? end : null, + }), + }); + }); + }); +} diff --git a/packages/runtime-host/src/server/artifact-coordinator.ts b/packages/runtime-host/src/server/artifact-coordinator.ts index 9b507ba550..6514d9a6d9 100644 --- a/packages/runtime-host/src/server/artifact-coordinator.ts +++ b/packages/runtime-host/src/server/artifact-coordinator.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonArrayPageBudget } from './json-array-page-budget.js'; + import { createHash } from 'node:crypto'; import { attachmentKindFromMimeType } from '@maka/core/attachments'; import type { AttachmentRef } from '@maka/core/events'; @@ -507,18 +509,17 @@ function createPage( offset: number, ): ArtifactQueryResult { const pageArtifacts: ArtifactProjection[] = []; + const budget = new JsonArrayPageBudget(ARTIFACT_RESULT_MAX_BYTES, { + kind: 'page', + sessionId, + revision, + artifacts: [], + nextCursor: null, + }); for (const record of records) { const artifact = encodeArtifactProjection(record); - const candidateArtifacts = [...pageArtifacts, artifact]; - const nextOffset = offset + candidateArtifacts.length; - const candidate: ArtifactQueryResult = { - kind: 'page', - sessionId, - revision, - artifacts: candidateArtifacts, - nextCursor: nextOffset < total ? String(nextOffset) : null, - }; - if (Buffer.byteLength(JSON.stringify(candidate), 'utf8') > ARTIFACT_RESULT_MAX_BYTES) { + const nextOffset = offset + pageArtifacts.length + 1; + if (!budget.tryAppend(artifact, nextOffset < total ? String(nextOffset) : null)) { if (pageArtifacts.length === 0) { throw new Error('A canonical Artifact cannot fit in one page'); } diff --git a/packages/runtime-host/src/server/external-session-coordinator.ts b/packages/runtime-host/src/server/external-session-coordinator.ts index 53852ef9f2..e4d0cb61d7 100644 --- a/packages/runtime-host/src/server/external-session-coordinator.ts +++ b/packages/runtime-host/src/server/external-session-coordinator.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonArrayPageBudget } from './json-array-page-budget.js'; + import type { ExternalSessionAdapter, ExternalSessionAdapterRegistry, @@ -362,14 +364,13 @@ function boundedCatalogPage( totalCount: number, ): ExternalSessionCatalogItem[] { const page: ExternalSessionCatalogItem[] = []; + const budget = new JsonArrayPageBudget(EXTERNAL_SESSION_RESULT_MAX_BYTES, { + sessions: [], + nextCursor: null, + }); for (const candidate of candidates) { - const nextPage = [...page, candidate]; - const nextOffset = offset + nextPage.length; - const result = { - sessions: nextPage, - nextCursor: nextOffset < totalCount ? String(nextOffset) : null, - }; - if (Buffer.byteLength(JSON.stringify(result), 'utf8') > EXTERNAL_SESSION_RESULT_MAX_BYTES) { + const nextOffset = offset + page.length + 1; + if (!budget.tryAppend(candidate, nextOffset < totalCount ? String(nextOffset) : null)) { break; } page.push(candidate); diff --git a/packages/runtime-host/src/server/json-array-page-budget.ts b/packages/runtime-host/src/server/json-array-page-budget.ts new file mode 100644 index 0000000000..12154565bd --- /dev/null +++ b/packages/runtime-host/src/server/json-array-page-budget.ts @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Budgets a page of plain protocol projections without encoding its accepted + * items again. The template must contain the empty item array and a null cursor + * (or nextOffset); all other fields must stay unchanged while assembling a page. + * Accepted items must also remain unchanged. This counts a result, not a frame. + */ +export class JsonArrayPageBudget { + readonly #envelopeBytes: number; + #itemsBytes = 0; + #count = 0; + + constructor( + private readonly maxBytes: number, + emptyPage: object, + ) { + // The template already includes array brackets and the cursor's field name. + this.#envelopeBytes = jsonBytes(emptyPage) - 'null'.length; + } + + tryAppend(item: unknown, cursor: unknown): boolean { + // JSON array elements that encode as undefined are represented by null. + const itemBytes = Buffer.byteLength(JSON.stringify(item) ?? 'null', 'utf8'); + const candidateBytes = this.#itemsBytes + (this.#count > 0 ? 1 : 0) + itemBytes; + if (this.#envelopeBytes + candidateBytes + jsonBytes(cursor) > this.maxBytes) return false; + this.#itemsBytes = candidateBytes; + this.#count += 1; + return true; + } +} + +function jsonBytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), 'utf8'); +} diff --git a/packages/runtime-host/src/server/memory-projection.ts b/packages/runtime-host/src/server/memory-projection.ts index 39540b5019..65c64d05b2 100644 --- a/packages/runtime-host/src/server/memory-projection.ts +++ b/packages/runtime-host/src/server/memory-projection.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonArrayPageBudget } from './json-array-page-budget.js'; + import { parseLocalMemoryMarkdown, type LocalMemoryEntryPreview } from '@maka/core/local-memory'; import type { MemoryBackupSnapshot, @@ -157,31 +159,29 @@ function entriesPage( offset: number, ): MemoryEntriesPage { const items: MemoryEntryProjection[] = []; + const page: MemoryEntriesPage = { + kind: 'entries_page', + view, + revision, + items, + nextCursor: null, + }; + const budget = new JsonArrayPageBudget(MEMORY_RESULT_MAX_BYTES, page); const limit = Math.min(source.length, offset + MEMORY_ENTRY_PAGE_MAX_ITEMS); for (let index = offset; index < limit; index += 1) { const entry = source[index]; if (!entry) break; - const candidate = [...items, projectEntry(entry)]; - const nextOffset = offset + candidate.length; - const page = { - kind: 'entries_page' as const, - view, - revision, - items: candidate, - nextCursor: nextOffset < source.length ? nextOffset : null, - }; - if (Buffer.byteLength(JSON.stringify(page), 'utf8') > MEMORY_RESULT_MAX_BYTES) break; - items.push(candidate.at(-1)!); + const projected = projectEntry(entry); + const nextOffset = offset + items.length + 1; + if (!budget.tryAppend(projected, nextOffset < source.length ? nextOffset : null)) break; + items.push(projected); } if (items.length === 0 && offset < source.length) { throw new Error('A legal Memory entry exceeded the page result byte limit'); } const nextOffset = offset + items.length; return { - kind: 'entries_page', - view, - revision, - items, + ...page, nextCursor: nextOffset < source.length ? nextOffset : null, }; } diff --git a/packages/runtime-host/src/server/plan-coordinator.ts b/packages/runtime-host/src/server/plan-coordinator.ts index 8bd777fbd6..8259704b89 100644 --- a/packages/runtime-host/src/server/plan-coordinator.ts +++ b/packages/runtime-host/src/server/plan-coordinator.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonArrayPageBudget } from './json-array-page-budget.js'; + import { createHash } from 'node:crypto'; import { PlanConflictError, @@ -308,16 +310,21 @@ function fitPage( offset: number, ): Extract { const items: PlanProjectionItem[] = []; + const budget = new JsonArrayPageBudget(PLAN_RESULT_MAX_BYTES, { + kind: 'page', + ...header, + items: [], + nextCursor: null, + }); const limit = Math.min(allItems.length, offset + PLAN_PAGE_MAX_ITEMS); for (let index = offset; index < limit; index += 1) { - const candidate = [...items, structuredClone(allItems[index]!)]; - const nextOffset = offset + candidate.length; - const page = planPage(header, candidate, nextOffset, allItems.length); - if (Buffer.byteLength(JSON.stringify(page), 'utf8') > PLAN_RESULT_MAX_BYTES) { + const item = structuredClone(allItems[index]!); + const nextOffset = offset + items.length + 1; + if (!budget.tryAppend(item, nextOffset < allItems.length ? String(nextOffset) : null)) { if (items.length === 0) throw new Error('Persisted Plan item exceeds its wire invariant'); break; } - items.push(candidate.at(-1)!); + items.push(item); } return planPage(header, items, offset + items.length, allItems.length); } diff --git a/packages/runtime-host/src/server/plugin-platform-coordinator.ts b/packages/runtime-host/src/server/plugin-platform-coordinator.ts index 415f092272..889fd266e0 100644 --- a/packages/runtime-host/src/server/plugin-platform-coordinator.ts +++ b/packages/runtime-host/src/server/plugin-platform-coordinator.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonArrayPageBudget } from './json-array-page-budget.js'; + import { createHash } from 'node:crypto'; import { MakaPluginRuntimeError, @@ -213,18 +215,14 @@ function boundedPage( if (cursor > values.length) throw new HostPluginPlatformError('stale_cursor', 'Plugin Platform query cursor is stale'); const items: T[] = []; + const budget = new JsonArrayPageBudget(PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES, { + view, + items: [], + nextCursor: null, + }); for (let index = cursor; index < values.length && items.length < limit; index += 1) { - const candidate = [...items, values[index] as T]; - if ( - Buffer.byteLength( - JSON.stringify({ - view, - items: candidate, - nextCursor: encodeCursor(view, input.rootId, digest, index + 1), - }), - 'utf8', - ) > PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES - ) { + // Preserve the existing non-null cursor reservation, including the last item. + if (!budget.tryAppend(values[index], encodeCursor(view, input.rootId, digest, index + 1))) { break; } items.push(values[index] as T); diff --git a/packages/runtime-host/src/server/project-catalog-coordinator.ts b/packages/runtime-host/src/server/project-catalog-coordinator.ts index 7c3040560f..032a2b3f9f 100644 --- a/packages/runtime-host/src/server/project-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/project-catalog-coordinator.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonArrayPageBudget } from './json-array-page-budget.js'; + import { createHash } from 'node:crypto'; import type { ProjectRecord } from '@maka/core/project'; import { @@ -252,20 +254,20 @@ function createPage( offset: number, ): ProjectCatalogQueryResult { const pageItems: ProjectCatalogPageItem[] = []; + const budget = new JsonArrayPageBudget(PROJECT_CATALOG_PAGE_MAX_BYTES, { + kind: 'page', + view, + revision, + projectCount, + items: [], + nextCursor: null, + }); for (let index = offset; index < items.length; index += 1) { if (pageItems.length >= PROJECT_CATALOG_PAGE_MAX_ITEMS) break; const item = items[index]; if (!item) throw new Error('Project catalog projection index was out of bounds'); const nextOffset = index + 1; - const candidate: ProjectCatalogQueryResult = { - kind: 'page', - view, - revision, - projectCount, - items: [...pageItems, item], - nextCursor: nextOffset < items.length ? encodeCursor(nextOffset) : null, - }; - if (encodedBytes(candidate) > PROJECT_CATALOG_PAGE_MAX_BYTES) break; + if (!budget.tryAppend(item, nextOffset < items.length ? encodeCursor(nextOffset) : null)) break; pageItems.push(item); } if (pageItems.length === 0 && offset < items.length) { @@ -292,10 +294,6 @@ function decodeCursor(cursor: string): number | undefined { return Number.isSafeInteger(offset) ? offset : undefined; } -function encodedBytes(value: unknown): number { - return Buffer.byteLength(JSON.stringify(value), 'utf8'); -} - function isInvalidPathError(error: unknown): boolean { const code = (error as NodeJS.ErrnoException)?.code; return ( diff --git a/packages/runtime-host/src/server/project-directory-authority.ts b/packages/runtime-host/src/server/project-directory-authority.ts index d01a0d818b..825a85f59a 100644 --- a/packages/runtime-host/src/server/project-directory-authority.ts +++ b/packages/runtime-host/src/server/project-directory-authority.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonArrayPageBudget } from './json-array-page-budget.js'; + import { realpathSync, statSync } from 'node:fs'; import { opendir, realpath, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; @@ -121,6 +123,10 @@ export class HostProjectDirectoryAuthority { const names = await boundedDirectoryNames(directory); const start = input.kind === 'directory_list_start' ? 0 : firstNameAfter(names, input.cursor); const entries: { name: string }[] = []; + const budget = new JsonArrayPageBudget( + PROJECT_DIRECTORY_PAGE_MAX_BYTES, + directoryPage(input, [], null), + ); for (let index = start; index < names.length; index += 1) { const name = names[index]; if (!name) continue; @@ -132,11 +138,8 @@ export class HostProjectDirectoryAuthority { // Entries can disappear while a directory is being listed. } if (!contained) continue; - const page = directoryPage(input, [...entries, { name }], name); - if ( - entries.length >= PROJECT_DIRECTORY_PAGE_MAX_ITEMS || - Buffer.byteLength(JSON.stringify(page), 'utf8') > PROJECT_DIRECTORY_PAGE_MAX_BYTES - ) { + // Keep reserving the candidate directory name, even for a possible final page. + if (entries.length >= PROJECT_DIRECTORY_PAGE_MAX_ITEMS || !budget.tryAppend({ name }, name)) { if (entries.length === 0) { throw new TypeError('Project directory entry exceeds the response limit'); } diff --git a/packages/runtime-host/src/server/runtime-policy-coordinator.ts b/packages/runtime-host/src/server/runtime-policy-coordinator.ts index aec72b6ffb..ee177ea300 100644 --- a/packages/runtime-host/src/server/runtime-policy-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-policy-coordinator.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonArrayPageBudget } from './json-array-page-budget.js'; + import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot, @@ -542,21 +544,25 @@ function catalogPage( offset: number, ): ConnectionCatalogQueryResult { const items: ConnectionCatalogPageItem[] = []; + const budget = new JsonArrayPageBudget(CONNECTION_CATALOG_PAGE_MAX_BYTES, { + kind: 'page', + revision: snapshot.revision, + defaultTarget: snapshot.defaultTarget, + connectionCount: snapshot.connections.length, + items: [], + nextCursor: null, + }); const limit = Math.min(allItems.length, offset + CONNECTION_CATALOG_PAGE_MAX_ITEMS); for (let index = offset; index < limit; index += 1) { const item = allItems[index]; if (!item) throw invariantFailure('Catalog projection index was out of bounds'); - const candidate = [...items, item]; - const nextOffset = offset + candidate.length; - const result = { - kind: 'page' as const, - revision: snapshot.revision, - defaultTarget: snapshot.defaultTarget, - connectionCount: snapshot.connections.length, - items: candidate, - nextCursor: nextOffset < allItems.length ? cursorForItem(allItems[nextOffset]) : null, - }; - if (Buffer.byteLength(JSON.stringify(result), 'utf8') > CONNECTION_CATALOG_PAGE_MAX_BYTES) { + const nextOffset = offset + items.length + 1; + if ( + !budget.tryAppend( + item, + nextOffset < allItems.length ? cursorForItem(allItems[nextOffset]) : null, + ) + ) { break; } items.push(item); diff --git a/packages/runtime-host/src/server/runtime-resource-projection.ts b/packages/runtime-host/src/server/runtime-resource-projection.ts index bf823ee0fc..04ff7fe8dc 100644 --- a/packages/runtime-host/src/server/runtime-resource-projection.ts +++ b/packages/runtime-host/src/server/runtime-resource-projection.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonArrayPageBudget } from './json-array-page-budget.js'; + import { createHash } from 'node:crypto'; import type { ShellRunSnapshotResult, @@ -65,20 +67,19 @@ export function createRuntimeResourcePage( offset: number, ): RuntimeResourceQueryResult { const pageResources: ShellRunUpdate[] = []; + const budget = new JsonArrayPageBudget(RUNTIME_RESOURCE_RESULT_MAX_BYTES, { + kind: 'page', + sessionId, + revision, + resources: [], + nextCursor: null, + }); for (let index = offset; index < resources.length; index += 1) { if (pageResources.length >= RUNTIME_RESOURCE_PAGE_MAX_ITEMS) break; const resource = resources[index]; if (!resource) throw new Error('Runtime Resource projection index was out of bounds'); - const candidateResources = [...pageResources, resource]; const nextOffset = index + 1; - const candidate = { - kind: 'page' as const, - sessionId, - revision, - resources: candidateResources, - nextCursor: nextOffset < resources.length ? String(nextOffset) : null, - }; - if (Buffer.byteLength(JSON.stringify(candidate), 'utf8') > RUNTIME_RESOURCE_RESULT_MAX_BYTES) { + if (!budget.tryAppend(resource, nextOffset < resources.length ? String(nextOffset) : null)) { break; } pageResources.push(resource); diff --git a/packages/runtime-host/src/server/scheduled-task-coordinator.ts b/packages/runtime-host/src/server/scheduled-task-coordinator.ts index 9df2251876..8e7c670201 100644 --- a/packages/runtime-host/src/server/scheduled-task-coordinator.ts +++ b/packages/runtime-host/src/server/scheduled-task-coordinator.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonArrayPageBudget } from './json-array-page-budget.js'; + import { randomUUID } from 'node:crypto'; import { botDisplayLabel } from '@maka/core/bot-events'; import { isBotDeliveryProvider } from '@maka/core/bot-chat-settings'; @@ -958,19 +960,18 @@ function createScheduledTaskPage( offset: number, ) { const page: ScheduledTask[] = []; + const budget = new JsonArrayPageBudget(SCHEDULED_TASK_RESULT_MAX_BYTES, { + kind: 'page', + revision, + tasks: [], + nextCursor: null, + }); for (let index = offset; index < tasks.length; index += 1) { if (page.length >= SCHEDULED_TASK_PAGE_MAX_ITEMS) break; const task = tasks[index]; if (!task) throw new Error('ScheduledTask page index is invalid'); - const candidate = [...page, task]; - const nextOffset = offset + candidate.length; - const result = { - kind: 'page' as const, - revision, - tasks: candidate, - nextCursor: nextOffset < tasks.length ? String(nextOffset) : null, - }; - if (Buffer.byteLength(JSON.stringify(result), 'utf8') > SCHEDULED_TASK_RESULT_MAX_BYTES) { + const nextOffset = offset + page.length + 1; + if (!budget.tryAppend(task, nextOffset < tasks.length ? String(nextOffset) : null)) { break; } page.push(task); diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index c2a3b1b5d2..4fc69163d2 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonArrayPageBudget } from './json-array-page-budget.js'; + import { RuntimeHostProtocolError } from '../protocol/errors.js'; import { createHash } from 'node:crypto'; import { authorizeConnectionModel, connectionEnabledModelIds } from '@maka/core/llm-connections'; @@ -1485,18 +1487,18 @@ function page( project: (record: SessionCatalogRecord) => SessionCatalogItem = projectSessionCatalogRecord, ): SessionCatalogQueryResult { const items: SessionCatalogItem[] = []; + const budget = new JsonArrayPageBudget(SESSION_CATALOG_RESULT_MAX_BYTES, { + kind: 'page', + revision, + sessions: [], + nextCursor: null, + }); for (let index = 0; index < records.length; index += 1) { const record = records[index]; if (!record) throw new Error('Session catalog record index is invalid'); const item = project(record); const moreItems = index + 1 < records.length || hasMore; - const candidate = { - kind: 'page' as const, - revision, - sessions: [...items, item], - nextCursor: moreItems ? encodeCursor(record) : null, - }; - if (Buffer.byteLength(JSON.stringify(candidate), 'utf8') > SESSION_CATALOG_RESULT_MAX_BYTES) { + if (!budget.tryAppend(item, moreItems ? encodeCursor(record) : null)) { break; } items.push(item); diff --git a/packages/runtime-host/src/server/skill-catalog-repository.ts b/packages/runtime-host/src/server/skill-catalog-repository.ts index 65a1b7fd91..ea2bdaab7c 100644 --- a/packages/runtime-host/src/server/skill-catalog-repository.ts +++ b/packages/runtime-host/src/server/skill-catalog-repository.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonArrayPageBudget } from './json-array-page-budget.js'; + import { createHash, randomUUID } from 'node:crypto'; import { lstat, mkdir, open, readdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises'; import { dirname, isAbsolute, join, resolve } from 'node:path'; @@ -1414,18 +1416,17 @@ function createPage( offset: number, ): SkillCatalogQueryProjection { const pageItems: SkillCatalogPageItem[] = []; + const budget = new JsonArrayPageBudget(SKILL_CATALOG_PAGE_MAX_BYTES, { + kind: 'page', + view, + revision, + items: [], + nextCursor: null, + }); let cursor = offset; while (cursor < items.length && pageItems.length < SKILL_CATALOG_PAGE_MAX_ITEMS) { - const candidate = [...pageItems, items[cursor]]; const hasMore = cursor + 1 < items.length; - const result = { - kind: 'page' as const, - view, - revision, - items: candidate, - nextCursor: hasMore ? encodeCursor(view, cursor + 1) : null, - }; - if (jsonBytes(result) > SKILL_CATALOG_PAGE_MAX_BYTES) { + if (!budget.tryAppend(items[cursor], hasMore ? encodeCursor(view, cursor + 1) : null)) { if (pageItems.length === 0) { throw new SkillCatalogRepositoryError( 'persistence_failed', @@ -1494,17 +1495,16 @@ function createInvocablePage( offset: number, ): SkillCatalogInvocableQueryResult { const pageItems: SkillCatalogInvocableItem[] = []; + const budget = new JsonArrayPageBudget(SKILL_CATALOG_PAGE_MAX_BYTES, { + kind: 'page', + revision, + items: [], + nextCursor: null, + }); let cursor = offset; while (cursor < items.length && pageItems.length < SKILL_CATALOG_PAGE_MAX_ITEMS) { - const candidate = [...pageItems, items[cursor]]; const hasMore = cursor + 1 < items.length; - const result = { - kind: 'page' as const, - revision, - items: candidate, - nextCursor: hasMore ? encodeInvocableCursor(cursor + 1) : null, - }; - if (jsonBytes(result) > SKILL_CATALOG_PAGE_MAX_BYTES) { + if (!budget.tryAppend(items[cursor], hasMore ? encodeInvocableCursor(cursor + 1) : null)) { if (pageItems.length === 0) { throw new SkillCatalogRepositoryError( 'persistence_failed', diff --git a/packages/runtime-host/src/server/usage-pricing-coordinator.ts b/packages/runtime-host/src/server/usage-pricing-coordinator.ts index 149546a24d..23c0a6bf87 100644 --- a/packages/runtime-host/src/server/usage-pricing-coordinator.ts +++ b/packages/runtime-host/src/server/usage-pricing-coordinator.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonArrayPageBudget } from './json-array-page-budget.js'; + import { createHash } from 'node:crypto'; import type { PricingConfig, @@ -417,20 +419,19 @@ function createPricingPage( offset: number, ): PricingQueryResult { const items: EffectivePricingEntry[] = []; + const budget = new JsonArrayPageBudget(PRICING_PAGE_MAX_BYTES, { + kind: 'page', + revision, + offset, + entries: [], + nextOffset: null, + }); for (let index = offset; index < entries.length; index += 1) { if (items.length >= PRICING_PAGE_MAX_ITEMS) break; const item = entries[index]; if (!item) break; - const candidate = [...items, item]; - const nextOffset = offset + candidate.length; - const page: PricingQueryResult = { - kind: 'page', - revision, - offset, - entries: candidate, - nextOffset: nextOffset < entries.length ? nextOffset : null, - }; - if (jsonBytes(page) > PRICING_PAGE_MAX_BYTES) { + const nextOffset = offset + items.length + 1; + if (!budget.tryAppend(item, nextOffset < entries.length ? nextOffset : null)) { if (items.length === 0) { throw new Error('Canonical pricing entry exceeds the wire page limit'); } @@ -478,20 +479,13 @@ function usagePage( ): Extract { const source = allItems.slice(offset, offset + limit); const items: UsageBucket[] = []; + const budget = new JsonArrayPageBudget( + USAGE_PAGE_MAX_BYTES, + bucketPageResult([], total, offset, null, provenance), + ); for (const item of source) { - const candidate = [...items, item]; - const nextOffset = offset + candidate.length; - if ( - jsonBytes( - bucketPageResult( - candidate, - total, - offset, - nextOffset < total ? nextOffset : null, - provenance, - ), - ) > USAGE_PAGE_MAX_BYTES - ) { + const nextOffset = offset + items.length + 1; + if (!budget.tryAppend(item, nextOffset < total ? nextOffset : null)) { break; } items.push(item); @@ -537,21 +531,13 @@ function usageLogPage( provenance?: UsageProvenance, ): Extract { const items: UsageLogProjection[] = []; + const budget = new JsonArrayPageBudget( + USAGE_PAGE_MAX_BYTES, + logPageResult(source, [], total, offset, null, provenance), + ); for (const item of allItems.slice(0, limit)) { - const candidate = [...items, item]; - const nextOffset = offset + candidate.length; - if ( - jsonBytes( - logPageResult( - source, - candidate, - total, - offset, - nextOffset < total ? nextOffset : null, - provenance, - ), - ) > USAGE_PAGE_MAX_BYTES - ) { + const nextOffset = offset + items.length + 1; + if (!budget.tryAppend(item, nextOffset < total ? nextOffset : null)) { break; } items.push(item); @@ -736,7 +722,3 @@ function projectCodePoint(codePoint: string): string { ? '\ufffd' : codePoint; } - -function jsonBytes(value: unknown): number { - return Buffer.byteLength(JSON.stringify(value), 'utf8'); -} From ea0263d1a9d0afa7a2283e2839c6d30a2681a51f Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 11 Sep 2026 00:37:09 +0800 Subject: [PATCH 2/3] ci(windows): update Skill catalog test count Account for the two pagination coverage cases; require 93 passing tests and zero skips. Refs #5038 Generated-by: OpenAI Codex --- .github/workflows/windows-recovery.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 886404f416..c39b8d4f93 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -226,8 +226,8 @@ jobs: $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { exit $exitCode } $output = Get-Content "$env:RUNNER_TEMP/skill-catalog.tap" - if ($output -notcontains '# tests 91' -or $output -notcontains '# pass 91' -or $output -notcontains '# skipped 0') { - Write-Error 'Skill catalog gate did not run exactly 91 passing Windows tests' + if ($output -notcontains '# tests 93' -or $output -notcontains '# pass 93' -or $output -notcontains '# skipped 0') { + Write-Error 'Skill catalog gate did not run exactly 93 passing Windows tests' exit 1 } From d45c16dda6562d323ebfe55688a00cb19b9b954d Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 11 Sep 2026 01:29:46 +0800 Subject: [PATCH 3/3] test(ci): align Skill catalog gate assertions Expect 93 passing Skill catalog tests in the workflow policy checks, matching the Windows gate after pagination coverage was added. Refs #5038 Generated-by: OpenAI Codex --- scripts/ci-workflow-policy.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci-workflow-policy.test.mjs b/scripts/ci-workflow-policy.test.mjs index 1604f2b5ed..97ef6b25bf 100644 --- a/scripts/ci-workflow-policy.test.mjs +++ b/scripts/ci-workflow-policy.test.mjs @@ -913,8 +913,8 @@ test('Windows recovery executes the complete Skill catalog suite', () => { assert.match(recovery, /skill-catalog-repository\.test\.js/u); assert.match(recovery, /skill-catalog-transaction\.test\.js/u); assert.match(recovery, /skill-catalog-two-client-uds\.test\.js/u); - assert.match(recovery, /# tests 91/u); - assert.match(recovery, /# pass 91/u); + assert.match(recovery, /# tests 93/u); + assert.match(recovery, /# pass 93/u); assert.match(recovery, /# skipped 0/u); });