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 } 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'); -} 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); });