diff --git a/README.md b/README.md index a80752e971f..02140941d57 100644 --- a/README.md +++ b/README.md @@ -188,9 +188,9 @@ Workspace data lives under Electron `userData` by default: ```text /workspaces/default/ runtime.sqlite - connection-catalog.json - credential-vault.json - settings.json + settings.json # UI and workspace preferences + connection-catalog.json # provider and model metadata + credential-vault.json # local provider secrets artifacts/ ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 324fbf8ea86..109b28cc74d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -187,9 +187,9 @@ Workspace 数据默认放在 Electron `userData` 下: ```text /workspaces/default/ runtime.sqlite - connection-catalog.json - credential-vault.json - settings.json + settings.json # 界面与工作区偏好 + connection-catalog.json # 提供商与模型元数据 + credential-vault.json # 本地提供商密钥 artifacts/ ``` diff --git a/apps/desktop/e2e/streaming-paint-observer.ts b/apps/desktop/e2e/streaming-paint-observer.ts new file mode 100644 index 00000000000..206721ccd97 --- /dev/null +++ b/apps/desktop/e2e/streaming-paint-observer.ts @@ -0,0 +1,89 @@ +/* + * 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. + */ + +export interface StreamingPaintObservation { + texts: string[]; + maxActiveAnimations: number; +} + +interface InstalledStreamingPaintObservation extends StreamingPaintObservation { + stop(): void; +} + +declare global { + interface Window { + __makaBackgroundRestoreObserved?: InstalledStreamingPaintObservation; + } +} + +/** Install a paint-clock sampler inside the browser under test. */ +export function installStreamingPaintObserver(): void { + const observed: InstalledStreamingPaintObservation = { + texts: [], + maxActiveAnimations: 0, + stop() {}, + }; + let stopped = false; + const sample = () => { + if (stopped) return; + const bubbles = [ + ...document.querySelectorAll(".maka-bubble-streaming"), + ]; + const bubble = bubbles.at(-1); + if (bubble) { + const text = bubble.textContent ?? ""; + if (observed.texts.at(-1) !== text) observed.texts.push(text); + observed.maxActiveAnimations = Math.max( + observed.maxActiveAnimations, + ...bubbles.map( + (element) => + element + .getAnimations({ subtree: true }) + .filter((animation) => animation.playState !== "finished").length, + ), + ); + } + window.requestAnimationFrame(sample); + }; + observed.stop = () => { + stopped = true; + }; + window.__makaBackgroundRestoreObserved = observed; + window.requestAnimationFrame(sample); +} + +/** Stop after one final painted frame and return a serializable snapshot. */ +export function stopStreamingPaintObserver(): Promise< + StreamingPaintObservation | undefined +> { + return new Promise((resolve) => { + window.requestAnimationFrame(() => { + const observed = window.__makaBackgroundRestoreObserved; + observed?.stop(); + resolve( + observed + ? { + texts: observed.texts, + maxActiveAnimations: observed.maxActiveAnimations, + } + : undefined, + ); + }); + }); +} diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts index d5046f5855e..bec2701374a 100644 --- a/apps/desktop/e2e/streaming-remount.spec.ts +++ b/apps/desktop/e2e/streaming-remount.spec.ts @@ -20,22 +20,26 @@ import { FAKE_HOLD_OPEN_PROMPT, FAKE_HOLD_OPEN_REWRITE_PROMPT, -} from '@maka/runtime/test-only/fake-backend'; -import type { Locator } from '@playwright/test'; +} from "@maka/runtime/test-only/fake-backend"; +import type { Locator } from "@playwright/test"; import { awaitSendReady, COMPOSER_INPUT, ensureSidebarExpanded, expect, test, -} from './fixtures'; +} from "./fixtures"; +import { + installStreamingPaintObserver, + stopStreamingPaintObserver, +} from "./streaming-paint-observer"; interface SessionObservationLatchWindow extends Window { /** E2E-only preload affordance; see the MAKA_E2E block in preload.ts. */ makaE2eLatch?: { rejectNextSessionObservation(message: string): void; - arm(key: 'sessions.observe'): void; - release(key: 'sessions.observe'): void; + arm(key: "sessions.observe"): void; + release(key: "sessions.observe"): void; rejectNextTranscriptOpen(message: string): void; }; } @@ -44,116 +48,185 @@ function sessionRow(sidebar: Locator, sessionId: string): Locator { return sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`); } -async function steerActiveTurn(composer: Locator, text: string): Promise { - // Mid-turn steering is Cmd/Ctrl+Enter: Send stays Send, and the modified - // submit hands the draft to the active Turn once. +async function submitSteeringDraft( + composer: Locator, + text: string, +): Promise { await composer.fill(text); - await composer.press('ControlOrMeta+Enter'); + await composer.press("ControlOrMeta+Enter"); } -test('ordinary Enter queues on an already-running Session before observation recovers', async ({ window: page }) => { - const nextPrompt = 'do this only after the current answer'; - const sessionId = await page.evaluate(async ({ prompt, nextPrompt }) => { - const session = await window.maka.sessions.create({ name: 'Observation recovery' }); - const result = await window.maka.sessions.submitMessage(session.id, 'next_turn', { - messageId: crypto.randomUUID(), text: prompt, - }, { waitForHostAdmission: true }); - if (!result.ok) throw new Error('Failed to start the background Turn'); - const evidence = { queued: false, steered: false }; - (window as typeof window & { admissionEvidence?: typeof evidence }).admissionEvidence = evidence; - // This independent reader records the actual Host queue, without writing AppShell state. - await new Promise((resolve) => { - window.maka.sessions.subscribeEvents(session.id, (event) => { - if (event.type !== 'queue_update') return; - evidence.queued ||= event.followupEntries?.some((entry) => entry.content.text === nextPrompt) ?? false; - evidence.steered ||= event.steeringEntries?.some((entry) => entry.content.text === nextPrompt) ?? false; - }, resolve); - }); - const latch = (window as SessionObservationLatchWindow).makaE2eLatch!; - latch.arm('sessions.observe'); - latch.rejectNextSessionObservation('forced first observation failure'); - return session.id; - }, { prompt: FAKE_HOLD_OPEN_PROMPT, nextPrompt }); - const sidebar = page.getByRole('navigation', { name: '任务列表' }); +test("ordinary Enter queues on an already-running Session before observation recovers", async ({ + window: page, +}) => { + const nextPrompt = "do this only after the current answer"; + const sessionId = await page.evaluate( + async ({ prompt, nextPrompt }) => { + const session = await window.maka.sessions.create({ + name: "Observation recovery", + }); + const result = await window.maka.sessions.submitMessage( + session.id, + "next_turn", + { + messageId: crypto.randomUUID(), + text: prompt, + }, + { waitForHostAdmission: true }, + ); + if (!result.ok) throw new Error("Failed to start the background Turn"); + const evidence = { queued: false, steered: false }; + ( + window as typeof window & { admissionEvidence?: typeof evidence } + ).admissionEvidence = evidence; + // This independent reader records the actual Host queue, without writing AppShell state. + await new Promise((resolve) => { + window.maka.sessions.subscribeEvents( + session.id, + (event) => { + if (event.type !== "queue_update") return; + evidence.queued ||= + event.followupEntries?.some( + (entry) => entry.content.text === nextPrompt, + ) ?? false; + evidence.steered ||= + event.steeringEntries?.some( + (entry) => entry.content.text === nextPrompt, + ) ?? false; + }, + resolve, + ); + }); + const latch = (window as SessionObservationLatchWindow).makaE2eLatch!; + latch.arm("sessions.observe"); + latch.rejectNextSessionObservation("forced first observation failure"); + return session.id; + }, + { prompt: FAKE_HOLD_OPEN_PROMPT, nextPrompt }, + ); + const sidebar = page.getByRole("navigation", { name: "任务列表" }); await ensureSidebarExpanded(page); await sessionRow(sidebar, sessionId).click(); // No execution snapshot has reached this surface. Sending must still express next-turn intent. - await expect(page.getByRole('button', { name: '停止', exact: true })).toHaveCount(0); + await expect( + page.getByRole("button", { name: "停止", exact: true }), + ).toHaveCount(0); const composer = page.locator(COMPOSER_INPUT); await composer.fill(nextPrompt); await awaitSendReady(page); - await composer.press('Enter'); - await expect.poll(() => page.evaluate(() => - (window as typeof window & { admissionEvidence?: { queued: boolean; steered: boolean } }).admissionEvidence, - )).toEqual({ queued: true, steered: false }); - await page.evaluate(() => (window as SessionObservationLatchWindow).makaE2eLatch!.release('sessions.observe')); - await expect(page.locator('.maka-bubble-streaming')).toContainText('Fake backend waiting', { timeout: 20_000 }); - await page.getByRole('button', { name: '停止', exact: true }).click(); - await expect(page.getByRole('button', { name: '停止', exact: true })).toHaveCount(0, { timeout: 20_000 }); + await composer.press("Enter"); + await expect + .poll(() => + page.evaluate( + () => + ( + window as typeof window & { + admissionEvidence?: { queued: boolean; steered: boolean }; + } + ).admissionEvidence, + ), + ) + .toEqual({ queued: true, steered: false }); + await page.evaluate(() => + (window as SessionObservationLatchWindow).makaE2eLatch!.release( + "sessions.observe", + ), + ); + await expect(page.locator(".maka-bubble-streaming")).toContainText( + "Fake backend waiting", + { timeout: 20_000 }, + ); + await page.getByRole("button", { name: "停止", exact: true }).click(); + await expect( + page.getByRole("button", { name: "停止", exact: true }), + ).toHaveCount(0, { timeout: 20_000 }); }); -test('a failed transcript open recovers when its Session observation becomes ready', async ({ +test("a failed transcript open recovers when its Session observation becomes ready", async ({ window: page, }) => { - const originalPrompt = 'transcript recovery source'; + const originalPrompt = "transcript recovery source"; const composer = page.locator(COMPOSER_INPUT); await composer.fill(originalPrompt); await awaitSendReady(page); - await composer.press('Enter'); - await expect(page.getByRole('log')).toContainText(`Fake backend received: ${originalPrompt}`, { - timeout: 20_000, - }); + await composer.press("Enter"); + await expect(page.getByRole("log")).toContainText( + `Fake backend received: ${originalPrompt}`, + { + timeout: 20_000, + }, + ); - const sidebar = page.getByRole('navigation', { name: '任务列表' }); + const sidebar = page.getByRole("navigation", { name: "任务列表" }); await ensureSidebarExpanded(page); const originalSessionId = await sidebar .locator('[data-session-id]:has([aria-current="page"])') - .getAttribute('data-session-id'); + .getAttribute("data-session-id"); expect(originalSessionId).toBeTruthy(); - await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); + await sidebar.getByRole("button", { name: "新任务", exact: true }).click(); const latchInstalled = await page.evaluate(() => { const latch = (window as SessionObservationLatchWindow).makaE2eLatch; if (!latch) return false; - latch.rejectNextTranscriptOpen('forced first transcript failure'); + latch.rejectNextTranscriptOpen("forced first transcript failure"); return true; }); - expect(latchInstalled, 'the preload E2E latch is installed').toBe(true); + expect(latchInstalled, "the preload E2E latch is installed").toBe(true); await sessionRow(sidebar, originalSessionId!).click(); - await expect(page.getByRole('log')).toContainText(`Fake backend received: ${originalPrompt}`, { - timeout: 20_000, - }); + await expect(page.getByRole("log")).toContainText( + `Fake backend received: ${originalPrompt}`, + { + timeout: 20_000, + }, + ); }); -test('a successor owns working status and remounting leaves accumulated output settled', async ({ +test("a successor owns working status and remounting leaves accumulated output settled", async ({ window: page, }) => { - await page.emulateMedia({ reducedMotion: 'no-preference' }); - expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(false); + await page.emulateMedia({ reducedMotion: "no-preference" }); + expect( + await page.evaluate( + () => matchMedia("(prefers-reduced-motion: reduce)").matches, + ), + ).toBe(false); const composer = page.locator(COMPOSER_INPUT); - await composer.fill('complete the predecessor'); + await composer.fill("complete the predecessor"); await awaitSendReady(page); - await composer.press('Enter'); - await expect(page.getByRole('log')).toContainText('Fake backend received: complete the predecessor'); - await expect(page.getByRole('button', { name: '停止', exact: true })).toHaveCount(0); - const previousTurnId = await page.locator('[data-transcript-turn-id]').first().getAttribute('data-transcript-turn-id'); + await composer.press("Enter"); + await expect(page.getByRole("log")).toContainText( + "Fake backend received: complete the predecessor", + ); + await expect( + page.getByRole("button", { name: "停止", exact: true }), + ).toHaveCount(0); + const previousTurnId = await page + .locator("[data-transcript-turn-id]") + .first() + .getAttribute("data-transcript-turn-id"); expect(previousTurnId).toBeTruthy(); await composer.fill(FAKE_HOLD_OPEN_REWRITE_PROMPT); await awaitSendReady(page); - await composer.press('Enter'); + await composer.press("Enter"); - const accumulatedOutput = 'prefix sk-123456789012345'; - const liveBubble = page.locator('.maka-bubble-streaming'); - await expect(liveBubble).toContainText(accumulatedOutput, { timeout: 20_000 }); - await expect(page.locator(`[data-transcript-turn-id=${JSON.stringify(previousTurnId)}] .maka-turn-processing`)).toHaveCount(0); - await expect(page.locator('.maka-turn-processing')).toHaveCount(1); + const accumulatedOutput = "prefix sk-123456789012345"; + const liveBubble = page.locator(".maka-bubble-streaming"); + await expect(liveBubble).toContainText(accumulatedOutput, { + timeout: 20_000, + }); + await expect( + page.locator( + `[data-transcript-turn-id=${JSON.stringify(previousTurnId)}] .maka-turn-processing`, + ), + ).toHaveCount(0); + await expect(page.locator(".maka-turn-processing")).toHaveCount(1); - const sidebar = page.getByRole('navigation', { name: '任务列表' }); + const sidebar = page.getByRole("navigation", { name: "任务列表" }); await ensureSidebarExpanded(page); - await sidebar.getByRole('button', { name: '扩展' }).click(); + await sidebar.getByRole("button", { name: "扩展" }).click(); await expect(page.locator('[data-module="skills"]')).toBeVisible(); await expect(liveBubble).toHaveCount(0); // Return through the task row the product exposes. Module navigation can @@ -167,187 +240,183 @@ test('a successor owns working status and remounting leaves accumulated output s await expect(liveBubble).toHaveCount(1); await expect(liveBubble).toContainText(accumulatedOutput); - expect((await liveBubble.textContent())?.split(accumulatedOutput)).toHaveLength(2); + expect( + (await liveBubble.textContent())?.split(accumulatedOutput), + ).toHaveLength(2); expect( await liveBubble.evaluate( (element) => element .getAnimations({ subtree: true }) - .filter((animation) => animation.playState !== 'finished').length, + .filter((animation) => animation.playState !== "finished").length, ), ).toBe(0); await liveBubble.evaluate((element) => { const observed = { texts: [] as string[] }; - (window as typeof window & { __makaStreamingRemountObserved?: typeof observed }) - .__makaStreamingRemountObserved = observed; + ( + window as typeof window & { + __makaStreamingRemountObserved?: typeof observed; + } + ).__makaStreamingRemountObserved = observed; new MutationObserver(() => { - observed.texts.push(element.textContent ?? ''); - }).observe(element, { childList: true, characterData: true, subtree: true }); + observed.texts.push(element.textContent ?? ""); + }).observe(element, { + childList: true, + characterData: true, + subtree: true, + }); }); - const steering = 'trigger rewrite after returning to this conversation'; - await steerActiveTurn(composer, steering); - const finalText = 'prefix NEW streamed after the remount'; + const steering = "trigger rewrite after returning to this conversation"; + await submitSteeringDraft(composer, steering); + const finalText = "prefix NEW streamed after the remount"; await expect(liveBubble).toContainText(finalText); - const observed = await page.evaluate(() => ( - window as typeof window & { - __makaStreamingRemountObserved?: { - texts: string[]; - }; - } - ).__makaStreamingRemountObserved); - expect(observed?.texts.some((text) => text.includes('') && !text.includes(finalText))) - .toBe(true); + const observed = await page.evaluate( + () => + ( + window as typeof window & { + __makaStreamingRemountObserved?: { + texts: string[]; + }; + } + ).__makaStreamingRemountObserved, + ); + expect( + observed?.texts.some( + (text) => text.includes("") && !text.includes(finalText), + ), + ).toBe(true); }); -test('returning to a live conversation settles output accumulated while away', async ({ +test("returning to a live conversation settles output accumulated while away", async ({ window: page, }) => { - await page.emulateMedia({ reducedMotion: 'no-preference' }); - expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(false); + await page.emulateMedia({ reducedMotion: "no-preference" }); + expect( + await page.evaluate( + () => matchMedia("(prefers-reduced-motion: reduce)").matches, + ), + ).toBe(false); const composer = page.locator(COMPOSER_INPUT); await composer.fill(FAKE_HOLD_OPEN_PROMPT); await awaitSendReady(page); - await composer.press('Enter'); + await composer.press("Enter"); - const accumulatedOutput = 'Fake backend waiting for the test to stop the Turn.'; - const liveBubble = page.locator('.maka-bubble-streaming'); - await expect(liveBubble).toContainText(accumulatedOutput, { timeout: 20_000 }); + const accumulatedOutput = + "Fake backend waiting for the test to stop the Turn."; + const liveBubble = page.locator(".maka-bubble-streaming"); + await expect(liveBubble).toContainText(accumulatedOutput, { + timeout: 20_000, + }); - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await expect(page.locator('[data-agents-page]')).toHaveAttribute( - 'data-sidebar-state', - 'expanded', + const sidebar = page.getByRole("navigation", { name: "任务列表" }); + await page.getByRole("button", { name: "展开侧边栏" }).click(); + await expect(page.locator("[data-agents-page]")).toHaveAttribute( + "data-sidebar-state", + "expanded", ); - const originalSessionId = await sidebar.locator('[data-session-id]').first() - .getAttribute('data-session-id'); + const originalSessionId = await sidebar + .locator("[data-session-id]") + .first() + .getAttribute("data-session-id"); expect(originalSessionId).toBeTruthy(); - await composer.fill('draft before switching conversations'); - await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); - await expect(composer).toHaveText(''); - await composer.fill('temporary second conversation'); + await composer.fill("draft before switching conversations"); + await sidebar.getByRole("button", { name: "新任务", exact: true }).click(); + await expect(composer).toHaveText(""); + await composer.fill("temporary second conversation"); await awaitSendReady(page); - await composer.press('Enter'); - await expect(page.getByRole('log')).toContainText( - 'Fake backend received: temporary second conversation', + await composer.press("Enter"); + await expect(page.getByRole("log")).toContainText( + "Fake backend received: temporary second conversation", { timeout: 20_000 }, ); - await expect(page.locator('.maka-assistant-answer [data-action="copy"]')).toHaveCount(1, { + await expect( + page.locator('.maka-assistant-answer [data-action="copy"]'), + ).toHaveCount(1, { timeout: 20_000, }); - const backgroundSteering = 'background output accumulated while away'; + const backgroundSteering = "background output accumulated while away"; await page.evaluate( - ({ sessionId, steering }) => new Promise((resolve, reject) => { - const timeout = window.setTimeout(() => { - unsubscribe(); - reject(new Error('Timed out waiting for background stream output')); - }, 10_000); - const unsubscribe = window.maka.sessions.subscribeEvents(sessionId, (event) => { - if (event.type !== 'text_delta' || !event.text.includes(steering)) return; - window.clearTimeout(timeout); - unsubscribe(); - resolve(); - }); - // Runtime Host decides what this Message becomes; the test only needs it - // to reach the running Turn, so anything short of an accepted admission - // fails closed rather than waiting out the timeout. - void window.maka.sessions - .submitMessage(sessionId, 'current_turn', { - messageId: crypto.randomUUID(), - text: steering, - }) - .then((result) => { - if (result.ok) return; - window.clearTimeout(timeout); - unsubscribe(); - reject(new Error(`Runtime Host refused the steering Message: ${result.reason}`)); - }) - .catch((error) => { - window.clearTimeout(timeout); + ({ sessionId, steering }) => + new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { unsubscribe(); - reject(error); - }); - }), + reject(new Error("Timed out waiting for background stream output")); + }, 10_000); + const unsubscribe = window.maka.sessions.subscribeEvents( + sessionId, + (event) => { + if (event.type !== "text_delta" || !event.text.includes(steering)) + return; + window.clearTimeout(timeout); + unsubscribe(); + resolve(); + }, + ); + // Runtime Host decides what this Message becomes; the test only needs it + // to reach the running Turn, so anything short of an accepted admission + // fails closed rather than waiting out the timeout. + void window.maka.sessions + .submitMessage(sessionId, "current_turn", { + messageId: crypto.randomUUID(), + text: steering, + }) + .then((result) => { + if (result.ok) return; + window.clearTimeout(timeout); + unsubscribe(); + reject( + new Error( + `Runtime Host refused the steering Message: ${result.reason}`, + ), + ); + }) + .catch((error) => { + window.clearTimeout(timeout); + unsubscribe(); + reject(error); + }); + }), { sessionId: originalSessionId!, steering: backgroundSteering }, ); - await page.evaluate(() => { - // Painted frames only. A MutationObserver on `document.body` fires on every - // shell mutation during the remount, including React commit intermediates - // that never paint. Those samples made this assertion machine-load - // dependent (#3061): the same restore passed in isolation and failed when - // the rest of the file had already warmed the compositor. - const observed = { - texts: [] as string[], - maxActiveAnimations: 0, - stop() {}, - }; - let stopped = false; - const sample = () => { - if (stopped) return; - // The steer ends the waiting message; its acknowledgement streams in the last bubble. - const bubbles = [...document.querySelectorAll('.maka-bubble-streaming')]; - const bubble = bubbles.at(-1); - if (bubble) { - const text = bubble.textContent ?? ''; - if (observed.texts.at(-1) !== text) observed.texts.push(text); - observed.maxActiveAnimations = Math.max( - observed.maxActiveAnimations, - ...bubbles.map((element) => element - .getAnimations({ subtree: true }) - .filter((animation) => animation.playState !== 'finished').length), - ); - } - window.requestAnimationFrame(sample); - }; - observed.stop = () => { - stopped = true; - }; - ( - window as typeof window & { - __makaBackgroundRestoreObserved?: typeof observed; - } - ).__makaBackgroundRestoreObserved = observed; - window.requestAnimationFrame(sample); - }); + // Sample the paint clock, not React's intermediate mutation commits. + await page.evaluate(installStreamingPaintObserver); await sessionRow(sidebar, originalSessionId!).click(); - await expect(liveBubble.filter({ hasText: backgroundSteering })).toHaveCount(1); + await expect(liveBubble.filter({ hasText: backgroundSteering })).toHaveCount( + 1, + ); - expect((await page.getByRole('log').textContent())?.split(accumulatedOutput)).toHaveLength(2); + expect( + (await page.getByRole("log").textContent())?.split(accumulatedOutput), + ).toHaveLength(2); // Playwright's toContainText is a DOM check. Stop on the next animation // frame so the already-queued sample() records that settled paint first. const backgroundRestoreObserved = await page.evaluate( - () => - new Promise<{ - texts: string[]; - maxActiveAnimations: number; - } | undefined>((resolve) => { - window.requestAnimationFrame(() => { - const observed = ( - window as typeof window & { - __makaBackgroundRestoreObserved?: { - texts: string[]; - maxActiveAnimations: number; - stop(): void; - }; - } - ).__makaBackgroundRestoreObserved; - observed?.stop(); - resolve(observed); - }); - }), + stopStreamingPaintObserver, ); expect( - backgroundRestoreObserved?.texts.some((text) => text.includes(backgroundSteering)), + backgroundRestoreObserved?.texts.some((text) => + text.includes(backgroundSteering), + ), ).toBe(true); - expect(backgroundRestoreObserved?.texts.some((text) => - text.includes('background output') && !text.includes(backgroundSteering) - )).toBe(false); + expect( + backgroundRestoreObserved?.texts.some( + (text) => + text.includes("background output") && + !text.includes(backgroundSteering), + ), + ).toBe(false); expect(backgroundRestoreObserved?.maxActiveAnimations).toBe(0); - await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); - await page.evaluate((sessionId) => window.maka.sessions.stop(sessionId), originalSessionId!); - await expect(sessionRow(sidebar, originalSessionId!).getByLabel('正在响应', { exact: true })).toHaveCount(0); - + await sidebar.getByRole("button", { name: "新任务", exact: true }).click(); + await page.evaluate( + (sessionId) => window.maka.sessions.stop(sessionId), + originalSessionId!, + ); + await expect( + sessionRow(sidebar, originalSessionId!).getByLabel("正在响应", { + exact: true, + }), + ).toHaveCount(0); }); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 3563633db4e..b876ff0b60a 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -45,7 +45,6 @@ "src/renderer/desktop-slash-command.ts", "src/renderer/error-boundary.tsx", "src/renderer/follow-up-submit-routing.ts", - "src/renderer/live-content-seed.ts", "src/renderer/local-memory-digest.ts", "src/renderer/locales/agent-graph-copy.ts", "src/renderer/locales/artifact-copy.ts", @@ -209,7 +208,6 @@ "legacyFeatureImports": [ "src/renderer/features/module-hub/controller/use-daily-review-controller.ts -> src/renderer/daily-review-actions", "src/renderer/features/session-navigation/model/session-list-layout.ts -> src/renderer/browser-storage", - "src/renderer/features/session-navigation/model/session-rail-layout-store.ts -> src/renderer/browser-storage", "src/renderer/features/task-entry/ui/task-entry-host.tsx -> src/renderer/remote-project-directory-dialog", "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/browser-storage", "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/side-chat-command", @@ -487,7 +485,7 @@ "react": 1 }, "importSpecifiers": 14, - "nonTriviaTokens": 3507 + "nonTriviaTokens": 3494 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -593,7 +591,7 @@ "@maka/ui": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 2590 + "nonTriviaTokens": 2557 }, "src/renderer/app-shell-session-ui-state.ts": { "importDeclarations": 0, @@ -650,7 +648,7 @@ "nonTriviaTokens": 571 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 56, + "importDeclarations": 55, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, @@ -747,7 +745,6 @@ "./features/workbar": 1, "./features/workhub": 1, "./follow-up-submit-routing": 1, - "./live-content-seed": 1, "./locales/shell-copy": 1, "./locales/shell-remaining-copy.js": 1, "./model-connection-errors": 1, @@ -785,8 +782,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 92, - "nonTriviaTokens": 12152 + "importSpecifiers": 91, + "nonTriviaTokens": 12127 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -917,7 +914,9 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": {} + "dependencyPaths": { + "./application/contracts/agent-graph-panel-visibility.js": 1 + } }, "src/renderer/agent-graph-panel.tsx": { "bridgePaths": { @@ -1208,15 +1207,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/live-content-seed.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/renderer/local-memory-digest.ts": { "bridgePaths": {}, "environmentCapabilities": { diff --git a/apps/desktop/src/main/__tests__/agent-graph-panel-model.test.ts b/apps/desktop/src/main/__tests__/agent-graph-panel-model.test.ts new file mode 100644 index 00000000000..63a727e1031 --- /dev/null +++ b/apps/desktop/src/main/__tests__/agent-graph-panel-model.test.ts @@ -0,0 +1,85 @@ +/* + * 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 { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { + createAgentGraphPanelModel, + reduceAgentGraphPanelModel, + shouldShowAgentGraphPanel, +} from '../../renderer/features/overlays/index.js'; + +describe('AgentGraphPanelModel', () => { + it('keeps presentation state separate from backend snapshots', () => { + let state = createAgentGraphPanelModel('session-a'); + state = reduceAgentGraphPanelModel(state, { type: 'dismiss', graphId: 'graph-a' }); + state = reduceAgentGraphPanelModel(state, { + type: 'commit-snapshot', + current: true, + snapshot: { rootSessionId: 'session-a', graphId: 'graph-a', status: 'completed' }, + }); + + assert.deepEqual(state.dismissedBySession, { 'session-a': 'graph-a' }); + assert.equal(state.selectedGraphId, 'graph-a'); + assert.equal(state.collapsed, true); + }); + + it('does not let a stale backend snapshot from another session mutate the model', () => { + let state = createAgentGraphPanelModel('session-a'); + state = reduceAgentGraphPanelModel(state, { type: 'dismiss', graphId: 'graph-a' }); + state = reduceAgentGraphPanelModel(state, { type: 'enter-session', rootSessionId: 'session-b' }); + state = reduceAgentGraphPanelModel(state, { + type: 'commit-snapshot', + current: true, + snapshot: { rootSessionId: 'session-a', graphId: 'graph-a', status: 'completed' }, + }); + + assert.equal(state.selectedGraphId, undefined); + assert.equal(state.rootSessionId, 'session-b'); + assert.deepEqual(state.dismissedBySession, { 'session-a': 'graph-a' }); + }); + + it('ablation: removing graph identity hides a new terminal graph incorrectly', () => { + let state = createAgentGraphPanelModel('session-a'); + state = reduceAgentGraphPanelModel(state, { type: 'dismiss', graphId: 'graph-a' }); + const dismissedState = state; + state = reduceAgentGraphPanelModel(state, { + type: 'commit-snapshot', + current: true, + snapshot: { rootSessionId: 'session-a', graphId: 'graph-b', status: 'completed' }, + }); + + assert.equal( + shouldShowAgentGraphPanel({ + enabled: true, + hasGraphActivity: true, + sessionId: 'session-a', + graphId: 'graph-b', + status: 'completed', + dismissedBySession: state.dismissedBySession, + }), + true, + ); + + // Counterfactual: a session-only terminal flag would suppress graph-b too. + const sessionOnlyDismissal = dismissedState.dismissedBySession['session-a'] !== undefined; + const naiveShow = !(sessionOnlyDismissal && state.selectedGraphId === 'graph-b'); + assert.equal(naiveShow, false); + }); +}); diff --git a/apps/desktop/src/main/__tests__/agent-graph-panel-visibility.test.ts b/apps/desktop/src/main/__tests__/agent-graph-panel-visibility.test.ts index d41edf8ed47..ecb88daa08f 100644 --- a/apps/desktop/src/main/__tests__/agent-graph-panel-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/agent-graph-panel-visibility.test.ts @@ -20,10 +20,10 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { - dismissAgentGraphPanel, + createAgentGraphPanelModel, isAgentGraphLive, isAgentGraphPanelDismissible, - reconcileAgentGraphPanelDismissals, + reduceAgentGraphPanelModel, shouldShowAgentGraphPanel, } from '../../renderer/agent-graph-panel-visibility.js'; @@ -138,52 +138,55 @@ describe('shouldShowAgentGraphPanel', () => { describe('dismiss and reconcile', () => { it('records the dismissed graph for the session', () => { - assert.deepEqual(dismissAgentGraphPanel({}, 'session-1', 'graph-1'), { - 'session-1': 'graph-1', + const state = reduceAgentGraphPanelModel(createAgentGraphPanelModel('session-1'), { + type: 'dismiss', + graphId: 'graph-1', }); + assert.deepEqual(state.dismissedBySession, { 'session-1': 'graph-1' }); }); it('drops a stale dismissal when a later snapshot is a different graph', () => { - assert.deepEqual( - reconcileAgentGraphPanelDismissals( - { 'session-1': 'graph-1' }, - 'session-1', - { rootSessionId: 'session-1', graphId: 'graph-2', status: 'active' }, - ), - {}, - ); + let state = createAgentGraphPanelModel('session-1'); + state = reduceAgentGraphPanelModel(state, { type: 'dismiss', graphId: 'graph-1' }); + state = reduceAgentGraphPanelModel(state, { + type: 'commit-snapshot', + current: true, + snapshot: { rootSessionId: 'session-1', graphId: 'graph-2', status: 'active' }, + }); + assert.deepEqual(state.dismissedBySession, {}); }); it('drops a stale dismissal when the same graph becomes active again', () => { - assert.deepEqual( - reconcileAgentGraphPanelDismissals( - { 'session-1': 'graph-1' }, - 'session-1', - { rootSessionId: 'session-1', graphId: 'graph-1', status: 'active' }, - ), - {}, - ); + let state = createAgentGraphPanelModel('session-1'); + state = reduceAgentGraphPanelModel(state, { type: 'dismiss', graphId: 'graph-1' }); + state = reduceAgentGraphPanelModel(state, { + type: 'commit-snapshot', + current: true, + snapshot: { rootSessionId: 'session-1', graphId: 'graph-1', status: 'active' }, + }); + assert.deepEqual(state.dismissedBySession, {}); }); it('keeps a matching terminal dismissal', () => { - assert.deepEqual( - reconcileAgentGraphPanelDismissals( - { 'session-1': 'graph-1' }, - 'session-1', - { rootSessionId: 'session-1', graphId: 'graph-1', status: 'completed' }, - ), - { 'session-1': 'graph-1' }, - ); + let state = createAgentGraphPanelModel('session-1'); + state = reduceAgentGraphPanelModel(state, { type: 'dismiss', graphId: 'graph-1' }); + state = reduceAgentGraphPanelModel(state, { + type: 'commit-snapshot', + current: true, + snapshot: { rootSessionId: 'session-1', graphId: 'graph-1', status: 'completed' }, + }); + assert.deepEqual(state.dismissedBySession, { 'session-1': 'graph-1' }); }); it('does not clear a dismissal against a snapshot still owned by the previous session', () => { - assert.deepEqual( - reconcileAgentGraphPanelDismissals( - { 'session-a': 'graph-a' }, - 'session-a', - { rootSessionId: 'session-b', graphId: 'graph-b', status: 'completed' }, - ), - { 'session-a': 'graph-a' }, - ); + let state = createAgentGraphPanelModel('session-a'); + state = reduceAgentGraphPanelModel(state, { type: 'dismiss', graphId: 'graph-a' }); + state = reduceAgentGraphPanelModel(state, { type: 'enter-session', rootSessionId: 'session-b' }); + state = reduceAgentGraphPanelModel(state, { + type: 'commit-snapshot', + current: true, + snapshot: { rootSessionId: 'session-a', graphId: 'graph-a', status: 'completed' }, + }); + assert.deepEqual(state.dismissedBySession, { 'session-a': 'graph-a' }); }); }); diff --git a/apps/desktop/src/main/__tests__/app-shell-queue-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-queue-actions.test.ts new file mode 100644 index 00000000000..feee857cd7c --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-queue-actions.test.ts @@ -0,0 +1,110 @@ +/* + * 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 { createAppShellQueueActions } from "../../renderer/features/conversation/index.js"; + +test("queue actions capture the active session and retract its transient message", async () => { + const calls: unknown[] = []; + const removed: unknown[] = []; + const actions = createAppShellQueueActions({ + activeSessionId: () => "session-1", + entries: [ + { + entryId: "entry-1", + messageId: "message-1", + content: { text: "queued" }, + placement: "next_turn", + state: "queued", + }, + ], + reportFailure: () => assert.fail("successful queue actions must not report a failure"), + removeTransientMessage: (sessionId, messageId) => removed.push({ sessionId, messageId }), + retract: async (sessionId, entryId) => { + calls.push({ command: "retract", sessionId, entryId }); + }, + promote: async (sessionId, entryId) => { + calls.push({ command: "promote", sessionId, entryId }); + }, + update: async (sessionId, entryId, expectedQueueRevision, text) => { + calls.push({ command: "update", sessionId, entryId, expectedQueueRevision, text }); + }, + reorder: async (sessionId, entryIds, expectedQueueRevision) => { + calls.push({ command: "reorder", sessionId, entryIds, expectedQueueRevision }); + }, + }); + + await actions.promote("entry-1"); + await actions.update("entry-1", 4, "revised"); + await actions.reorder(["entry-2", "entry-1"], 5); + await actions.retract("entry-1"); + + assert.deepEqual(calls, [ + { command: "promote", sessionId: "session-1", entryId: "entry-1" }, + { + command: "update", + sessionId: "session-1", + entryId: "entry-1", + expectedQueueRevision: 4, + text: "revised", + }, + { + command: "reorder", + sessionId: "session-1", + entryIds: ["entry-2", "entry-1"], + expectedQueueRevision: 5, + }, + { command: "retract", sessionId: "session-1", entryId: "entry-1" }, + ]); + assert.deepEqual(removed, [{ sessionId: "session-1", messageId: "message-1" }]); +}); + +test("a failed action reports only while its captured session remains active", async () => { + let activeSessionId: string | undefined = "session-1"; + const pending = deferred(); + const failures: unknown[] = []; + const actions = createAppShellQueueActions({ + activeSessionId: () => activeSessionId, + entries: [], + reportFailure: (sessionId, error) => failures.push({ sessionId, error }), + removeTransientMessage: () => assert.fail("failed retract must preserve transient state"), + retract: () => pending.promise, + promote: async () => undefined, + update: async () => undefined, + reorder: async () => undefined, + }); + + const retracting = actions.retract("entry-1"); + activeSessionId = "session-2"; + const error = new Error("operation_conflict"); + pending.reject(error); + await assert.rejects(retracting, error); + assert.deepEqual(failures, []); +}); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((settle, fail) => { + resolve = settle; + reject = fail; + }); + return { promise, reject, resolve }; +} diff --git a/apps/desktop/src/main/__tests__/connection-catalog-fixture.test.ts b/apps/desktop/src/main/__tests__/connection-catalog-fixture.test.ts deleted file mode 100644 index 0627ceadd4b..00000000000 --- a/apps/desktop/src/main/__tests__/connection-catalog-fixture.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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 { access, mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { openInteractiveRuntimePolicyStoresForWrite } from '@maka/storage/runtime-policy-stores'; -import { - resolveStorageRoot, - tryAcquireInteractiveRootOwner, -} from '@maka/storage/root-authority'; -import { writeConnections } from '../e2e-fixture/scenarios-settings.js'; - -test('connection fixture seeds the Runtime Policy catalog, not llm-connections.json', async () => { - const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-connection-catalog-fixture-')); - const now = Date.UTC(2026, 7, 16, 12, 0, 0); - try { - await writeConnections(workspaceRoot, now, 'settings-models'); - - for (const unexpectedFile of ['llm-connections.json', 'credentials.json']) { - await assert.rejects( - () => access(join(workspaceRoot, unexpectedFile)), - (error: NodeJS.ErrnoException) => error.code === 'ENOENT', - ); - } - await access(join(workspaceRoot, 'credential-vault.json')); - - const capability = await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' }); - const owner = await tryAcquireInteractiveRootOwner(capability); - assert.ok(owner); - try { - const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); - const snapshot = await stores.connectionCatalog.getSnapshot(); - assert.deepEqual( - snapshot.connections.map((connection) => connection.slug), - ['no-models', 'zai-live'], - ); - const zai = snapshot.connections.find((connection) => connection.slug === 'zai-live'); - const noModels = snapshot.connections.find((connection) => connection.slug === 'no-models'); - assert.ok(zai); - assert.ok(noModels); - assert.deepEqual(snapshot.defaultTarget, { - connectionId: zai.connectionId, - modelId: 'glm-5.1', - }); - assert.deepEqual( - zai.models.map((item) => item.id), - [ - 'glm-4.5', - 'glm-4.5-air', - 'glm-4.6', - 'glm-4.7', - 'glm-5', - 'glm-5-turbo', - 'glm-5.1', - ], - ); - assert.equal(zai.modelSource, 'fetched'); - assert.equal(zai.modelsFetchedAt, now - 5 * 60_000); - assert.deepEqual(zai.lastTest, { - status: 'verified', - checkedAt: new Date(now - 4 * 60_000).toISOString(), - }); - assert.deepEqual(noModels.models, []); - assert.equal(noModels.modelSource, undefined); - assert.equal(noModels.lastTest, undefined); - for (const connection of [zai, noModels]) { - const credential = await stores.credentialVault.getStatus({ - scope: 'connection', - connectionId: connection.connectionId, - kind: 'api_key', - }); - assert.equal(credential.kind, 'status'); - if (credential.kind === 'status') assert.equal(credential.status.configured, true); - } - } finally { - await owner.close(); - } - } finally { - await rm(workspaceRoot, { recursive: true, force: true }); - } -}); diff --git a/apps/desktop/src/main/__tests__/daily-review-fixture.test.ts b/apps/desktop/src/main/__tests__/daily-review-fixture.test.ts index 101dd161f9e..6924437685b 100644 --- a/apps/desktop/src/main/__tests__/daily-review-fixture.test.ts +++ b/apps/desktop/src/main/__tests__/daily-review-fixture.test.ts @@ -58,3 +58,26 @@ test('Daily Review fixture seeds archives through the storage authority', async await rm(workspaceRoot, { recursive: true, force: true }); } }); + +test('Daily Review fixture releases its root owner after a rejected archive', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-daily-review-fixture-error-')); + try { + await assert.rejects(writeDailyReviewArchives(workspaceRoot, Number.NaN), /generatedAt/); + + const capability = await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner, 'a failed fixture write must not retain the root owner'); + try { + const writer = await openInteractiveDailyReviewAuthorityForWrite(owner.lease); + try { + assert.deepEqual((await writer.listArchivePage(null, 180)).archives, []); + } finally { + writer.close(); + } + } finally { + await owner.close(); + } + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/src/main/__tests__/live-content-seed.test.ts b/apps/desktop/src/main/__tests__/live-content-seed.test.ts deleted file mode 100644 index c12d5f52961..00000000000 --- a/apps/desktop/src/main/__tests__/live-content-seed.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * 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 { - EMPTY_LIVE_CONTENT_SEED, - EMPTY_SESSION_OBSERVATION_AUTHORITY, - advanceSessionObservationAuthority, - beginLiveContentSeed, - completeLiveContentSeed, - liveContentSeedRevision, -} from '../../renderer/live-content-seed.js'; - -test('catalog hydration does not replace an already-bound Session observation', () => { - const selected = advanceSessionObservationAuthority( - EMPTY_SESSION_OBSERVATION_AUTHORITY, - 'session-a', - undefined, - ); - const hydrated = advanceSessionObservationAuthority(selected, 'session-a', 'profile-a'); - - assert.equal(hydrated.profileId, 'profile-a'); - assert.equal(hydrated.revision, selected.revision); -}); - -test('a real Session observation authority handoff advances the revision', () => { - const selected = advanceSessionObservationAuthority( - EMPTY_SESSION_OBSERVATION_AUTHORITY, - 'session-a', - 'profile-a', - ); - const handedOff = advanceSessionObservationAuthority(selected, 'session-a', 'profile-b'); - - assert.equal(handedOff.profileId, 'profile-b'); - assert.equal(handedOff.revision, selected.revision + 1); -}); - -test('withholds live content until the current observation generation is ready', () => { - const first = beginLiveContentSeed(EMPTY_LIVE_CONTENT_SEED, 'session-a'); - assert.equal(liveContentSeedRevision(first, 'session-a'), 0); - - const ready = completeLiveContentSeed(first, 'session-a'); - assert.equal(liveContentSeedRevision(ready, 'session-a'), first.generation); - assert.equal(liveContentSeedRevision(ready, 'session-b'), 0); -}); - -test('A → B → A does not reuse the previous ready generation', () => { - const firstReady = completeLiveContentSeed( - beginLiveContentSeed(EMPTY_LIVE_CONTENT_SEED, 'session-a'), - 'session-a', - ); - assert.equal(liveContentSeedRevision(firstReady, 'session-a'), 1); - - const pendingB = beginLiveContentSeed(firstReady, 'session-b'); - assert.equal(liveContentSeedRevision(pendingB, 'session-b'), 0); - assert.equal(liveContentSeedRevision(pendingB, 'session-a'), 0); - - const pendingA = beginLiveContentSeed(pendingB, 'session-a'); - assert.equal(pendingA.generation, 3); - assert.equal(liveContentSeedRevision(pendingA, 'session-a'), 0); - - const recovered = completeLiveContentSeed(pendingA, 'session-a'); - assert.equal(liveContentSeedRevision(recovered, 'session-a'), 3); -}); - -test('a recovery generation only exposes live content after that generation completes', () => { - const firstReady = completeLiveContentSeed( - beginLiveContentSeed(EMPTY_LIVE_CONTENT_SEED, 'session-a'), - 'session-a', - ); - const recovering = beginLiveContentSeed(firstReady, 'session-a'); - assert.equal(liveContentSeedRevision(recovering, 'session-a'), 0); - - const ready = completeLiveContentSeed(recovering, 'session-a'); - assert.equal(liveContentSeedRevision(ready, 'session-a'), 2); -}); diff --git a/apps/desktop/src/main/__tests__/live-content-visibility.test.ts b/apps/desktop/src/main/__tests__/live-content-visibility.test.ts new file mode 100644 index 00000000000..a1ad501e708 --- /dev/null +++ b/apps/desktop/src/main/__tests__/live-content-visibility.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { + INITIAL_LIVE_CONTENT_SEED, + INITIAL_OBSERVATION_AUTHORITY, + beginLiveContentSeed, + ownsLiveContentSeed, + reconcileObservationAuthority, + revealLiveContentSeed, + visibleLiveContentGeneration, +} from '../../renderer/features/conversation/index.js'; + +test('observation generations change only when the owning source changes', () => { + const selected = reconcileObservationAuthority( + INITIAL_OBSERVATION_AUTHORITY, + { sessionId: 'session-a' }, + ); + const hydrated = reconcileObservationAuthority(selected, { + sessionId: 'session-a', + profileId: 'profile-a', + }); + const sameProfile = reconcileObservationAuthority(hydrated, { + sessionId: 'session-a', + profileId: 'profile-a', + }); + const changedProfile = reconcileObservationAuthority(sameProfile, { + sessionId: 'session-a', + profileId: 'profile-b', + }); + const changedSession = reconcileObservationAuthority(changedProfile, { + sessionId: 'session-b', + profileId: 'profile-b', + }); + + assert.deepEqual( + [selected.generation, hydrated.generation, sameProfile.generation, changedProfile.generation, changedSession.generation], + [1, 1, 1, 2, 3], + ); + assert.equal(hydrated.profileId, 'profile-a'); +}); + +test('only the current seed token can reveal live content', () => { + const first = beginLiveContentSeed(INITIAL_LIVE_CONTENT_SEED, 'session-a'); + const second = beginLiveContentSeed(first.state, 'session-b'); + const current = beginLiveContentSeed(second.state, 'session-a'); + + assert.equal(ownsLiveContentSeed(current.state, first.token), false); + assert.equal(revealLiveContentSeed(current.state, first.token), current.state); + assert.equal(visibleLiveContentGeneration(current.state, 'session-a'), 0); + + const visible = revealLiveContentSeed(current.state, current.token); + assert.equal(ownsLiveContentSeed(visible, current.token), true); + assert.equal(visibleLiveContentGeneration(visible, 'session-a'), 3); + assert.equal(visibleLiveContentGeneration(visible, 'session-b'), 0); + + const recovery = beginLiveContentSeed(visible, 'session-a'); + assert.equal(visibleLiveContentGeneration(recovery.state, 'session-a'), 0); + assert.equal( + visibleLiveContentGeneration(revealLiveContentSeed(recovery.state, recovery.token), 'session-a'), + 4, + ); +}); diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index 077b8d0fa6a..82c85592f47 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -165,6 +165,7 @@ test('queue_update stores the snapshot and retires every listed local placeholde assert.deepEqual(controller.getState().messageQueueBySession['session-1'], { ts: 1, + queueRevision: 3, entries: [steeringEntry, followupEntry], }); assert.equal(transientMessages.size, 0, @@ -206,6 +207,7 @@ test('queue_update stores the snapshot and retires every listed local placeholde assert.equal(transientMessages.size, 0); assert.deepEqual(controller.getState().messageQueueBySession['session-1'], { ts: 3, + queueRevision: 4, entries: [nextEntry], }); diff --git a/apps/desktop/src/main/__tests__/project-management-service.test.ts b/apps/desktop/src/main/__tests__/project-management-service.test.ts index 3b32cb30bf8..f0683b4ade6 100644 --- a/apps/desktop/src/main/__tests__/project-management-service.test.ts +++ b/apps/desktop/src/main/__tests__/project-management-service.test.ts @@ -18,14 +18,11 @@ */ import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); import { createProjectCatalog, type ProjectCatalog } from '@maka/storage/project-catalog'; import { createProjectManagementService, @@ -93,43 +90,47 @@ test('owns Project selection and reversible lifecycle actions in Desktop', async } }); -test('adding a nested folder selects that folder instead of the parent project', async () => { - const base = await mkdtemp(join(tmpdir(), 'maka-project-nested-add-')); - const parentPath = join(base, 'parent-project'); - const childPath = join(parentPath, 'child-project'); - await mkdir(childPath, { recursive: true }); - await execFileAsync('git', ['init', '--quiet'], { cwd: parentPath }); +test('add preserves the exact selected directory inside an existing repository', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-exact-selection-')); + const repository = join(base, 'repository'); + const nestedDirectory = join(repository, 'nested'); + await mkdir(nestedDirectory, { recursive: true }); + execFileSync('git', ['init', '--quiet'], { cwd: repository }); - const selected: string[] = []; - let nextDirectory = parentPath; + const choices = [repository, nestedDirectory]; + const selections: Array<{ id: string; path: string }> = []; let nextId = 0; const catalog = createProjectCatalog(join(base, 'storage'), { - now: () => 1_000, createId: () => `project-${++nextId}`, + now: () => 1_000, }); const service = createProjectManagementService({ capabilities: LOCAL_CAPABILITIES, catalog: managementCatalog(catalog), - chooseDirectory: async () => nextDirectory, + chooseDirectory: async () => choices.shift(), selection: { - currentSelection: async () => ({ projectId: undefined, path: parentPath }), - setSelection: (_projectId, path) => selected.push(path), + currentSelection: async () => ({ path: repository, projectId: undefined }), + setSelection: (id, path) => { + assert.ok(id); + assert.ok(path); + selections.push({ id, path }); + }, }, }); try { - const parent = await service.add(); - assert.equal(parent.ok, true); - if (!parent.ok) return; - assert.equal(parent.path, await realpath(parentPath)); + const repositoryResult = await service.add(); + const nestedResult = await service.add(); + assert.equal(repositoryResult.ok, true); + assert.equal(nestedResult.ok, true); + if (!repositoryResult.ok || !nestedResult.ok) assert.fail('Expected both Projects to be added'); - nextDirectory = childPath; - const child = await service.add(); - assert.equal(child.ok, true); - if (!child.ok) return; - assert.notEqual(child.project.id, parent.project.id); - assert.equal(child.path, await realpath(childPath)); - assert.equal(selected.at(-1), await realpath(childPath)); + assert.notEqual(repositoryResult.project.id, nestedResult.project.id); + assert.deepEqual( + selections.map(({ path }) => path), + [await realpath(repository), await realpath(nestedDirectory)], + ); + assert.equal(nestedResult.path, await realpath(nestedDirectory)); } finally { catalog.close(); await rm(base, { recursive: true, force: true }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 821d8b080e3..46fae309f46 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -445,12 +445,15 @@ test('treats empty configuration patches as read-only lookups', async () => { ]); }); -test('binds message controls to the current Host Epoch', async () => { +test('binds every message command to the current Host Epoch', async () => { const { client, requests } = clientWithResponses([ { disposition: 'steering', queueRevision: 2 }, { queueRevision: 3 }, + { queueRevision: 4 }, + { queueRevision: 5 }, + { queueRevision: 6 }, { - queueRevision: 4, + queueRevision: 7, retracted: [], turn: { sessionId: 'session-1', @@ -474,6 +477,24 @@ test('binds message controls to the current Host Epoch', async () => { entryId: 'entry-1', retractId: 'retract-1', }); + await client.promoteQueueEntry({ + sessionId: 'session-1', + entryId: 'entry-2', + promoteId: 'promote-1', + }); + await client.updateQueueEntry({ + sessionId: 'session-1', + entryId: 'entry-1', + updateId: 'update-1', + expectedQueueRevision: 3, + text: 'Updated steer', + }); + await client.reorderQueueEntries({ + sessionId: 'session-1', + reorderId: 'reorder-1', + expectedQueueRevision: 5, + entryIds: ['entry-2', 'entry-1'], + }); await client.interruptTurn({ sessionId: 'session-1', interruptId: 'interrupt-1', @@ -501,6 +522,36 @@ test('binds message controls to the current Host Epoch', async () => { originHostEpoch: 'host-current', }, }, + { + operation: 'queue.entry.promote', + input: { + sessionId: 'session-1', + entryId: 'entry-2', + promoteId: 'promote-1', + originHostEpoch: 'host-current', + }, + }, + { + operation: 'queue.entry.update', + input: { + sessionId: 'session-1', + entryId: 'entry-1', + updateId: 'update-1', + expectedQueueRevision: 3, + text: 'Updated steer', + originHostEpoch: 'host-current', + }, + }, + { + operation: 'queue.entries.reorder', + input: { + sessionId: 'session-1', + reorderId: 'reorder-1', + expectedQueueRevision: 5, + entryIds: ['entry-2', 'entry-1'], + originHostEpoch: 'host-current', + }, + }, { operation: 'turn.interrupt', input: { diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index ff45ee48420..023b28bd81a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -869,10 +869,8 @@ test('does not release or report a Revision the Host retained during cleanup', a test('resyncs Goal, exact interaction, and sidecar state after candidate replacement', async () => { const ref = 'maka://runtime/background-tasks/shell-1'; const observations = new RuntimeHostSessionObservationRegistry(); - const resyncs: Array<{ channel: string; payload: unknown }> = []; - const firstIpc = ipcHarness((channel, payload) => { - resyncs.push({ channel, payload }); - }); + const timeline = createRendererTimeline(); + const firstIpc = ipcHarness(timeline.record); const firstHost = connectionHarness('first-observer', { subscriptionSnapshot: continuitySnapshot({ interactions: { pending: [pendingQuestion()] }, @@ -903,14 +901,14 @@ test('resyncs Goal, exact interaction, and sidecar state after candidate replace 'before replacement', ); await firstCandidate.close(); - resyncs.length = 0; + timeline.clear(); const secondIpc = ipcHarness(); const sessionChanges: Array<{ reason: string; sessionId?: string }> = []; let terminalReattach: Promise | undefined; const secondHost = connectionHarness('second-observer', { subscriptionSnapshot: continuitySnapshot({ interactions: { pending: [] } }), - activeAssistantStreams: [activeText('message-1')], + assistantStreams: [textStream('message-1')], runtimeResourcePty: ptySnapshot(ref, 'after replacement'), }); const secondCandidate = await createDesktopRuntimeHostCandidate( @@ -921,7 +919,7 @@ test('resyncs Goal, exact interaction, and sidecar state after candidate replace sessionChanges.push({ reason, sessionId }), renderer: { send(channel, _host, payload) { - resyncs.push({ channel, payload }); + timeline.record(channel, payload); if (channel === 'shell-runs:resync') { terminalReattach ??= secondIpc.invoke('shell-runs:attach', { sessionId: 'session-1', @@ -934,23 +932,17 @@ test('resyncs Goal, exact interaction, and sidecar state after candidate replace observations, ); - const seedPendingAt = resyncs.findIndex( - ({ channel, payload }) => - channel === 'sessions:event:session-1' - && (payload as { type?: unknown }).type === 'host_observation_pending', - ); - const seedReadyAt = resyncs.findIndex( - ({ channel, payload }) => - channel === 'sessions:event:session-1' - && (payload as { type?: unknown }).type === 'host_observation_seed', - ); - assert.ok(seedPendingAt >= 0); - assert.ok(seedReadyAt > seedPendingAt); - const seed = resyncs[seedReadyAt]!.payload as { execution: { available: boolean }; events: unknown[] }; + const seedPendingAt = timeline.sessionEventIndex('session-1', 'host_observation_pending'); + const seedReadyAt = timeline.sessionEventIndex('session-1', 'host_observation_seed'); + assert.equal(seedPendingAt >= 0 && seedReadyAt > seedPendingAt, true); + const seed = timeline.records[seedReadyAt]!.payload as { + execution: { available: boolean }; + events: unknown[]; + }; assert.equal(seed.execution.available, true); assert.ok(seed.events.length > 0); assert.ok( - resyncs.some( + timeline.records.some( ({ channel, payload }) => channel === 'graphs:resync' && (payload as { rootSessionId?: unknown }).rootSessionId === 'session-1', @@ -963,14 +955,14 @@ test('resyncs Goal, exact interaction, and sidecar state after candidate replace ), ); assert.ok( - resyncs.some( + timeline.records.some( ({ channel, payload }) => channel === 'shell-runs:resync' && (payload as { sessionId?: unknown }).sessionId === 'session-1', ), ); assert.ok( - resyncs.some( + timeline.records.some( ({ channel, payload }) => channel === 'sessions:active-interactions-changed' && (payload as { sessionId?: unknown }).sessionId === 'session-1' && @@ -995,7 +987,7 @@ test('resyncs Goal, exact interaction, and sidecar state after candidate replace data: ' live', }); await waitFor(() => - resyncs.some( + timeline.records.some( ({ channel, payload }) => channel === 'shell-runs:pty-data' && (payload as { sequence?: unknown }).sequence === 5 && @@ -1007,97 +999,45 @@ test('resyncs Goal, exact interaction, and sidecar state after candidate replace await observations.close(); }); -test('retries candidate startup when a restored observation cannot seed', async () => { - const observations = new RuntimeHostSessionObservationRegistry(); - const seedEvents: Array<{ channel: string; payload: unknown }> = []; - const firstIpc = ipcHarness((channel, payload) => { - seedEvents.push({ channel, payload }); - }); - const firstHost = connectionHarness('restore-source', { - sessionId: 'session-1', - subscriptionSnapshot: continuitySnapshot(), - }); - const firstCandidate = await createDesktopRuntimeHostCandidate( - firstHost.connection, - deps(firstIpc), - observations, - ); - await firstIpc.invoke('sessions:observe', 'session-1', 'observer-1'); - await firstCandidate.close(); - seedEvents.length = 0; - - const failingHost = connectionHarness('restore-failure', { - sessionId: 'session-1', - subscriptionError: new Error('restore failed'), - }); - await assert.rejects( - () => - createDesktopRuntimeHostCandidate( - failingHost.connection, - { - ...deps(ipcHarness()), - renderer: { - send(channel, _host, payload) { - seedEvents.push({ channel, payload }); - }, - }, - }, - observations, - ), - /Failed to restore Session observations: session-1/, - ); - // Restore startup and subscription failure may both invalidate observation. - // Neither is evidence that the Host Turn ended or observation became ready. - const failureEvents = seedEvents - .filter(({ channel }) => channel === 'sessions:event:session-1') - .map(({ payload }) => payload as { type?: unknown; message?: unknown }); - assert.ok(failureEvents.some((event) => - event.type === 'host_observation_error' && event.message === 'restore failed')); - assert.ok(failureEvents.some((event) => event.type === 'host_observation_pending')); - assert.ok(failureEvents.every((event) => - event.type === 'host_observation_error' || event.type === 'host_observation_pending')); - seedEvents.length = 0; - - const recoveredHost = connectionHarness('restore-recovered', { - sessionId: 'session-1', - subscriptionSnapshot: continuitySnapshot(), - activeAssistantStreams: [activeText('message-1')], - }); - const recoveredCandidate = await createDesktopRuntimeHostCandidate( - recoveredHost.connection, - { - ...deps(ipcHarness()), - renderer: { - send(channel, _host, payload) { - seedEvents.push({ channel, payload }); - }, - }, - }, - observations, - ); - const pendingAt = seedEvents.findIndex( - ({ channel, payload }) => - channel === 'sessions:event:session-1' - && (payload as { type?: unknown }).type === 'host_observation_pending', - ); - const readyAt = seedEvents.findIndex( - ({ channel, payload }) => - channel === 'sessions:event:session-1' - && (payload as { type?: unknown }).type === 'host_observation_seed', - ); - assert.ok(pendingAt >= 0); - assert.ok(readyAt > pendingAt); - await recoveredCandidate.close(); - await observations.close(); -}); - +test('keeps a restored observation retryable until replacement seeding succeeds', async (t) => { + const registry = new RuntimeHostSessionObservationRegistry(); + t.after(() => registry.close()); + const timeline = createRendererTimeline(); + const start = ( + connection: RuntimeHostConnection, + candidateDeps: DesktopRuntimeHostCandidateDeps, + ) => createDesktopRuntimeHostCandidate(connection, candidateDeps, registry); + const sourceIpc = ipcHarness(timeline.record); + const sourceHost = connectionHarness('restore-source', restorableObservation()); + const sourceCandidate = await start(sourceHost.connection, deps(sourceIpc)); + await sourceIpc.invoke('sessions:observe', 'session-1', 'observer-1'); + await sourceCandidate.close(); + timeline.clear(); + const rejectedHost = connectionHarness('restore-failure', restorableObservation({ + subscribeFailure: new Error('restore failed'), + })); + const rejectedDeps = { ...deps(ipcHarness()), renderer: timeline.renderer }; + const rejectedCandidate = start(rejectedHost.connection, rejectedDeps); + await assert.rejects(rejectedCandidate, /restore Session observations: session-1/); + const failureEvents = timeline.sessionEvents('session-1'); + const failureTypes = [...new Set(failureEvents.map((event) => event.type))].sort().join(','); + assert.equal(failureTypes, 'host_observation_error,host_observation_pending'); + assert.equal(failureEvents.find((event) => event.type === 'host_observation_error')?.message, 'restore failed'); + timeline.clear(); + const replacementHost = connectionHarness('restore-recovered', restorableObservation({ + assistantStreams: [textStream('message-1')], + })); + const replacementDeps = { ...deps(ipcHarness()), renderer: timeline.renderer }; + const replacementCandidate = await start(replacementHost.connection, replacementDeps); + const pendingAt = timeline.sessionEventIndex('session-1', 'host_observation_pending'); + const readyAt = timeline.sessionEventIndex('session-1', 'host_observation_seed'); + assert.equal(pendingAt >= 0 && readyAt > pendingAt, true); + await replacementCandidate.close(); +}); // A failed replacement leaves the registry available to the next candidate. test('drops a stale shared Session observation when Guest access is gone', async () => { const observations = new RuntimeHostSessionObservationRegistry(); const firstIpc = ipcHarness(); - const firstHost = connectionHarness('shared-before-revoke', { - sessionId: 'session-1', - subscriptionSnapshot: continuitySnapshot(), - }); + const firstHost = connectionHarness('shared-before-revoke', restorableObservation()); const firstCandidate = await createCandidate( firstHost.connection, { ...deps(firstIpc), onGuestSessionCatalogChanged: () => undefined }, @@ -1137,10 +1077,7 @@ test('drops a stale shared Session observation when Guest access is gone', async test('forgets an observed Session the Host no longer serves instead of blocking every reconnect', async () => { const observations = new RuntimeHostSessionObservationRegistry(); const firstIpc = ipcHarness(); - const firstHost = connectionHarness('missing-session-source', { - sessionId: 'session-1', - subscriptionSnapshot: continuitySnapshot(), - }); + const firstHost = connectionHarness('missing-session-source', restorableObservation()); const firstCandidate = await createDesktopRuntimeHostCandidate( firstHost.connection, deps(firstIpc), @@ -1154,7 +1091,7 @@ test('forgets an observed Session the Host no longer serves instead of blocking const changes: Array<{ reason: string; sessionId?: string }> = []; const missingHost = connectionHarness('missing-session-host', { sessionId: 'session-1', - subscriptionError: new RuntimeHostOperationError( + subscribeFailure: new RuntimeHostOperationError( 'subscription.open', 'not_found', 'Runtime Host Session was not found', @@ -1173,7 +1110,7 @@ test('forgets an observed Session the Host no longer serves instead of blocking // The stale active registration is forgotten instead of failing the // candidate start, and the renderer is told to drop the Session view. - assert.deepEqual(observations.observedSessionIds(), []); + assert.deepEqual(observations.observationSessionIds(), []); assert.ok( changes.some( ({ reason, sessionId }) => reason === 'deleted' && sessionId === 'session-1', @@ -1192,14 +1129,14 @@ test('forgets an observed Session the Host no longer serves instead of blocking observations, ); await thirdIpc.invoke('sessions:observe', 'session-2', 'observer-2'); - assert.deepEqual(observations.observedSessionIds(), ['session-2']); + assert.deepEqual(observations.observationSessionIds(), ['session-2']); await thirdCandidate.close(); await observations.close(); }); type IpcHandler = Parameters['handle']>[1]; -function ipcHarness(onSend?: (channel: string, payload: unknown) => void) { +function ipcHarness(recordSend?: (channel: string, payload: unknown) => void) { const handlers = new Map(); let epoch = TEST_TARGET_EPOCH; const sender = Object.assign(new EventEmitter(), { @@ -1207,13 +1144,14 @@ function ipcHarness(onSend?: (channel: string, payload: unknown) => void) { sent: [] as Array<{ channel: string; hostId?: string; payload: unknown }>, send(channel: string, ...args: unknown[]): void { const hostId = (args[0] as { hostId?: unknown } | undefined)?.hostId; - const payload = args.at(-1); - sender.sent.push({ + const message = args.at(-1); + const entry = { channel, ...(typeof hostId === 'string' ? { hostId } : {}), - payload, - }); - onSend?.(channel, payload); + payload: message, + }; + sender.sent.push(entry); + recordSend?.(entry.channel, entry.payload); }, }); return { @@ -1321,8 +1259,8 @@ function connectionHarness( sessionId?: string; revisionAbandon?: 'abandoned' | 'retained'; subscriptionSnapshot?: SessionContinuitySnapshot; - activeAssistantStreams?: readonly SessionAssistantStreamIdentity[]; - subscriptionError?: Error; + assistantStreams?: readonly SessionAssistantStreamIdentity[]; + subscribeFailure?: Error; runtimeResourcePty?: ReturnType; runtimeResourceUpdate?: ShellRunUpdate; sharedSessionAvailable?: boolean; @@ -1464,7 +1402,7 @@ function connectionHarness( throw new Error(`Unexpected operation: ${operation}`); }, openSessionSubscription: async ({ sessionId }: { sessionId: string }) => { - if (options.subscriptionError) throw options.subscriptionError; + if (options.subscribeFailure) throw options.subscribeFailure; const subscriptionFrames = new AsyncFrameQueue(); activeSubscriptionFrames = subscriptionFrames; // The Host holds a subscription's frames until the subscriber calls @@ -1501,7 +1439,7 @@ function connectionHarness( projectionRevision: 1, session: { sessionId }, }, - activeAssistantStreams: options.activeAssistantStreams ?? [], + activeAssistantStreams: options.assistantStreams ?? [], transcriptBootstrap: { throughSequence: null, durable: emptyPage, @@ -1610,10 +1548,57 @@ function continuitySnapshot( }; } -function activeText(messageId: string): SessionAssistantStreamIdentity { - return { kind: 'text', turnId: 'turn-1', messageId }; +const textStream = (messageId: string): SessionAssistantStreamIdentity => ({ + kind: 'text', + turnId: 'turn-1', + messageId, +}); + +function restorableObservation( + overrides: { + assistantStreams?: readonly SessionAssistantStreamIdentity[]; + subscribeFailure?: Error; + } = {}, +) { + return { + sessionId: 'session-1', + subscriptionSnapshot: continuitySnapshot(), + ...overrides, + }; } +type RendererRecord = { channel: string; payload: unknown }; + +const createRendererTimeline = () => { + const records: RendererRecord[] = []; + const record = (channel: string, payload: unknown): void => { + records.push({ channel, payload }); + }; + return { + records, + record, + renderer: { + send(channel: string, _host: unknown, payload: unknown): void { + record(channel, payload); + }, + }, + clear(): void { + records.length = 0; + }, + sessionEvents(sessionId: string): Array<{ type?: unknown; message?: unknown }> { + return records + .filter(({ channel }) => channel === `sessions:event:${sessionId}`) + .map(({ payload }) => payload as { type?: unknown; message?: unknown }); + }, + sessionEventIndex(sessionId: string, type: string): number { + return records.findIndex( + ({ channel, payload }) => + channel === `sessions:event:${sessionId}` + && (payload as { type?: unknown }).type === type, + ); + }, + }; +}; function pendingQuestion() { return { schemaVersion: 1 as const, diff --git a/apps/desktop/src/main/__tests__/runtime-host-queue-mutation-ipc.test.ts b/apps/desktop/src/main/__tests__/runtime-host-queue-mutation-ipc.test.ts new file mode 100644 index 00000000000..34fbad5ef8c --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-queue-mutation-ipc.test.ts @@ -0,0 +1,137 @@ +/* + * 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 type { IpcMain } from "electron"; +import type { DesktopRuntimeHostClient } from "../runtime-host-client.js"; +import { registerRuntimeHostQueueMutationIpc } from "../runtime-host-queue-mutation-ipc.js"; + +type QueueMutationClient = Pick< + DesktopRuntimeHostClient, + | "promoteQueueEntry" + | "reorderQueueEntries" + | "retractQueueEntry" + | "updateQueueEntry" +>; +type Handler = Parameters["handle"]>[1]; + +test("routes queue mutations with fresh operation identities", async () => { + const calls: unknown[] = []; + const ipc = ipcHarness(); + let sequence = 0; + registerRuntimeHostQueueMutationIpc( + ipc, + queueClient((operation, input) => calls.push({ operation, ...input })), + () => `operation-${++sequence}`, + ); + + await ipc.invoke("sessions:retractQueueEntry", "session-1", "entry-1"); + await ipc.invoke("sessions:promoteQueueEntry", "session-1", "entry-2"); + await ipc.invoke("sessions:updateQueueEntry", "session-1", "entry-2", 4, " revised "); + await ipc.invoke("sessions:reorderQueueEntries", "session-1", ["entry-3", "entry-2"], 5); + + assert.deepEqual(calls, [ + { + operation: "retract", + sessionId: "session-1", + entryId: "entry-1", + retractId: "operation-1", + }, + { + operation: "promote", + sessionId: "session-1", + entryId: "entry-2", + promoteId: "operation-2", + }, + { + operation: "update", + sessionId: "session-1", + entryId: "entry-2", + updateId: "operation-3", + expectedQueueRevision: 4, + text: "revised", + }, + { + operation: "reorder", + sessionId: "session-1", + reorderId: "operation-4", + expectedQueueRevision: 5, + entryIds: ["entry-3", "entry-2"], + }, + ]); +}); + +test("rejects malformed queue mutation arguments before dispatch", async () => { + const calls: unknown[] = []; + const ipc = ipcHarness(); + registerRuntimeHostQueueMutationIpc( + ipc, + queueClient((operation, input) => calls.push({ operation, ...input })), + () => "operation-1", + ); + + const invalidCalls: ReadonlyArray = [ + ["sessions:updateQueueEntry", ["session-1", "entry-1", 4, " "], /Queued message text/], + ["sessions:promoteQueueEntry", ["session-1", 42], /queue entry identity/], + ["sessions:reorderQueueEntries", ["session-1", ["entry-1", 42], 1], /queue entry order/], + ["sessions:reorderQueueEntries", ["session-1", ["entry-1"], -1], /Queue sequence/], + ]; + for (const [channel, args, error] of invalidCalls) { + await assert.rejects(ipc.invoke(channel, ...args), error); + } + assert.deepEqual(calls, []); +}); + +function queueClient( + record: (operation: string, input: Record) => void, +): QueueMutationClient { + return { + async retractQueueEntry(input) { + record("retract", input); + return { queueRevision: 1 }; + }, + async promoteQueueEntry(input) { + record("promote", input); + return { queueRevision: 1 }; + }, + async updateQueueEntry(input) { + record("update", input); + return { queueRevision: 1 }; + }, + async reorderQueueEntries(input) { + record("reorder", input); + return { queueRevision: 1 }; + }, + }; +} + +function ipcHarness() { + const handlers = new Map(); + return { + handle(channel: string, handler: Handler): void { + handlers.set(channel, handler); + }, + async invoke(channel: string, ...args: unknown[]): Promise { + const handler = handlers.get(channel); + assert.ok(handler, `Missing IPC handler for ${channel}`); + return handler({} as Electron.IpcMainInvokeEvent, ...args); + }, + }; +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index bd5ad8eeae3..190d446c0c2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -81,14 +81,14 @@ for (const phase of ['connecting', 'seeding'] as const) { const observing = ipc.invoke('sessions:observe', 'session-1', 'observer-1'); void observing.catch(() => undefined); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(observations.observedSessionIds(), ['session-1']); + assert.deepEqual(observations.observationSessionIds(), ['session-1']); if (cancellation === 'unobserve') await observations.unobserve('observer-1'); else ipc.rendererDestroyed(); finishSeed(); assert.deepEqual(await observing, { kind: 'cancelled' }); - assert.deepEqual(observations.observedSessionIds(), []); + assert.deepEqual(observations.observationSessionIds(), []); assert.deepEqual(await observations.attach(source), []); assert.equal(seeds, phase === 'seeding' ? 1 : 0); assert.deepEqual(errors, []); @@ -1879,72 +1879,6 @@ test('keeps an unknown Desktop follow-up admission available for reconciliation' ); }); -test("routes per-entry queue mutations to the Runtime Host", async () => { - const calls: unknown[] = []; - let sequence = 0; - const ipc = ipcHarness(); - registerExecutionIpc( - { - client: executionClient({ - retractQueueEntry: async (input) => { - calls.push({ operation: "retract", ...input }); - return { queueRevision: 3 }; - }, - promoteQueueEntry: async (input) => { - calls.push({ operation: "promote", ...input }); - return { queueRevision: 4 }; - }, - reorderQueueEntries: async (input) => { - calls.push({ operation: "reorder", ...input }); - return { queueRevision: 5 }; - }, - }), - observer: unusedObserver(), - attachmentApprovals: createAttachmentApprovalRegistry(), - emitSessionsChanged() {}, - stat: async () => ({ size: 0 }), - resizeImage: async (bytes) => bytes, - beforeStop() {}, - newId: () => `id-${++sequence}`, - }, - ipc, - ); - - assert.equal(await ipc.invoke("sessions:retractQueueEntry", "session-1", "entry-1"), undefined); - await ipc.invoke("sessions:promoteQueueEntry", "session-1", "entry-2"); - await ipc.invoke("sessions:reorderQueueEntries", "session-1", ["entry-3", "entry-2"]); - - assert.deepEqual(calls, [ - { - operation: "retract", - sessionId: "session-1", - entryId: "entry-1", - retractId: "id-1", - }, - { - operation: "promote", - sessionId: "session-1", - entryId: "entry-2", - promoteId: "id-2", - }, - { - operation: "reorder", - sessionId: "session-1", - reorderId: "id-3", - entryIds: ["entry-3", "entry-2"], - }, - ]); - - await assert.rejects( - () => ipc.invoke("sessions:promoteQueueEntry", "session-1", 42), - /Invalid queue entry identity/, - ); - await assert.rejects( - () => ipc.invoke("sessions:reorderQueueEntries", "session-1", ["entry-1", 42]), - /Invalid queue entry order/, - ); -}); - test("binds steer and stop to Host-owned queue and active Turn identities", async () => { const submits: unknown[] = []; const interrupts: unknown[] = []; @@ -2295,6 +2229,7 @@ function executionClient(overrides: Partial): ExecutionClient { openSession: unavailable, retractQueueEntry: unavailable, promoteQueueEntry: unavailable, + updateQueueEntry: unavailable, reorderQueueEntries: unavailable, setSessionReadMarker: unavailable, startTurnResume: unavailable, @@ -2516,9 +2451,9 @@ test('renderer reload releases old observations without accumulating destroyed l assert.equal(ipc.rendererListenerCount(), 2); ipc.rendererNavigate(true); // Same-document navigation keeps live subscriptions. ipc.rendererNavigate(false, false); // So do child-frame navigations. - assert.deepEqual(registry.observedSessionIds(), ['session-1']); + assert.deepEqual(registry.observationSessionIds(), ['session-1']); ipc.rendererNavigate(); - assert.deepEqual(registry.observedSessionIds(), []); + assert.deepEqual(registry.observationSessionIds(), []); assert.equal(ipc.rendererListenerCount(), 1); } assert.equal(removed.length, 20); @@ -2535,7 +2470,7 @@ test('an observation admitted across document replacement is cancelled before re ipc.rendererNavigate(); resolving.resolve(); assert.deepEqual(await observing, { kind: 'cancelled' }); - assert.deepEqual(registry.observedSessionIds(), []); + assert.deepEqual(registry.observationSessionIds(), []); await registry.close(); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index e82485498f3..c3e1be9c428 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -1911,7 +1911,7 @@ test("ignores a stale seed failure after its replacement succeeds", async () => assert.deepEqual(await attaching, ["session-1"]); await observing; assert.equal(ready, true); - assert.deepEqual(observations.observedSessionIds(), ["session-1"]); + assert.deepEqual(observations.observationSessionIds(), ["session-1"]); await observations.close(); }); @@ -2554,7 +2554,7 @@ test("reconciles terminal, Goal, interaction, and sidecar state after subscripti const finishedTurns: Array<[string, "completed" | "abandoned"]> = []; const interactionSnapshots: Array = []; const recoveredSessions: string[] = []; - const seedTimeline: string[] = []; + const observationPhases: string[] = []; const sessionChanges: string[] = []; const firstInteraction = pendingQuestion("interaction-1", "turn-1", "run-1"); const secondInteraction = pendingQuestion("interaction-2", "turn-2", "run-2"); @@ -2623,12 +2623,7 @@ test("reconciles terminal, Goal, interaction, and sidecar state after subscripti }, now: () => 50, }); - const target = eventTarget(15); - const originalSend = target.send.bind(target); - target.send = (channel, event) => { - seedTimeline.push(`event:${event.type}`); - originalSend(channel, event); - }; + const target = eventTarget(15, (event) => observationPhases.push(event.type)); await observer.observe("session-1", "observer-1", target); await observer.watchTurn("session-1", "turn-1"); @@ -2643,10 +2638,12 @@ test("reconciles terminal, Goal, interaction, and sidecar state after subscripti assert.deepEqual(finishedTurns, [["session-1", "completed"]]); assert.deepEqual(recoveredSessions, ["session-1"]); - const pendingAt = seedTimeline.indexOf('event:host_observation_pending'); - const readyAt = seedTimeline.lastIndexOf('event:host_observation_seed'); - assert.ok(pendingAt >= 0); - assert.ok(readyAt > pendingAt); + const pendingAt = observationPhases.indexOf('host_observation_pending'); + const readyAt = observationPhases.lastIndexOf('host_observation_seed'); + assert.deepEqual( + { pendingObserved: pendingAt >= 0, readyObservedLater: readyAt > pendingAt }, + { pendingObserved: true, readyObservedLater: true }, + ); assert.ok(sessionChanges.includes("goal-change")); assert.deepEqual( interactionSnapshots.at(-1)?.map((interaction) => interaction.requestId), @@ -3747,6 +3744,7 @@ function pendingQuestion(interactionId: string, turnId: string, runId: string) { function eventTarget( id: number, + onSend?: (event: SessionEvent | SessionObservationMessage) => void, ): RuntimeHostSessionObserverTarget & { events: SessionEvent[]; observations: SessionObservationMessage[] } { const events: SessionEvent[] = []; const observations: SessionObservationMessage[] = []; @@ -3755,6 +3753,7 @@ function eventTarget( events, observations, send(_channel, event) { + onSend?.(event); if (event.type === 'host_observation_seed') { observations.push(event); events.push(...event.events); diff --git a/apps/desktop/src/main/__tests__/runtime-policy-connection-fixture.test.ts b/apps/desktop/src/main/__tests__/runtime-policy-connection-fixture.test.ts new file mode 100644 index 00000000000..ef88500bd86 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-policy-connection-fixture.test.ts @@ -0,0 +1,137 @@ +/* + * 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 { mkdtemp, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; +import { openInteractiveRuntimePolicyStoresForRead } from '@maka/storage/runtime-policy-stores'; +import { + resolveStorageRoot, + tryAcquireInteractiveRootReader, +} from '@maka/storage/root-authority'; +import { writeConnections } from '../e2e-fixture/scenarios-settings.js'; + +const NOW = Date.UTC(2026, 7, 16, 12, 0, 0); + +test('settings fixture projects a complete current-format connection profile', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-policy-fixture-')); + try { + await writeConnections(root, NOW, 'settings-connections'); + assert.deepEqual(await projectFixture(root), { + files: { + catalog: true, + credentialVault: true, + legacyConnections: false, + legacyCredentials: false, + }, + defaultTarget: { slug: 'zai-live', modelId: 'glm-5.1' }, + connections: [ + { + slug: 'no-models', + modelIds: [], + modelSource: null, + modelsFetchedAt: null, + lastTest: null, + hasApiKey: true, + }, + { + slug: 'zai-live', + modelIds: [ + 'glm-4.5', + 'glm-4.5-air', + 'glm-4.6', + 'glm-4.7', + 'glm-5', + 'glm-5-turbo', + 'glm-5.1', + ], + modelSource: 'fetched', + modelsFetchedAt: NOW - 5 * 60_000, + lastTest: { + status: 'verified', + checkedAt: new Date(NOW - 4 * 60_000).toISOString(), + }, + hasApiKey: true, + }, + ], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function projectFixture(root: string) { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const reader = await tryAcquireInteractiveRootReader(capability); + assert.ok(reader, 'fixture must release its writer before verification'); + try { + const stores = await openInteractiveRuntimePolicyStoresForRead(reader.lease); + const snapshot = await stores.connectionCatalog.getSnapshot(); + return { + files: { + catalog: await exists(root, 'connection-catalog.json'), + credentialVault: await exists(root, 'credential-vault.json'), + legacyConnections: await exists(root, 'llm-connections.json'), + legacyCredentials: await exists(root, 'credentials.json'), + }, + defaultTarget: projectDefaultTarget(snapshot), + connections: await Promise.all( + snapshot.connections.map(async (connection) => { + const credential = await stores.credentialVault.getStatus({ + scope: 'connection', + connectionId: connection.connectionId, + kind: 'api_key', + }); + return { + slug: connection.slug, + modelIds: connection.models.map(({ id }) => id), + modelSource: connection.modelSource ?? null, + modelsFetchedAt: connection.modelsFetchedAt ?? null, + lastTest: connection.lastTest ?? null, + hasApiKey: credential.kind === 'status' && credential.status.configured, + }; + }), + ), + }; + } finally { + await reader.close(); + } +} + +function projectDefaultTarget(snapshot: ConnectionCatalogSnapshot) { + if (!snapshot.defaultTarget) return null; + const connection = snapshot.connections.find( + ({ connectionId }) => connectionId === snapshot.defaultTarget?.connectionId, + ); + assert.ok(connection, 'default target must reference a catalog entry'); + return { slug: connection.slug, modelId: snapshot.defaultTarget.modelId }; +} + +async function exists(root: string, name: string): Promise { + try { + await stat(join(root, name)); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} diff --git a/apps/desktop/src/main/__tests__/session-list-layout.test.ts b/apps/desktop/src/main/__tests__/session-list-layout.test.ts index 8726f5bb77b..27584020a39 100644 --- a/apps/desktop/src/main/__tests__/session-list-layout.test.ts +++ b/apps/desktop/src/main/__tests__/session-list-layout.test.ts @@ -18,123 +18,128 @@ */ import assert from 'node:assert/strict'; -import { afterEach, describe, it, mock } from 'node:test'; -import { - createSessionRailLayoutStore, - readSessionListViewMode, - writeSessionListViewMode, -} from '../../renderer/features/session-navigation/testing.js'; +import { describe, it, mock } from 'node:test'; +import { createSessionRailLayoutStore } from '../../renderer/features/session-navigation/testing.js'; const VIEW_MODE_KEY = 'maka-chat-list-view-mode-v1'; const WIDTH_KEY = 'maka-chat-list-width-v1'; -function installMemoryLocalStorage(initial: Record = {}) { - const store = new Map(Object.entries(initial)); - const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); - const memory: Storage = { - get length() { - return store.size; - }, - clear() { - store.clear(); - }, - getItem(key) { - return store.has(key) ? store.get(key)! : null; - }, - key(index) { - return [...store.keys()][index] ?? null; - }, - removeItem(key) { - store.delete(key); - }, - setItem(key, value) { - store.set(key, String(value)); - }, - }; - Object.defineProperty(globalThis, 'localStorage', { - configurable: true, - writable: true, - value: memory, - }); - return { - store, - restore() { - if (previous) Object.defineProperty(globalThis, 'localStorage', previous); - else Reflect.deleteProperty(globalThis, 'localStorage'); - }, - }; -} +class TestStorage implements Storage { + readonly #values: Map; -describe('session list view mode persistence', () => { - const cleanups: Array<() => void> = []; - afterEach(() => { - while (cleanups.length > 0) cleanups.pop()?.(); - }); + constructor(seed: Record) { + this.#values = new Map(Object.entries(seed)); + } - it('defaults to conversation when nothing is stored', () => { - cleanups.push(installMemoryLocalStorage().restore); - assert.equal(readSessionListViewMode(), 'conversation'); - }); + get length(): number { + return this.#values.size; + } - it('round-trips a project grouping through the same key the shell hydrates', () => { - const memory = installMemoryLocalStorage(); - cleanups.push(memory.restore); - writeSessionListViewMode('project'); - assert.equal(memory.store.get(VIEW_MODE_KEY), 'project'); - assert.equal(readSessionListViewMode(), 'project'); - }); + clear(): void { + this.#values.clear(); + } - it('keeps conversation when that is what was written', () => { - cleanups.push(installMemoryLocalStorage({ [VIEW_MODE_KEY]: 'project' }).restore); - writeSessionListViewMode('conversation'); - assert.equal(readSessionListViewMode(), 'conversation'); - }); + getItem(key: string): string | null { + return this.#values.get(key) ?? null; + } - it('fails open to conversation for garbage or empty stored values', () => { - for (const stored of ['', 'time', 'true', 'PROJECT', 'conversation\n']) { - const memory = installMemoryLocalStorage({ [VIEW_MODE_KEY]: stored }); - assert.equal(readSessionListViewMode(), 'conversation', stored); - memory.restore(); - } - }); -}); + key(index: number): string | null { + return [...this.#values.keys()][index] ?? null; + } -describe('session rail width persistence', () => { - const cleanups: Array<() => void> = []; - afterEach(() => { + removeItem(key: string): void { + this.#values.delete(key); + } + + setItem(key: string, value: string): void { + this.#values.set(key, value); + } +} + +function withLocalStorage(seed: Record, run: (storage: TestStorage) => T): T { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + const storage = new TestStorage(seed); + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage }); + try { + return run(storage); + } finally { + if (descriptor) Object.defineProperty(globalThis, 'localStorage', descriptor); + else Reflect.deleteProperty(globalThis, 'localStorage'); + } +} + +function withMockedTimeouts(run: () => void): void { + mock.timers.enable({ apis: ['setTimeout'] }); + try { + run(); + } finally { mock.timers.reset(); - while (cleanups.length > 0) cleanups.pop()?.(); + } +} + +describe('session grouping persistence', () => { + it('hydrates only the two supported grouping values', () => { + const cases = [ + { expected: 'conversation', stored: undefined }, + { expected: 'conversation', stored: 'conversation' }, + { expected: 'project', stored: 'project' }, + { expected: 'conversation', stored: '' }, + { expected: 'conversation', stored: 'time' }, + { expected: 'conversation', stored: 'PROJECT' }, + { expected: 'conversation', stored: 'conversation\n' }, + ] as const; + for (const { expected, stored } of cases) { + const seed: Record = {}; + if (stored !== undefined) seed[VIEW_MODE_KEY] = stored; + withLocalStorage(seed, () => { + assert.equal( + createSessionRailLayoutStore().getState().viewMode, + expected, + `stored=${JSON.stringify(stored)}`, + ); + }); + } }); - it('persists a width the user dragged to', () => { - const memory = installMemoryLocalStorage(); - cleanups.push(memory.restore); - mock.timers.enable({ apis: ['setTimeout'] }); - const store = createSessionRailLayoutStore(); + it('persists each change and a new store hydrates the latest grouping', () => { + withLocalStorage({}, (storage) => { + const current = createSessionRailLayoutStore(); + current.setViewMode('project'); + assert.equal(storage.getItem(VIEW_MODE_KEY), 'project'); + assert.equal(createSessionRailLayoutStore().getState().viewMode, 'project'); - store.setWidth(400); - mock.timers.tick(200); + current.setViewMode('conversation'); + assert.equal(storage.getItem(VIEW_MODE_KEY), 'conversation'); + assert.equal(createSessionRailLayoutStore().getState().viewMode, 'conversation'); + }); + }); +}); - assert.equal(store.getState().width, 400); - assert.equal(memory.store.get(WIDTH_KEY), '400'); +describe('session rail width persistence', () => { + it('writes a debounced user width', () => { + withLocalStorage({}, (storage) => { + withMockedTimeouts(() => { + const rail = createSessionRailLayoutStore(); + rail.setWidth(400); + mock.timers.tick(200); + assert.equal(rail.getState().width, 400); + assert.equal(storage.getItem(WIDTH_KEY), '400'); + }); + }); }); - // Astryx reports a collapse as `onSizeChange(0)`. Clamping that to the minimum - // and storing it loses the width the user chose: collapse, reload, expand, and - // the rail comes back at 180 instead of 400. - it("ignores the collapse sentinel instead of storing it as the user's width", () => { - const memory = installMemoryLocalStorage(); - cleanups.push(memory.restore); - mock.timers.enable({ apis: ['setTimeout'] }); - const store = createSessionRailLayoutStore(); - store.setWidth(400); - mock.timers.tick(200); - - store.setCollapsed(true); - store.setWidth(0); - mock.timers.tick(200); - - assert.equal(store.getState().width, 400); - assert.equal(memory.store.get(WIDTH_KEY), '400'); + it('does not replace the expanded width with the collapse sentinel', () => { + withLocalStorage({}, (storage) => { + withMockedTimeouts(() => { + const rail = createSessionRailLayoutStore(); + rail.setWidth(400); + mock.timers.tick(200); + rail.setCollapsed(true); + rail.setWidth(0); + mock.timers.tick(200); + assert.equal(rail.getState().width, 400); + assert.equal(storage.getItem(WIDTH_KEY), '400'); + }); + }); }); }); diff --git a/apps/desktop/src/main/__tests__/session-terminal-query-policy.test.ts b/apps/desktop/src/main/__tests__/session-terminal-query-policy.test.ts new file mode 100644 index 00000000000..f83b399e38f --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-terminal-query-policy.test.ts @@ -0,0 +1,148 @@ +/* + * 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 type { IDisposable, IParser } from "@xterm/xterm"; +import { + isColorQuery, + isDeviceAttributesQuery, + isDeviceStatusQuery, + isWindowReportQuery, + isXtVersionQuery, + suppressTerminalQueryReplies, +} from "../../renderer/features/workbar/testing.js"; + +test("classifies terminal capability queries without swallowing setters", () => { + for (const [params, expected] of [ + [[10, "?"], true], [[11, "?"], true], [[12, "?"], true], + [[10, "?;?;?"], true], [[10, "?;#fff"], true], + [[4, "0;?"], true], [[4, "0;?;15;?"], true], [[4, "0;?;1;#fff"], true], + [[10, "rgb:0f0f/0f0f/1212"], false], [[11, "#ffffff"], false], + [[4, "0;rgb:0000/0000/0000"], false], [[4, "256;?"], false], + [[4, ""], false], [[3, "?"], false], + ] as const) { + assert.equal(isColorQuery(params[0], params[1]), expected); + } + + assert.deepEqual( + [0, 0].map((value, index, values) => isDeviceAttributesQuery(values.slice(0, index + 1))), + [true, true], + ); + assert.equal(isDeviceAttributesQuery([1, 0]), false); + assert.equal(isDeviceStatusQuery([5]), true); + assert.equal(isDeviceStatusQuery([5, 0]), true); + assert.equal(isDeviceStatusQuery([6]), false); + assert.equal(isDeviceStatusQuery([4, 0]), false); + assert.equal(isXtVersionQuery([0]), true); + assert.equal(isXtVersionQuery([0, 1]), true); + assert.equal(isXtVersionQuery([1]), false); +}); + +test("recognizes only the window reports xterm should answer", () => { + for (const operation of [11, 13, 14, 15, 16, 18, 19, 20, 21]) { + assert.equal(isWindowReportQuery([operation]), true); + } + for (const params of [[14, 2], [8, 24, 80], [22, 0], [23, 0], [[14]]]) { + assert.equal(isWindowReportQuery(params as never), false); + } +}); + +interface ParserHarness { + readonly registered: Array<{ kind: string; id: unknown; callback: unknown }>; + readonly writes: string[]; + readonly disposed: { value: number }; + readonly terminal: { parser: IParser; write(data: string): void }; +} + +function parserHarness(): ParserHarness { + const registered: ParserHarness["registered"] = []; + const writes: string[] = []; + const disposed = { value: 0 }; + const disposable = (): IDisposable => ({ dispose: () => { disposed.value += 1; } }); + const parser = { + registerOscHandler(id: number, callback: unknown) { + registered.push({ kind: "osc", id, callback }); + return disposable(); + }, + registerCsiHandler(id: unknown, callback: unknown) { + registered.push({ kind: "csi", id, callback }); + return disposable(); + }, + registerDcsHandler(id: unknown, callback: unknown) { + registered.push({ kind: "dcs", id, callback }); + return disposable(); + }, + } as unknown as IParser; + return { registered, writes, disposed, terminal: { parser, write: (data) => writes.push(data) } }; +} + +function oscHandler(harness: ParserHarness, id: number): (data: string) => boolean { + const callback = harness.registered.find((handler) => handler.kind === "osc" && handler.id === id)?.callback; + assert.equal(typeof callback, "function"); + return callback as (data: string) => boolean; +} + +test("replays setters while suppressing mixed OSC color queries", () => { + const harness = parserHarness(); + const registration = suppressTerminalQueryReplies(harness.terminal); + assert.equal(oscHandler(harness, 4)("0;?;1;#fff"), true); + assert.deepEqual(harness.writes, ["\x1b]4;1;#fff\x1b\\"]); + harness.writes.length = 0; + assert.equal(oscHandler(harness, 10)("?;#fff"), true); + assert.deepEqual(harness.writes, ["\x1b]11;#fff\x1b\\"]); + harness.writes.length = 0; + assert.equal(oscHandler(harness, 10)("#000;?;#fff"), true); + assert.deepEqual(harness.writes, ["\x1b]10;#000\x1b\\", "\x1b]12;#fff\x1b\\"]); + harness.writes.length = 0; + assert.equal(oscHandler(harness, 4)("0;#000;1;#fff"), false); + assert.deepEqual(harness.writes, []); + registration.dispose(); +}); + +test("registers the response handlers and leaves cursor reports to xterm", () => { + const harness = parserHarness(); + const registration = suppressTerminalQueryReplies(harness.terminal); + assert.equal(harness.registered.length, 12); + assert.deepEqual( + harness.registered.filter(({ kind }) => kind === "osc").map(({ id }) => id), + [4, 10, 11, 12], + ); + assert.equal( + harness.registered.some(({ kind, id }) => kind === "csi" && JSON.stringify(id) === JSON.stringify({ final: "n" })), + true, + ); + const handlers = new Map boolean>(); + const parser = { + registerOscHandler: () => ({ dispose() {} }), + registerCsiHandler: (id: unknown, callback: (params: (number | number[])[]) => boolean) => { + handlers.set(JSON.stringify(id), callback); + return { dispose() {} }; + }, + registerDcsHandler: () => ({ dispose() {} }), + } as unknown as IParser; + const cursorRegistration = suppressTerminalQueryReplies({ parser, write() {} }); + assert.equal(handlers.get(JSON.stringify({ final: "n" }))?.([5]), true); + assert.equal(handlers.get(JSON.stringify({ final: "n" }))?.([6]), false); + assert.equal(handlers.get(JSON.stringify({ prefix: ">", final: "q" }))?.([0]), true); + assert.equal(handlers.get(JSON.stringify({ prefix: ">", final: "q" }))?.([1]), false); + cursorRegistration.dispose(); + registration.dispose(); + assert.equal(harness.disposed.value, harness.registered.length); +}); diff --git a/apps/desktop/src/main/__tests__/session-terminal-query.test.ts b/apps/desktop/src/main/__tests__/session-terminal-query.test.ts deleted file mode 100644 index fdd580e4592..00000000000 --- a/apps/desktop/src/main/__tests__/session-terminal-query.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/* - * 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 type { IDisposable, IParser } from '@xterm/xterm'; -import { - isColorQuery, - isDeviceAttributesQuery, - isDeviceStatusQuery, - isWindowReportQuery, - isXtVersionQuery, - suppressTerminalQueryReplies, -} from '../../renderer/features/workbar/testing.js'; - -test('recognizes pure and mixed OSC color queries', () => { - assert.equal(isColorQuery(10, '?'), true); - assert.equal(isColorQuery(11, '?'), true); - assert.equal(isColorQuery(12, '?'), true); - assert.equal(isColorQuery(10, '?;?;?'), true); - assert.equal(isColorQuery(10, '?;#fff'), true); - assert.equal(isColorQuery(4, '0;?'), true); - assert.equal(isColorQuery(4, '0;?;15;?'), true); - assert.equal(isColorQuery(4, '0;?;1;#fff'), true); -}); - -test('does not suppress OSC color setters or invalid query forms', () => { - assert.equal(isColorQuery(10, 'rgb:0f0f/0f0f/1212'), false); - assert.equal(isColorQuery(11, '#ffffff'), false); - assert.equal(isColorQuery(4, '0;rgb:0000/0000/0000'), false); - assert.equal(isColorQuery(4, '256;?'), false); - assert.equal(isColorQuery(4, ''), false); - assert.equal(isColorQuery(3, '?'), false); -}); - -test('recognizes device attribute and status reports by their first parameter', () => { - assert.equal(isDeviceAttributesQuery([0]), true); - assert.equal(isDeviceAttributesQuery([0, 0]), true); - assert.equal(isDeviceAttributesQuery([0, 1]), true); - assert.equal(isDeviceAttributesQuery([1, 0]), false); - - assert.equal(isDeviceStatusQuery([5]), true); - assert.equal(isDeviceStatusQuery([5, 0]), true); - assert.equal(isDeviceStatusQuery([6]), false); - assert.equal(isDeviceStatusQuery([6, 1]), false); - assert.equal(isDeviceStatusQuery([4, 0]), false); - - assert.equal(isXtVersionQuery([0]), true); - assert.equal(isXtVersionQuery([0, 1]), true); - assert.equal(isXtVersionQuery([1]), false); -}); - -test('recognizes window reports without intercepting window commands', () => { - for (const operation of [11, 13, 14, 15, 16, 18, 19, 20, 21]) { - assert.equal(isWindowReportQuery([operation]), true); - } - assert.equal(isWindowReportQuery([14, 0]), true); - assert.equal(isWindowReportQuery([14, 2]), false); - assert.equal(isWindowReportQuery([16, 0]), true); - assert.equal(isWindowReportQuery([8, 24, 80]), false); - assert.equal(isWindowReportQuery([22, 0]), false); - assert.equal(isWindowReportQuery([23, 0]), false); - assert.equal(isWindowReportQuery([[14]]), false); -}); - -test('mixed OSC color queries are suppressed and their setters are replayed', () => { - const registered: Array<{ kind: string; id: unknown; callback: unknown }> = []; - const writes: string[] = []; - const disposable = (): IDisposable => ({ dispose() {} }); - const parser = { - registerOscHandler: (id: number, callback: unknown) => { - registered.push({ kind: 'osc', id, callback }); - return disposable(); - }, - registerCsiHandler: () => disposable(), - registerDcsHandler: () => disposable(), - } as unknown as IParser; - const terminal = { parser, write: (data: string) => writes.push(data) }; - const queryReplies = suppressTerminalQueryReplies(terminal); - const oscHandler = (id: number): ((data: string) => boolean) => { - const callback = registered.find( - (handler) => handler.kind === 'osc' && handler.id === id, - )?.callback; - assert.equal(typeof callback, 'function'); - return callback as (data: string) => boolean; - }; - - assert.equal(oscHandler(4)('0;?;1;#fff'), true); - assert.deepEqual(writes, ['\x1b]4;1;#fff\x1b\\']); - - writes.length = 0; - assert.equal(oscHandler(10)('?;#fff'), true); - assert.deepEqual(writes, ['\x1b]11;#fff\x1b\\']); - - writes.length = 0; - assert.equal(oscHandler(10)('#000;?;#fff'), true); - assert.deepEqual(writes, ['\x1b]10;#000\x1b\\', '\x1b]12;#fff\x1b\\']); - - writes.length = 0; - assert.equal(oscHandler(4)('0;#000;1;#fff'), false); - assert.deepEqual(writes, []); - queryReplies.dispose(); -}); - -test('registers and disposes every xterm response-generating query handler', () => { - const registered: Array<{ kind: string; id: unknown; callback: unknown }> = []; - let disposed = 0; - const disposable = (): IDisposable => ({ - dispose: () => { - disposed += 1; - }, - }); - const parser = { - registerOscHandler: (id: number, callback: unknown) => { - registered.push({ kind: 'osc', id, callback }); - return disposable(); - }, - registerCsiHandler: (id: unknown, callback: unknown) => { - registered.push({ kind: 'csi', id, callback }); - return disposable(); - }, - registerDcsHandler: (id: unknown, callback: unknown) => { - registered.push({ kind: 'dcs', id, callback }); - return disposable(); - }, - } as unknown as IParser; - const terminal = { parser, write: (_data: string) => {} }; - - const queryReplies = suppressTerminalQueryReplies(terminal); - - assert.equal(registered.length, 12); - assert.deepEqual( - registered.filter(({ kind }) => kind === 'osc').map(({ id }) => id), - [4, 10, 11, 12], - ); - assert.equal( - registered.some( - ({ kind, id }) => - kind === 'csi' && JSON.stringify(id) === JSON.stringify({ prefix: '>', final: 'q' }), - ), - true, - ); - assert.equal( - registered.some( - ({ kind, id }) => - kind === 'dcs' && JSON.stringify(id) === JSON.stringify({ intermediates: '$', final: 'q' }), - ), - true, - ); - queryReplies.dispose(); - assert.equal(disposed, registered.length); -}); diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 8e3a81ad5f9..3feb7a6f85c 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -529,7 +529,7 @@ describe('single live-turn handoff', () => { seq: 0, stream: 'stdout', chunk: 'late', redacted: false, createdAt: 1, ts: 1, }); - handlers.dropDisplayEvents('session-1'); + handlers.discardDisplayEvents('session-1'); liveTurns.set(() => ({})); liveTurnBySessionRef.current = liveTurns.get(); @@ -537,68 +537,56 @@ describe('single live-turn handoff', () => { assert.equal(liveTurns.get()['session-1'], undefined); }); - it('applies catch-up deltas immediately until the returning session is seeded', () => { - const liveTurns = createStateSetter>({ - 'session-1': [armLiveTurn('turn-1')], - }); - const liveTurnBySessionRef = { current: liveTurns.get() }; - const interactions = createStateSetter({}); - const frames: Array<() => void> = []; - let publications = 0; - const handlers = createAppShellSessionEventHandlers({ + it('publishes recovery text before returning the session to frame scheduling', () => { + const sessionId = 'recovery-session'; + const state = createStateSetter>({ + [sessionId]: [armLiveTurn('turn-1')], + } satisfies Record); + const stateRef = { current: state.get() }; + const frameQueue: Array<() => void> = []; + const publicationCounts: number[] = []; + const eventHandlers = createAppShellSessionEventHandlers({ uiLocale: 'zh-CN', - activeIdRef: { current: 'session-1' }, - liveTurnBySessionRef, - refreshMessages: async () => true, - refreshSessions: async () => [], - setLiveTurnBySession: (updater) => { - publications += 1; - liveTurns.set(updater); - liveTurnBySessionRef.current = liveTurns.get(); + activeIdRef: { current: sessionId }, + liveTurnBySessionRef: stateRef, + async refreshMessages() { return true; }, + async refreshSessions() { return []; }, + setLiveTurnBySession(update) { + state.set(update); + stateRef.current = state.get(); + publicationCounts.push(publicationCounts.length + 1); }, - setInteractionBySession: interactions.set, - showModelSetupToast: () => {}, - toastApi: { error: () => {} }, - scheduleFrame: (callback) => { frames.push(callback); }, - }); - - handlers.markDisplayPending('session-1'); - handlers.handleEvent('session-1', { - type: 'text_delta', - id: 'seed', - turnId: 'turn-1', - messageId: 'assistant-1', - ts: 1, - startOffset: 0, - text: 'prefix accumulated while away', - }); - assert.equal(publications, 1); - assert.equal(frames.length, 0); - assert.equal( - liveTurns.get()['session-1']?.[0]?.steps[0]?.text?.text, - 'prefix accumulated while away', - ); - - handlers.flushDisplayEvents('session-1'); - handlers.markDisplayReady('session-1'); - handlers.handleEvent('session-1', { - type: 'text_delta', - id: 'live', - turnId: 'turn-1', - messageId: 'assistant-1', - ts: 2, - text: ' new', - }); - assert.equal(publications, 1); - assert.equal(frames.length, 1); - frames.shift()?.(); - assert.equal(publications, 2); - assert.equal( - liveTurns.get()['session-1']?.[0]?.steps[0]?.text?.text, - 'prefix accumulated while away new', + setInteractionBySession: createStateSetter({}).set, + showModelSetupToast() {}, + toastApi: { error() {} }, + scheduleFrame: (callback) => void frameQueue.push(callback), + } satisfies Parameters[0]); + const renderedText = () => state.get()[sessionId]?.[0]?.steps[0]?.text?.text; + const emit = (event: SessionEvent) => eventHandlers.handleEvent(sessionId, event); + eventHandlers.holdDisplayEvents(sessionId); + emit({ + type: 'text_delta', id: 'recovered', turnId: 'turn-1', messageId: 'assistant-1', + ts: 1, startOffset: 0, text: 'restored prefix', + } satisfies SessionEvent); + const recoverySnapshot = { + publications: publicationCounts.length, + frames: frameQueue.length, + text: renderedText(), + }; + assert.deepEqual(recoverySnapshot, { publications: 1, frames: 0, text: 'restored prefix' }); + eventHandlers.releaseDisplayEvents(sessionId); + emit({ + type: 'text_delta', id: 'continued', turnId: 'turn-1', messageId: 'assistant-1', + ts: 2, text: ' plus live text', + } satisfies SessionEvent); + assert.deepEqual( + { publications: publicationCounts.length, frames: frameQueue.length }, + { publications: 1, frames: 1 }, 'live continuation waits for a frame', ); - }); - + frameQueue.shift()?.(); + const renderedSnapshot = { publications: publicationCounts.length, text: renderedText() }; + assert.deepEqual(renderedSnapshot, { publications: 2, text: 'restored prefix plus live text' }); + }); // Recovery delivery is synchronous only while the hold is active. it('shares pending display events across handler replacement', () => { const liveTurns = createStateSetter>({ 'session-1': [armLiveTurn('turn-1')], diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index 0a670e29d2b..9bfec6cc4ba 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -275,7 +275,7 @@ describe('createDesktopWorkbarServices', () => { await services.sideChat.queryMessageExecutions('fork', ['message-next']); await services.sideChat.retractQueueEntry('fork', 'entry-1'); await services.sideChat.promoteQueueEntry('fork', 'entry-2'); - await services.sideChat.reorderQueueEntries('fork', ['entry-3', 'entry-2']); + await services.sideChat.reorderQueueEntries('fork', ['entry-3', 'entry-2'], 5); await services.sideChat.setPermissionMode('fork', 'ask'); await services.sideChat.respondToSandboxBoundary('fork', {} as never); await services.sideChat.respondToClientCapability('fork', {} as never); @@ -376,5 +376,9 @@ describe('createDesktopWorkbarServices', () => { calls.find((call) => call.name === 'sessions.queryMessageExecutions')?.args, ['fork', ['message-next']], ); + assert.deepEqual( + calls.find((call) => call.name === 'sessions.reorderQueueEntries')?.args, + ['fork', ['entry-3', 'entry-2'], 5], + ); }); }); diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts index ac5d80b03f9..51249e7bcd1 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -117,6 +117,7 @@ async function mountController(failFirstRead = false, overrides: Partial) => { queueMutations.push(['retract', ...input]); }, + updateQueueEntry: async (...input: Parameters) => { queueMutations.push(['update', ...input]); }, promoteQueueEntry: async (...input: Parameters) => { queueMutations.push(['promote', ...input]); }, reorderQueueEntries: async (...input: Parameters) => { queueMutations.push(['reorder', ...input]); }, enqueueMessage: async (...input: Parameters) => { steers.push(input); onSteer?.(input); return steerResult; }, @@ -683,16 +684,16 @@ test('WorkHub sends queue edits, withdrawal and both queue orders to the Host an await act(() => h.emit({ type: 'queue_update', id: 'queued', turnId: 'active-turn', ts: 2, queueRevision: 7, steering: ['first', 'second'], followup: [], steeringEntries: entries })); await act(async () => { - await h.controller.editQueuedEntry?.(entries[1]!); - await h.controller.reorderQueuedEntries(['second', 'first']); + await h.controller.updateQueuedEntry('second', 7, 'edited second'); + await h.controller.reorderQueuedEntries(['second', 'first'], 7); await h.controller.deleteQueuedEntry('first'); }); assert.deepEqual(h.queueMutations, [ - ['retract', h.controller.sessionId, 'second'], - ['reorder', h.controller.sessionId, ['second', 'first']], + ['update', h.controller.sessionId, 'second', 7, 'edited second'], + ['reorder', h.controller.sessionId, ['second', 'first'], 7], ['retract', h.controller.sessionId, 'first'], ]); - assert.deepEqual(h.restoredDrafts, [[h.controller.sessionId, 'second']], 'edit hands the retracted text back to the draft'); + assert.deepEqual(h.restoredDrafts, [], 'Host-side edits stay in the queue projection'); assert.deepEqual(h.controller.messageQueue.entries.map((entry) => entry.entryId), ['first', 'second']); await act(() => h.emit({ type: 'queue_update', id: 'updated', turnId: 'active-turn', ts: 3, queueRevision: 10, steering: ['edited second'], followup: [], steeringEntries: [{ ...entries[1]!, content: { text: 'edited second' } }] })); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index a242c86692a..bf99d7975c0 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -61,7 +61,7 @@ import { import { usageStatsRecords, usageStatsSessions } from './e2e-fixture/scenarios-usage.js'; const E2E_FIXTURE_SCENARIOS = new Set([ - 'settings-models', + 'settings-connections', 'turn-narrative', 'turn-narrative-browser', 'chat-prompt-rail', @@ -163,7 +163,7 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState ...(fixture.timezone ? { timezone: fixture.timezone } : {}), }; switch (fixture.scenario) { - case 'settings-models': + case 'settings-connections': return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'models' }; case 'turn-narrative': // Any open face will do — the scenario is about focus order through the diff --git a/apps/desktop/src/main/e2e-fixture/connection-catalog-seed.ts b/apps/desktop/src/main/e2e-fixture/connection-catalog-seed.ts new file mode 100644 index 00000000000..a5c230516e9 --- /dev/null +++ b/apps/desktop/src/main/e2e-fixture/connection-catalog-seed.ts @@ -0,0 +1,214 @@ +/* + * 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 type { + ConnectionCatalogEntry, + ConnectionCatalogEntryDraft, + ConnectionCatalogSnapshot, + ConnectionModel, +} from '@maka/core/runtime-policy'; +import type { E2eFixtureScenario } from '@maka/core/e2e-fixture'; +import { + openInteractiveRuntimePolicyStoresForWrite, + type RuntimePolicyStoresWriter, +} from '@maka/storage/runtime-policy-stores'; +import { + resolveStorageRoot, + tryAcquireInteractiveRootOwner, +} from '@maka/storage/root-authority'; + +interface ConnectionSeed { + readonly draft: ConnectionCatalogEntryDraft; + readonly apiKey?: string; + readonly modelInventory?: { + readonly models: readonly ConnectionModel[]; + readonly fetchedAt: number; + }; + readonly verifiedAt?: string; + readonly verifiedModelId?: string; +} + +interface CatalogSeedPlan { + readonly connections: readonly ConnectionSeed[]; + readonly defaultTarget: { readonly slug: string; readonly modelId: string }; +} + +const ZAI_MODELS: readonly ConnectionModel[] = [ + fixtureModel('glm-4.5', { functionCalling: true }, 128_000), + fixtureModel('glm-4.5-air', { functionCalling: true }, 128_000), + fixtureModel('glm-4.6', { reasoning: true, functionCalling: true }, 200_000), + fixtureModel('glm-4.7', { reasoning: true, functionCalling: true }, 200_000), + fixtureModel('glm-5', { reasoning: true, functionCalling: true }, 200_000), + fixtureModel('glm-5-turbo', { reasoning: true, functionCalling: true }, 200_000), + fixtureModel('glm-5.1', { vision: true, reasoning: true, functionCalling: true }, 1_000_000), +]; + +export async function seedSettingsConnectionCatalog( + workspaceRoot: string, + now: number, + scenario: E2eFixtureScenario, +): Promise { + const showEmptyConnectionFirst = scenario === 'settings-connections'; + const zai = zaiSeed(now); + const empty = emptySeed(showEmptyConnectionFirst); + const connections = showEmptyConnectionFirst ? [empty, zai] : [zai, empty]; + await seedCatalog(workspaceRoot, { + connections, + defaultTarget: { slug: 'zai-live', modelId: 'glm-5.1' }, + }); +} + +function zaiSeed(now: number): ConnectionSeed { + return { + draft: { + slug: 'zai-live', + name: 'Z.ai Live Fixture', + providerType: 'zai-coding-plan', + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + enabled: true, + enabledModelIds: ZAI_MODELS.map(({ id }) => id), + }, + apiKey: 'fixture-key-zai-live', + modelInventory: { models: ZAI_MODELS, fetchedAt: now - 5 * 60_000 }, + verifiedAt: new Date(now - 4 * 60_000).toISOString(), + verifiedModelId: 'glm-5.1', + }; +} + +function emptySeed(withCredential: boolean): ConnectionSeed { + return { + draft: { + slug: 'no-models', + name: 'No Models Fixture', + providerType: 'custom', + baseUrl: 'https://empty.example.test/v1', + defaultApiProtocol: 'openai-chat', + enabled: true, + enabledModelIds: [], + }, + ...(withCredential ? { apiKey: 'fixture-key-no-models' } : {}), + }; +} + +async function seedCatalog(workspaceRoot: string, plan: CatalogSeedPlan): Promise { + const capability = await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + if (!owner) throw new Error('Connection fixture could not acquire its storage root'); + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + let snapshot = await stores.connectionCatalog.getSnapshot(); + for (const seed of plan.connections) snapshot = await addConnection(stores, snapshot, seed); + const defaultConnection = connectionBySlug(snapshot, plan.defaultTarget.slug); + const result = await stores.connectionCatalog.setDefaultTarget({ + expectedCatalogRevision: snapshot.revision, + target: { + connectionId: defaultConnection.connectionId, + modelId: plan.defaultTarget.modelId, + }, + }); + requireCommitted(result, `set default ${plan.defaultTarget.slug}`); + } finally { + await owner.close(); + } +} + +async function addConnection( + stores: RuntimePolicyStoresWriter, + snapshot: ConnectionCatalogSnapshot, + seed: ConnectionSeed, +): Promise { + const created = await stores.connectionCatalog.create({ + expectedCatalogRevision: snapshot.revision, + connection: seed.draft, + }); + snapshot = requireCommitted(created, `create ${seed.draft.slug}`); + const connection = connectionBySlug(snapshot, seed.draft.slug); + + if (seed.apiKey) await setCredential(stores, connection, seed.apiKey); + if (seed.modelInventory) { + const prepared = await stores.operations.beginModelFetch(connection.connectionId); + if (prepared.kind !== 'ready') fail(seed.draft.slug, 'begin model fetch', prepared.kind); + const completed = await stores.operations.completeModelFetch(prepared.ticket, { + ...seed.modelInventory, + source: 'fetched', + }); + snapshot = requireCommitted(completed, `complete model fetch for ${seed.draft.slug}`); + } + if (seed.verifiedAt) { + const prepared = await stores.operations.beginConnectionTest( + connection.connectionId, + seed.verifiedModelId ?? null, + ); + if (prepared.kind !== 'ready') fail(seed.draft.slug, 'begin connection test', prepared.kind); + const completed = await stores.operations.completeConnectionTest(prepared.ticket, { + status: 'verified', + checkedAt: seed.verifiedAt, + }); + snapshot = requireCommitted(completed, `complete connection test for ${seed.draft.slug}`); + } + return snapshot; +} + +async function setCredential( + stores: RuntimePolicyStoresWriter, + connection: ConnectionCatalogEntry, + secret: string, +): Promise { + const result = await stores.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'api_key', + }, + expected: null, + secret, + }); + if (result.kind !== 'committed') fail(connection.slug, 'set credential', result.kind); +} + +function requireCommitted( + result: T, + operation: string, +): ConnectionCatalogSnapshot { + if (result.kind !== 'committed' || !result.snapshot) { + throw new Error(`Connection fixture could not ${operation}: ${result.kind}`); + } + return result.snapshot; +} + +function connectionBySlug( + snapshot: ConnectionCatalogSnapshot, + slug: string, +): ConnectionCatalogEntry { + const connection = snapshot.connections.find((candidate) => candidate.slug === slug); + if (!connection) throw new Error(`Connection fixture lost committed entry ${slug}`); + return connection; +} + +function fail(slug: string, operation: string, outcome: string): never { + throw new Error(`Connection fixture could not ${operation} for ${slug}: ${outcome}`); +} + +function fixtureModel( + id: string, + capabilities: NonNullable, + contextWindow: number, +): ConnectionModel { + return { id, capabilities, contextWindow }; +} diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts b/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts index 97433d344ca..ac7dd527fed 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts @@ -20,14 +20,8 @@ import { join } from 'node:path'; import type { DailyReviewArchive } from '@maka/core/daily-review'; import type { E2eFixtureScenario } from '@maka/core/e2e-fixture'; -import type { - ConnectionCatalogEntryDraft, - ConnectionCatalogSnapshot, - ConnectionModel, -} from '@maka/core/runtime-policy'; import { createDefaultSettings } from '@maka/core/settings'; import { openInteractiveDailyReviewAuthorityForWrite } from '@maka/storage/daily-review-authority'; -import { openInteractiveRuntimePolicyStoresForWrite } from '@maka/storage/runtime-policy-stores'; import { openInteractiveScheduledTaskStoreForWrite } from '@maka/storage/scheduled-task-store'; import { resolveStorageRoot, @@ -35,6 +29,8 @@ import { } from '@maka/storage/root-authority'; import { writeJson } from './seed-helpers.js'; +export { seedSettingsConnectionCatalog as writeConnections } from './connection-catalog-seed.js'; + export async function writeSettings( workspaceRoot: string, scenario?: E2eFixtureScenario, @@ -66,132 +62,6 @@ export async function writeSettings( await writeJson(join(workspaceRoot, 'settings.json'), settings); } -const ZAI_FIXTURE_MODELS: readonly ConnectionModel[] = [ - model('glm-4.5', { functionCalling: true }, 128_000), - model('glm-4.5-air', { functionCalling: true }, 128_000), - model('glm-4.6', { reasoning: true, functionCalling: true }, 200_000), - model('glm-4.7', { reasoning: true, functionCalling: true }, 200_000), - model('glm-5', { reasoning: true, functionCalling: true }, 200_000), - model('glm-5-turbo', { reasoning: true, functionCalling: true }, 200_000), - model('glm-5.1', { vision: true, reasoning: true, functionCalling: true }, 1_000_000), -]; - -export async function writeConnections( - workspaceRoot: string, - now: number, - scenario: E2eFixtureScenario, -): Promise { - const zaiLive: ConnectionCatalogEntryDraft = { - slug: 'zai-live', - name: 'Z.ai Live Fixture', - providerType: 'zai-coding-plan', - baseUrl: 'https://api.z.ai/api/coding/paas/v4', - enabled: true, - enabledModelIds: ZAI_FIXTURE_MODELS.map((item) => item.id), - }; - const noModels: ConnectionCatalogEntryDraft = { - slug: 'no-models', - name: 'No Models Fixture', - providerType: 'custom', - baseUrl: 'https://empty.example.test/v1', - defaultApiProtocol: 'openai-chat', - enabled: true, - enabledModelIds: [], - }; - const drafts = scenario === 'settings-models' ? [noModels, zaiLive] : [zaiLive, noModels]; - const capability = await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' }); - const owner = await tryAcquireInteractiveRootOwner(capability); - if (!owner) throw new Error('Unable to acquire the connection catalog fixture root'); - try { - const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); - let revision = 0; - let defaultConnectionId: string | undefined; - for (const draft of drafts) { - const created = await stores.connectionCatalog.create({ - expectedCatalogRevision: revision, - connection: draft, - }); - if (created.kind !== 'committed') { - throw new Error(`Failed to seed ${draft.slug}: ${created.kind}`); - } - revision = created.snapshot.revision; - const connection = findFixtureConnection(created.snapshot, draft.slug); - if (draft.slug === 'zai-live' || scenario === 'settings-models') { - const credential = await stores.credentialVault.set({ - locator: { - scope: 'connection', - connectionId: connection.connectionId, - kind: 'api_key', - }, - expected: null, - secret: `fixture-key-${draft.slug}`, - }); - if (credential.kind !== 'committed') { - throw new Error(`Failed to seed ${draft.slug} credential: ${credential.kind}`); - } - } - if (draft.slug !== 'zai-live') continue; - - defaultConnectionId = connection.connectionId; - const modelFetch = await stores.operations.beginModelFetch(connection.connectionId); - if (modelFetch.kind !== 'ready') { - throw new Error(`Failed to start the zai-live model fixture: ${modelFetch.kind}`); - } - const modelInventory = await stores.operations.completeModelFetch(modelFetch.ticket, { - models: ZAI_FIXTURE_MODELS, - source: 'fetched', - fetchedAt: now - 5 * 60_000, - }); - if (modelInventory.kind !== 'committed') { - throw new Error(`Failed to seed the zai-live model fixture: ${modelInventory.kind}`); - } - revision = modelInventory.snapshot.revision; - - const connectionTest = await stores.operations.beginConnectionTest( - connection.connectionId, - 'glm-5.1', - ); - if (connectionTest.kind !== 'ready') { - throw new Error(`Failed to start the zai-live test fixture: ${connectionTest.kind}`); - } - const tested = await stores.operations.completeConnectionTest(connectionTest.ticket, { - status: 'verified', - checkedAt: new Date(now - 4 * 60_000).toISOString(), - }); - if (tested.kind !== 'committed') { - throw new Error(`Failed to seed the zai-live test fixture: ${tested.kind}`); - } - revision = tested.snapshot.revision; - } - if (!defaultConnectionId) { - throw new Error('Failed to resolve the zai-live fixture connection id'); - } - const defaulted = await stores.connectionCatalog.setDefaultTarget({ - expectedCatalogRevision: revision, - target: { connectionId: defaultConnectionId, modelId: 'glm-5.1' }, - }); - if (defaulted.kind !== 'committed') { - throw new Error(`Failed to set the fixture default target: ${defaulted.kind}`); - } - } finally { - await owner.close(); - } -} - -function findFixtureConnection(snapshot: ConnectionCatalogSnapshot, slug: string) { - const connection = snapshot.connections.find((item) => item.slug === slug); - if (!connection) throw new Error(`Committed fixture connection is missing: ${slug}`); - return connection; -} - -function model( - id: string, - capabilities: NonNullable, - contextWindow: number, -): ConnectionModel { - return { id, capabilities, contextWindow }; -} - export async function writeScheduledTasks(workspaceRoot: string, now: number): Promise { const scheduledRunAt = Date.UTC(2026, 11, 18, 3, 0, 0); const pausedRunAt = Date.UTC(2026, 11, 20, 3, 0, 0); diff --git a/apps/desktop/src/main/mcp-oauth-controller.ts b/apps/desktop/src/main/mcp-oauth-controller.ts index 06e4764ed37..826d7d322c7 100644 --- a/apps/desktop/src/main/mcp-oauth-controller.ts +++ b/apps/desktop/src/main/mcp-oauth-controller.ts @@ -131,7 +131,6 @@ export function createMcpOAuthController(deps: McpOAuthControllerDeps): McpOAuth const boundedAbandon = (serverId: string): Promise => new Promise((resolve) => { const timer = setTimeout(resolve, abandonGraceMs); - timer.unref?.(); void deps.manager .abandonAuthorization(serverId) .catch(() => {}) @@ -355,7 +354,6 @@ function createLoginDeadline(timeoutMs: number): { round.abort(new Error('Timed out waiting for the browser login')); reject(new Error('Timed out waiting for the browser login')); }, timeoutMs); - timer.unref(); cancel = () => clearTimeout(timer); // A user cancellation ends the round the same way a timeout does: the // race rejects and the signal fences the round's late writes. diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index f2c6954a287..c3e75079616 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -101,10 +101,6 @@ import { PROJECT_DIRECTORY_MAX_ENTRIES, type ProjectDirectoryEntry, type ProjectDirectoryRoot, - type QueueEntriesReorderInput, - type QueueEntryPromoteInput, - type QueueEntryRetractInput, - type QueueMutationResult, SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, type SessionCatalogChangedFrame, type ScheduledTaskChangedFrame, @@ -165,6 +161,17 @@ const RUNTIME_HOST_RETIREMENT_TIMEOUT_MS = 15_000; export type DesktopSessionConfigurationPatch = SessionConfigurationPatch; +type QueueMutationOperation = + | "queue.entry.promote" + | "queue.entry.retract" + | "queue.entry.update" + | "queue.entries.reorder"; + +type QueueMutationInput = Omit< + OperationInput, + "originHostEpoch" +>; + /** * How a remove settled. `restored` is not a failure: the task left the state * the caller decided against, so nothing was destroyed and nothing is wrong. @@ -1324,30 +1331,37 @@ export class DesktopRuntimeHostClient { } retractQueueEntry( - input: Omit, - ): Promise { - return this.request("queue.entry.retract", { - ...input, - originHostEpoch: this.connection.hostEpoch, - }); + input: QueueMutationInput<"queue.entry.retract">, + ): Promise> { + return this.#mutateQueue("queue.entry.retract", input); } promoteQueueEntry( - input: Omit, - ): Promise { - return this.request("queue.entry.promote", { - ...input, - originHostEpoch: this.connection.hostEpoch, - }); + input: QueueMutationInput<"queue.entry.promote">, + ): Promise> { + return this.#mutateQueue("queue.entry.promote", input); + } + + updateQueueEntry( + input: QueueMutationInput<"queue.entry.update">, + ): Promise> { + return this.#mutateQueue("queue.entry.update", input); } reorderQueueEntries( - input: Omit, - ): Promise { - return this.request("queue.entries.reorder", { + input: QueueMutationInput<"queue.entries.reorder">, + ): Promise> { + return this.#mutateQueue("queue.entries.reorder", input); + } + + #mutateQueue( + operation: K, + input: QueueMutationInput, + ): Promise> { + return this.request(operation, { ...input, originHostEpoch: this.connection.hostEpoch, - }); + } as OperationInput); } interruptTurn( diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 0f615b49eed..58d1b480669 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -416,6 +416,26 @@ export async function startDesktopRuntimeHostCandidate( } } +async function restoreSessionObservations(input: { + sessionIds(): string[]; + announcePending(sessionId: string): void; + attach(): Promise; +}): Promise { + const requested = input.sessionIds(); + for (const sessionId of requested) input.announcePending(sessionId); + + const restored = await input.attach(); + const restoredSet = new Set(restored); + const registeredSet = new Set(input.sessionIds()); + const failed = requested.filter( + (sessionId) => registeredSet.has(sessionId) && !restoredSet.has(sessionId), + ); + if (failed.length > 0) { + throw new Error(`Failed to restore Session observations: ${failed.join(', ')}`); + } + return restored; +} + function noGuestBotService(): BotIncomingMainService { return { handleBotIncomingMessage: async () => { @@ -747,37 +767,27 @@ export async function createDesktopRuntimeHostCandidate( } } } - const observedSessionIds = sessionObservations.observedSessionIds(); - for (const sessionId of observedSessionIds) { - sendToRenderer(`sessions:event:${sessionId}`, { type: 'host_observation_pending' }); - } - observationsAttached = true; - const restoredSessionIds = await sessionObservations.attach( - sessionObserver, - (target) => ({ - id: target.id, - send: (channel, payload) => - (target.send as (channel: string, ...args: unknown[]) => void)( - channel, - scope, - payload, - ), - once: target.once.bind(target), - off: target.off.bind(target), - }), - (missingSessionId) => emitSessionsChanged("deleted", missingSessionId), - ); - const restoredSessionIdSet = new Set(restoredSessionIds); - // Attach forgets Sessions the Host no longer serves, so only Sessions - // that are still registered but failed to restore count as failures. - const failedSessionIds = sessionObservations - .observedSessionIds() - .filter((sessionId) => !restoredSessionIdSet.has(sessionId)); - if (failedSessionIds.length > 0) { - throw new Error( - `Failed to restore Session observations: ${failedSessionIds.join(', ')}`, - ); - } + observationsAttached = Boolean(sessionObserver); + const restoredSessionIds = await restoreSessionObservations({ + sessionIds: () => sessionObservations.observationSessionIds(), + announcePending: (sessionId) => + sendToRenderer(`sessions:event:${sessionId}`, { type: 'host_observation_pending' }), + attach: () => sessionObservations.attach( + sessionObserver, + (target) => ({ + id: target.id, + send: (channel, payload) => + (target.send as (channel: string, ...args: unknown[]) => void)( + channel, + scope, + payload, + ), + once: target.once.bind(target), + off: target.off.bind(target), + }), + (missingSessionId) => emitSessionsChanged("deleted", missingSessionId), + ), + }); for (const sessionId of restoredSessionIds) { emitSessionsChanged("message-appended", sessionId); emitSessionsChanged("goal-change", sessionId); diff --git a/apps/desktop/src/main/runtime-host-queue-mutation-ipc.ts b/apps/desktop/src/main/runtime-host-queue-mutation-ipc.ts new file mode 100644 index 00000000000..cdffe4a3aa9 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-queue-mutation-ipc.ts @@ -0,0 +1,143 @@ +/* + * 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 type { ReconnectableReadIpcMain } from "./ipc-reconnect-policy.js"; +import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; + +export type RuntimeHostQueueMutationClient = Pick< + DesktopRuntimeHostClient, + | "promoteQueueEntry" + | "reorderQueueEntries" + | "retractQueueEntry" + | "updateQueueEntry" +>; + +export function registerRuntimeHostQueueMutationIpc( + ipcMain: ReconnectableReadIpcMain, + client: RuntimeHostQueueMutationClient, + newId: () => string, +): void { + for (const mutation of queueMutationRegistrations(client)) { + ipcMain.handle(mutation.channel, async (_event, ...args: unknown[]) => { + await mutation.invoke(args, newId); + }); + } +} + +type QueueMutationRegistration = { + readonly channel: + | "sessions:promoteQueueEntry" + | "sessions:reorderQueueEntries" + | "sessions:retractQueueEntry" + | "sessions:updateQueueEntry"; + readonly invoke: (args: readonly unknown[], newId: () => string) => Promise; +}; + +function queueMutationRegistrations( + client: RuntimeHostQueueMutationClient, +): readonly QueueMutationRegistration[] { + return [ + { + channel: "sessions:retractQueueEntry", + invoke: async ([sessionId, entryId], newId) => { + await client.retractQueueEntry({ + sessionId: requiredId(sessionId, "Session"), + entryId: queueEntryId(entryId), + retractId: newId(), + }); + }, + }, + { + channel: "sessions:promoteQueueEntry", + invoke: async ([sessionId, entryId], newId) => { + await client.promoteQueueEntry({ + sessionId: requiredId(sessionId, "Session"), + entryId: queueEntryId(entryId), + promoteId: newId(), + }); + }, + }, + { + channel: "sessions:updateQueueEntry", + invoke: async ([sessionId, entryId, expectedQueueRevision, text], newId) => { + await client.updateQueueEntry({ + sessionId: requiredId(sessionId, "Session"), + entryId: requiredId(entryId, "Queue entry"), + updateId: newId(), + expectedQueueRevision: queueRevision(expectedQueueRevision), + text: queuedMessageText(text), + }); + }, + }, + { + channel: "sessions:reorderQueueEntries", + invoke: async ([sessionId, entryIds, expectedQueueRevision], newId) => { + await client.reorderQueueEntries({ + sessionId: requiredId(sessionId, "Session"), + reorderId: newId(), + expectedQueueRevision: queueRevision(expectedQueueRevision), + entryIds: queueEntryOrder(entryIds), + }); + }, + }, + ]; +} + +function queueEntryId(value: unknown): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 256 + ) { + throw new TypeError("Invalid queue entry identity"); + } + return value; +} + +function queueEntryOrder(value: unknown): string[] { + if (!Array.isArray(value) || value.some((entryId) => typeof entryId !== "string")) { + throw new TypeError("Invalid queue entry order"); + } + return value; +} + +function queuedMessageText(value: unknown): string { + if ( + typeof value !== "string" || + value.trim().length === 0 || + Buffer.byteLength(value, "utf8") > 48 * 1024 + ) { + throw new Error("Invalid Queued message text"); + } + return value.trim(); +} + +function queueRevision(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error("Invalid Queue sequence"); + } + return value as number; +} + +function requiredId(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0 || value.length > 256) { + throw new Error(`Invalid ${label} identity`); + } + return value; +} diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 62fe6dbd302..27658e5dada 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -83,6 +83,10 @@ import type { import type { DesktopSessionStopResult } from '../preload/bridge-contract.js'; import { toDesktopHostSessionSummary } from "./runtime-host-session-catalog-ipc-main.js"; import { mergeWorkspaceFileInlineReferences } from "./session-workspace-inline-references.js"; +import { + registerRuntimeHostQueueMutationIpc, + type RuntimeHostQueueMutationClient, +} from "./runtime-host-queue-mutation-ipc.js"; type SideConversationBranchResult = | { readonly ok: true; readonly session: ReturnType } @@ -122,15 +126,12 @@ type RuntimeHostSessionExecutionClient = Pick< | 'queryMessages' | "queryTurnResume" | "readExecutionBoundary" - | "retractQueueEntry" - | "promoteQueueEntry" - | "reorderQueueEntries" | "setSessionReadMarker" | "startTurnResume" | "submitMessage" | "updateSessionMetadata" | "updateSessionConfiguration" ->; +> & RuntimeHostQueueMutationClient; /** No Skill was named, so the Host resolved none. */ const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] } as const; @@ -648,48 +649,7 @@ export function registerRuntimeHostSessionExecutionIpc( }; }, ); - ipcMain.handle( - "sessions:retractQueueEntry", - async (_event, sessionId: string, entryId: unknown) => { - if (typeof entryId !== "string") { - throw new TypeError("Invalid queue entry identity"); - } - await deps.client.retractQueueEntry({ - sessionId, - entryId, - retractId: newId(), - }); - }, - ); - ipcMain.handle( - "sessions:promoteQueueEntry", - async (_event, sessionId: string, entryId: unknown) => { - if (typeof entryId !== "string") { - throw new TypeError("Invalid queue entry identity"); - } - await deps.client.promoteQueueEntry({ - sessionId, - entryId, - promoteId: newId(), - }); - }, - ); - ipcMain.handle( - "sessions:reorderQueueEntries", - async (_event, sessionId: string, entryIds: unknown) => { - if ( - !Array.isArray(entryIds) || - entryIds.some((entryId) => typeof entryId !== "string") - ) { - throw new TypeError("Invalid queue entry order"); - } - await deps.client.reorderQueueEntries({ - sessionId, - reorderId: newId(), - entryIds, - }); - }, - ); + registerRuntimeHostQueueMutationIpc(ipcMain, deps.client, newId); ipcMain.handle( "sessions:stop", async (_event, sessionId: string, input: unknown) => { @@ -1063,6 +1023,24 @@ function requiredMessageId(value: unknown): string { return value; } +function requiredText(value: unknown, label: string): string { + if ( + typeof value !== "string" || + value.trim().length === 0 || + Buffer.byteLength(value, "utf8") > 48 * 1024 + ) { + throw new Error(`Invalid ${label} text`); + } + return value; +} + +function requiredSequence(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error(`Invalid ${label} sequence`); + } + return value as number; +} + function normalizeSnapshotMaxChars(options: unknown): number { if (options === undefined) return SESSION_SNAPSHOT_DEFAULT_MAX_CHARS; if (!options || typeof options !== 'object' || Array.isArray(options)) { diff --git a/apps/desktop/src/main/runtime-host-session-observation-registry.ts b/apps/desktop/src/main/runtime-host-session-observation-registry.ts index 48811a97eb1..6b03f0bdc96 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -233,10 +233,8 @@ export class RuntimeHostSessionObservationRegistry { return [...new Set(restored.filter((sessionId): sessionId is string => !!sessionId))]; } - observedSessionIds(): string[] { - return [...new Set([...this.#registrations.values()].map((registration) => registration.sessionId))]; - } - + observationSessionIds = (): string[] => + Array.from(new Set(Array.from(this.#registrations.values(), ({ sessionId }) => sessionId))); trackedSessionIds(): string[] { return [ ...new Set([ diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 3e9deaace28..d3db91690bd 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1284,6 +1284,12 @@ export interface MakaBridge { } | { ok: false; reason: 'outcome_unknown' } >; + updateQueueEntry( + sessionId: string, + entryId: string, + expectedQueueRevision: number, + text: string, + ): Promise; queryCancelledMessages( sessionId: string, messageIds: readonly string[], @@ -1294,7 +1300,7 @@ export interface MakaBridge { ): Promise; retractQueueEntry(sessionId: string, entryId: string): Promise; promoteQueueEntry(sessionId: string, entryId: string): Promise; - reorderQueueEntries(sessionId: string, entryIds: readonly string[]): Promise; + reorderQueueEntries(sessionId: string, entryIds: readonly string[], expectedQueueRevision: number): Promise; readExecutionBoundary(sessionId: string): Promise; listActiveInteractions(sessionId: string): Promise; subscribeActiveInteractions( @@ -1343,7 +1349,7 @@ export interface MakaBridge { subscribeEvents( sessionId: string, handler: (event: SessionEvent) => void, - onObservationSeed?: (phase: 'pending' | 'ready') => void, + onObservationPhase?: (phase: 'pending' | 'ready') => void, onSeedError?: (error: unknown) => void, onExecution?: (projection: import('../shared/session-execution-projection.js').SessionExecutionProjection | undefined) => void, ): () => void; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 72da9a55cab..58a87563bef 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -730,6 +730,20 @@ async function invokeSessionRuntimeHost( return invokeWhenReady(channel, session.scope, session.sessionId, ...args) as Promise; } +type QueueMutationChannel = + | 'sessions:promoteQueueEntry' + | 'sessions:reorderQueueEntries' + | 'sessions:retractQueueEntry' + | 'sessions:updateQueueEntry'; + +function invokeQueueMutation( + channel: QueueMutationChannel, + sessionId: string, + ...args: unknown[] +): Promise { + return invokeSessionRuntimeHost(channel, sessionId, ...args); +} + async function invokeRuntimeHostForSession( channel: string, sessionId: string, @@ -2342,15 +2356,26 @@ const makaBridge = { queryMessageExecutions(sessionId, messageIds) { return invokeSessionRuntimeHost('sessions:queryMessageExecutions', sessionId, messageIds); }, - retractQueueEntry(sessionId: string, entryId: string): Promise { - return invokeSessionRuntimeHost('sessions:retractQueueEntry', sessionId, entryId); - }, - promoteQueueEntry(sessionId: string, entryId: string): Promise { - return invokeSessionRuntimeHost('sessions:promoteQueueEntry', sessionId, entryId); - }, - reorderQueueEntries(sessionId: string, entryIds: readonly string[]): Promise { - return invokeSessionRuntimeHost('sessions:reorderQueueEntries', sessionId, [...entryIds]); + retractQueueEntry: (sessionId: string, entryId: string) => + invokeQueueMutation('sessions:retractQueueEntry', sessionId, entryId), + promoteQueueEntry: (sessionId: string, entryId: string) => + invokeQueueMutation('sessions:promoteQueueEntry', sessionId, entryId), + updateQueueEntry( + sessionId: string, + entryId: string, + expectedQueueRevision: number, + text: string, + ): Promise { + return invokeQueueMutation( + 'sessions:updateQueueEntry', + sessionId, + entryId, + expectedQueueRevision, + text, + ); }, + reorderQueueEntries: (sessionId: string, entryIds: readonly string[], expectedQueueRevision: number) => + invokeQueueMutation('sessions:reorderQueueEntries', sessionId, [...entryIds], expectedQueueRevision), readExecutionBoundary(sessionId: string): Promise { return invokeSessionRuntimeHost('sessions:readExecutionBoundary', sessionId); }, @@ -2454,7 +2479,7 @@ const makaBridge = { subscribeEvents( sessionId: string, handler: (event: SessionEvent) => void, - onObservationSeed?: (phase: 'pending' | 'ready') => void, + onObservationPhase?: (phase: 'pending' | 'ready') => void, onSeedError?: (error: unknown) => void, onExecution?: (projection: import('../shared/session-execution-projection.js').SessionExecutionProjection | undefined) => void, ): () => void { @@ -2478,9 +2503,10 @@ const makaBridge = { // registry restores this observer on the replacement target. Profile // identity admits that replacement without accepting another Host's // same-named Session channel. - unsubscribeEvents = subscribeEveryRuntimeHostEvent( - `sessions:event:${session.sessionId}`, - (scope, event: SessionEvent | SessionObservationMessage) => { + const consumeObservationEvent = ( + scope: DesktopTargetScope, + event: SessionEvent | SessionObservationMessage, + ): void => { if (disposed) return; if (runtimeHostMetadataFor(scope)?.profileId !== profileId) return; if (event.type === 'host_observation_seed') { @@ -2492,26 +2518,24 @@ const makaBridge = { if (disposed) return; handler(projectDesktopSessionEvent(scope, seededEvent)); } - if (!disposed) onObservationSeed?.('ready'); + if (!disposed) onObservationPhase?.('ready'); return; } if (event.type === 'host_observation_pending') { if (lastExecution) lastExecution = { ...lastExecution, available: false }; onExecution?.(lastExecution); - onObservationSeed?.('pending'); - return; - } - if (event.type === 'host_execution') { - acceptExecution(event); + onObservationPhase?.('pending'); return; } + if (event.type === 'host_execution') return void acceptExecution(event); if (event.type === 'host_observation_error') { onSeedError?.(new Error(event.message)); return; } handler(projectDesktopSessionEvent(scope, event)); - }, - ); + }; + const observationChannel = `sessions:event:${session.sessionId}`; + unsubscribeEvents = subscribeEveryRuntimeHostEvent(observationChannel, consumeObservationEvent); return { completion: invokeWhenReady( 'sessions:observe', @@ -4190,7 +4214,7 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { makaBridge.sessions.subscribeEvents = ( sessionId, handler, - onObservationSeed, + onObservationPhase, onSeedError, onExecution, ) => { @@ -4201,7 +4225,7 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { let unsubscribe = () => {}; void waitForLatch('sessions.observe').then(() => { if (!disposed) unsubscribe = subscribeSessionEvents( - sessionId, handler, onObservationSeed, onSeedError, onExecution, + sessionId, handler, onObservationPhase, onSeedError, onExecution, ); }); return () => { disposed = true; unsubscribe(); }; diff --git a/apps/desktop/src/renderer/agent-graph-panel-visibility.ts b/apps/desktop/src/renderer/agent-graph-panel-visibility.ts index 05723c6a97b..822371a2ef8 100644 --- a/apps/desktop/src/renderer/agent-graph-panel-visibility.ts +++ b/apps/desktop/src/renderer/agent-graph-panel-visibility.ts @@ -17,82 +17,12 @@ * under the License. */ -export type AgentGraphPanelStatus = - | 'empty' - | 'active' - | 'closing' - | 'waiting' - | 'stopped' - | 'failed' - | 'completed'; - -export type AgentGraphPanelDismissals = Readonly>; - -const DISMISSIBLE_STATUSES = new Set([ - 'completed', - 'stopped', - 'failed', -]); - -export function isAgentGraphPanelDismissible( - status: AgentGraphPanelStatus | undefined, -): boolean { - return status !== undefined && DISMISSIBLE_STATUSES.has(status); -} - -const LIVE_STATUSES = new Set([ - 'active', - 'waiting', - 'closing', -]); - -/** A graph in one of these statuses can be stopped and must keep signaling liveness. */ -export function isAgentGraphLive(status: AgentGraphPanelStatus | undefined): boolean { - return status !== undefined && LIVE_STATUSES.has(status); -} - -export function dismissAgentGraphPanel( - dismissedBySession: AgentGraphPanelDismissals, - sessionId: string, - graphId: string, -): AgentGraphPanelDismissals { - if (dismissedBySession[sessionId] === graphId) return dismissedBySession; - return { ...dismissedBySession, [sessionId]: graphId }; -} - -export function reconcileAgentGraphPanelDismissals( - dismissedBySession: AgentGraphPanelDismissals, - sessionId: string, - snapshot: - | { rootSessionId: string; graphId: string; status: AgentGraphPanelStatus } - | undefined, -): AgentGraphPanelDismissals { - const dismissed = dismissedBySession[sessionId]; - if (!dismissed || !snapshot || snapshot.rootSessionId !== sessionId) { - return dismissedBySession; - } - if (snapshot.graphId === dismissed && isAgentGraphPanelDismissible(snapshot.status)) { - return dismissedBySession; - } - const next = { ...dismissedBySession }; - delete next[sessionId]; - return next; -} - -export function shouldShowAgentGraphPanel(input: { - enabled: boolean; - hasGraphActivity: boolean; - sessionId: string; - graphId?: string; - status?: AgentGraphPanelStatus; - dismissedBySession: AgentGraphPanelDismissals; -}): boolean { - if ( - input.graphId !== undefined && - input.dismissedBySession[input.sessionId] === input.graphId && - isAgentGraphPanelDismissible(input.status) - ) { - return false; - } - return input.enabled || input.hasGraphActivity; -} +export { + createAgentGraphPanelModel, + dismissAgentGraphPanel, + isAgentGraphLive, + isAgentGraphPanelDismissible, + reconcileAgentGraphPanelDismissals, + reduceAgentGraphPanelModel, + shouldShowAgentGraphPanel, +} from './application/contracts/agent-graph-panel-visibility.js'; diff --git a/apps/desktop/src/renderer/agent-graph-panel.tsx b/apps/desktop/src/renderer/agent-graph-panel.tsx index 063a13763a6..f054c07b7dc 100644 --- a/apps/desktop/src/renderer/agent-graph-panel.tsx +++ b/apps/desktop/src/renderer/agent-graph-panel.tsx @@ -34,7 +34,6 @@ import { isAgentGraphPanelDismissible, reconcileAgentGraphPanelDismissals, shouldShowAgentGraphPanel, - type AgentGraphPanelDismissals, } from './agent-graph-panel-visibility.js'; import { createAgentGraphRefreshScheduler, @@ -69,7 +68,7 @@ export function AgentGraphPanel(props: { error: false, }); const [collapsed, setCollapsed] = useState(); - const [dismissedBySession, setDismissedBySession] = useState({}); + const [dismissedBySession, setDismissedBySession] = useState>>({}); const contentId = useId(); const refreshRef = useRef(noopAgentGraphRefreshScheduler); const selectedGraphIdRef = useRef(undefined); @@ -77,7 +76,8 @@ export function AgentGraphPanel(props: { const stopRequestIdRef = useRef(0); const copy = getAgentGraphPanelCopy(props.locale); const stopFeedbackMatchesSelection = - stopState.rootSessionId === props.rootSessionId && stopState.graphId === selectedGraphId; + stopState.rootSessionId === props.rootSessionId && + stopState.graphId === selectedGraphId; const stopPending = stopFeedbackMatchesSelection && stopState.pending; const stopError = stopFeedbackMatchesSelection && stopState.error; // One liveness judgment gates both animated signals. @@ -87,7 +87,6 @@ export function AgentGraphPanel(props: { setSnapshot(undefined); setEpochs([]); setEpochsTruncated(false); - setSelectedGraphId(undefined); selectedGraphIdRef.current = undefined; followCurrentRef.current = true; setError(false); @@ -98,8 +97,9 @@ export function AgentGraphPanel(props: { pending: false, error: false, }); - setCollapsed(undefined); setLoading(props.enabled); + setSelectedGraphId(undefined); + setCollapsed(undefined); let cachedDirectory: AgentGraphEpochDirectory | undefined; const scheduler = createAgentGraphRefreshScheduler(async (fence) => { @@ -107,7 +107,7 @@ export function AgentGraphPanel(props: { try { let directory: AgentGraphEpochDirectory; if (!cachedDirectory) { - directory = await window.maka.graphs.listEpochs(props.rootSessionId); + directory = await window.maka.graphs.listEpochs(props.rootSessionId); } else { const currentPage = await window.maka.graphs.listCurrentEpochs(props.rootSessionId); directory = sameEpochPage(cachedDirectory, currentPage) @@ -260,9 +260,9 @@ export function AgentGraphPanel(props: { onChange={(graphId: SelectorOptionType) => { if (typeof graphId !== 'string') return; selectedGraphIdRef.current = graphId; + const current = epochs.find((entry) => entry.graphId === graphId)?.current === true; + followCurrentRef.current = current; setSelectedGraphId(graphId); - followCurrentRef.current = - epochs.find((entry) => entry.graphId === graphId)?.current === true; refreshRef.current.invalidateAndRefresh(); }} /> diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index aa75602df46..dad55ddc131 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -292,8 +292,7 @@ export function useActiveSessionEvents(options: { activeIdRef: RefBox; handleEvent: (sessionId: string, event: SessionEvent) => void; setExecution: import('./features/conversation/index.js').AppShellSessionUiStateController['setExecution']; - beginObservationSeed: (sessionId: string) => void; - completeObservationSeed: (sessionId: string) => void; + beginObservationSeed: (sessionId: string) => () => void; setMessageLoadErrorBySession: (updater: (current: Record) => Record) => void; clearMessageLoadError(sessionId: string): void; setMessageLoadPending: (pending: boolean) => void; @@ -348,7 +347,6 @@ export function useActiveSessionEvents(options: { options.handleEvent(sessionId, event); }); const beginObservationSeed = useEffectEvent(options.beginObservationSeed); - const completeObservationSeed = useEffectEvent(options.completeObservationSeed); const markSessionEventStreamClosed = useEffectEvent((sessionId: string) => { options.setSessionEventHealthBySession((current) => { const previous = current[sessionId]; @@ -399,7 +397,7 @@ export function useActiveSessionEvents(options: { applyTranscript(activeId, controller, () => !disposed)); const subscribeSessionEvents = () => { const attempt = ++observationAttempt; - beginObservationSeed(activeId); + let completeObservationSeed = beginObservationSeed(activeId); let unsubscribeRequested = false; let unsubscribeCurrent = () => { unsubscribeRequested = true; @@ -413,10 +411,10 @@ export function useActiveSessionEvents(options: { (phase) => { if (attempt !== observationAttempt) return; controller.observationChanged(phase); - if (phase === 'pending') beginObservationSeed(activeId); + if (phase === 'pending') completeObservationSeed = beginObservationSeed(activeId); else { observationFailures = 0; - completeObservationSeed(activeId); + completeObservationSeed(); } }, () => { diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index d5bf2663c25..78b50a7c514 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -55,20 +55,19 @@ export interface AppShellSessionEventHandlers { handleEvent(sessionId: string, event: SessionEvent): void; reconcilePersistedMessages(sessionId: string, messages: readonly StoredMessage[]): void; settleAssistantStreaming(sessionId: string, messageId?: string): Promise; - flushDisplayEvents(sessionId: string): void; - dropDisplayEvents(sessionId: string): void; - markDisplayPending(sessionId: string): void; - markDisplayReady(sessionId: string): void; + holdDisplayEvents(sessionId: string): void; + releaseDisplayEvents(sessionId: string): void; + discardDisplayEvents(sessionId: string): void; } export interface AppShellSessionDisplayBatch { readonly pendingEvents: Map; - readonly displayPendingSessions: Set; + readonly heldSessions: Set; framePending: boolean; } export function createAppShellSessionDisplayBatch(): AppShellSessionDisplayBatch { - return { pendingEvents: new Map(), displayPendingSessions: new Set(), framePending: false }; + return { pendingEvents: new Map(), heldSessions: new Set(), framePending: false }; } export function createAppShellSessionEventHandlers(options: { @@ -183,29 +182,16 @@ export function createAppShellSessionEventHandlers(options: { }); } - function flushDisplayEvents(sessionId: string): void { - const events = takePendingDisplayEvents(sessionId); - if (!events.length) return; - updateLiveTurn(sessionId, events); - } - - function dropDisplayEvents(sessionId: string): void { + const holdDisplayEvents = (sessionId: string): void => void displayBatch.heldSessions.add(sessionId); + const releaseDisplayEvents = (sessionId: string): void => { + const queued = takePendingDisplayEvents(sessionId); + if (queued.length > 0) updateLiveTurn(sessionId, queued); + displayBatch.heldSessions.delete(sessionId); + }; + const discardDisplayEvents = (sessionId: string): void => { displayBatch.pendingEvents.delete(sessionId); - displayBatch.displayPendingSessions.delete(sessionId); - } - - function markDisplayPending(sessionId: string): void { - displayBatch.displayPendingSessions.add(sessionId); - } - - function markDisplayReady(sessionId: string): void { - displayBatch.displayPendingSessions.delete(sessionId); - } - - function canBatchDisplayEvents(sessionId: string): boolean { - return !displayBatch.displayPendingSessions.has(sessionId); - } - + displayBatch.heldSessions.delete(sessionId); + }; function updateLiveTurn(sessionId: string, events: readonly SessionEvent[]): void { setLiveTurnBySession((current) => replaceLiveTurns(current, new Map([[sessionId, events]]))); } @@ -282,7 +268,7 @@ export function createAppShellSessionEventHandlers(options: { if ( scheduleFrame && activeIdRef.current === sessionId - && canBatchDisplayEvents(sessionId) + && !displayBatch.heldSessions.has(sessionId) && ( event.type === 'text_delta' || event.type === 'thinking_delta' @@ -415,10 +401,9 @@ export function createAppShellSessionEventHandlers(options: { handleEvent, reconcilePersistedMessages, settleAssistantStreaming, - flushDisplayEvents, - dropDisplayEvents, - markDisplayPending, - markDisplayReady, + holdDisplayEvents, + releaseDisplayEvents, + discardDisplayEvents, }; } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 72d3b5f384b..2d7ae777c8b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -171,7 +171,6 @@ import { useSessionEventHealthPolling, useShellRunUpdates, } from './app-shell-effects'; -import * as liveContent from './live-content-seed'; import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults'; import { useTurnActionRegistry } from './use-turn-action-registry'; import { @@ -396,6 +395,7 @@ function AppShellContent({ attachFilePaths, restoreAttachments, restoreDirectories, + restoreQuotes, removeAttachment, clearSubmittedContext, imageNoticeLifecycle, @@ -404,7 +404,6 @@ function AppShellContent({ quotesForSend, addQuote, clearQuotes, - restoreQuotes, composerQuoteProps, chatViewQuoteProps, } = useComposerAttachments({ @@ -612,6 +611,8 @@ function AppShellContent({ // recent workspace history so the home view is populated before the async // `app:info` round-trip completes on mount. const persistedComposerDefaults = loadComposerDefaults(); + const composerRef = queueSurface.composer; + const restoreLocalMessageDraft = queueSurface.restoreDraft; const openComposerModelPicker = useCallback(() => { composerRef.current?.openModelPicker(); }, []); @@ -666,24 +667,6 @@ function AppShellContent({ } : undefined; const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; - const { - composer: composerRef, - transientMessages: transcriptTransientMessages, - restoreDraft: restoreLocalMessageDraft, - draftContextRestorer, - promoteQueuedEntry, - editQueuedEntry, - deleteQueuedEntry, - reorderQueuedEntries, - } = queueSurface; - // A retracted send hands its staged context back through the same keyed - // stores a picked file or quote would land in — keyed by Session, so the - // restore survives the owning Session navigating away mid-request. - draftContextRestorer.current = (targetSessionId, draft) => { - if (draft.attachments?.length) restoreAttachments(targetSessionId, draft.attachments); - if (draft.directoryReferences?.length) restoreDirectories(targetSessionId, draft.directoryReferences); - if (draft.quotes?.length) restoreQuotes(targetSessionId, draft.quotes); - }; // The shell's reading of the active live turn: streaming/settled flags, the // in-flight tool signal, and the #646 turn-wait cues, all derived from the // semantic snapshot rather than the projection (#1985). @@ -785,11 +768,11 @@ function AppShellContent({ // model claims live in the session UI store. const turnActionRegistry = useTurnActionRegistry(); - // A hoisted declaration on purpose: `dropDisplayEvents` is destructured + // A hoisted declaration on purpose: `discardDisplayEvents` is destructured // hundreds of lines below, and the rail does not need this identity held // still — the rail's controller reads it through `portsRef`. function clearSessionRendererState(sessionId: string): void { - dropDisplayEvents(sessionId); + discardDisplayEvents(sessionId); // `clearOwnedSessionState` ends in `clearSessionUiState`, which drops this // session from every session-UI map — the four pending claims included. clearOwnedSessionState(sessionId); @@ -961,10 +944,7 @@ function AppShellContent({ sessionSettingIntent.overlay.permissionMode, ); const activePermissionMode = activeBoundarySurface.permissionMode; - const planMode = usePlanModeState( - ownerActiveId ? activeHostSession : undefined, - sessionCatalogController, - ); + const planMode = usePlanModeState(ownerActiveId ? activeHostSession : undefined); const planConversationItems = (planMode.state?.proposals ?? []).map((proposal) => ({ id: proposal.proposalId, afterTurnId: proposal.turnId, @@ -1198,7 +1178,6 @@ function AppShellContent({ }), [toastApi], ); - const exitWorkHub = useCallback(() => setWorkHubActive(false), []); const selectSessionSurface = useCallback( () => setNavSelection({ section: 'sessions' }), @@ -1270,8 +1249,10 @@ function AppShellContent({ setNavSelection, openSearchModal: openSearch, setSessionListCollapsed: sessionRailLayoutStore.setCollapsed, - // Stable bridge delegates: the fixture keeps them across Workbar updates. - workbar: commands, + workbar: { + setWorkbarCollapsed: commands.setWorkbarCollapsed, + openTool: commands.openTool, + }, setThemePref, setUiLocaleOverride, }); @@ -1637,8 +1618,6 @@ function AppShellContent({ return ok; } - - const stop = createAppShellStopAction({ uiLocale, activeIdRef, @@ -1648,15 +1627,7 @@ function AppShellContent({ }); const [sessionDisplayBatch] = useState(createAppShellSessionDisplayBatch); - const { - handleEvent, - reconcilePersistedMessages, - settleAssistantStreaming, - flushDisplayEvents, - dropDisplayEvents, - markDisplayPending, - markDisplayReady, - } = useStableActions(createAppShellSessionEventHandlers, { + const sessionEventHandlers = useStableActions(createAppShellSessionEventHandlers, { uiLocale, activeIdRef, liveTurnBySessionRef: sessionUiController.liveTurnBySessionRef, @@ -1678,6 +1649,12 @@ function AppShellContent({ setPetCompletionNonce((current) => current + 1); }, }); + const handleEvent = sessionEventHandlers.handleEvent; + const reconcilePersistedMessages = sessionEventHandlers.reconcilePersistedMessages; + const settleAssistantStreaming = sessionEventHandlers.settleAssistantStreaming; + const holdDisplayEvents = sessionEventHandlers.holdDisplayEvents; + const releaseDisplayEvents = sessionEventHandlers.releaseDisplayEvents; + const discardDisplayEvents = sessionEventHandlers.discardDisplayEvents; // Streaming-settle handoff, FALLBACK path only. The bubble's primary // `onStreamingSettled` signal runs after Astryx commits the terminal text. @@ -1734,43 +1711,43 @@ function AppShellContent({ themePalette, themePref, }); - const [activeEventSeed, setActiveEventSeed] = useState( - liveContent.EMPTY_LIVE_CONTENT_SEED, + const [liveContentSeed, setLiveContentSeed] = useState( + Conversation.INITIAL_LIVE_CONTENT_SEED, ); - const activeEventSeedRef = useRef(activeEventSeed); - activeEventSeedRef.current = activeEventSeed; - const beginObservationSeed = (sessionId: string) => { - const next = liveContent.beginLiveContentSeed(activeEventSeedRef.current, sessionId); - activeEventSeedRef.current = next; - markDisplayPending(sessionId); - setActiveEventSeed(next); - }; - const completeObservationSeed = (sessionId: string) => { - const current = activeEventSeedRef.current; - if (current.sessionId !== sessionId) return; - flushDisplayEvents(sessionId); - markDisplayReady(sessionId); - const next = liveContent.completeLiveContentSeed(current, sessionId); - activeEventSeedRef.current = next; - setActiveEventSeed(next); - void retireCancelledTransientMessages(sessionId); - }; - const observationAuthorityRef = useRef(liveContent.EMPTY_SESSION_OBSERVATION_AUTHORITY); - observationAuthorityRef.current = liveContent.advanceSessionObservationAuthority( + const liveContentSeedRef = useRef(liveContentSeed); + liveContentSeedRef.current = liveContentSeed; + function beginObservationSeed(sessionId: string) { + const seed = Conversation.beginLiveContentSeed(liveContentSeedRef.current, sessionId); + liveContentSeedRef.current = seed.state; + holdDisplayEvents(sessionId); + setLiveContentSeed(seed.state); + function finishObservationSeed() { + if (!Conversation.ownsLiveContentSeed(liveContentSeedRef.current, seed.token)) return; + releaseDisplayEvents(sessionId); + const revealed = Conversation.revealLiveContentSeed(liveContentSeedRef.current, seed.token); + liveContentSeedRef.current = revealed; + setLiveContentSeed(revealed); + void retireCancelledTransientMessages(sessionId); + } + return finishObservationSeed; + } + const observationAuthorityRef = useRef(Conversation.INITIAL_OBSERVATION_AUTHORITY); + observationAuthorityRef.current = Conversation.reconcileObservationAuthority( observationAuthorityRef.current, - requestedSessionId, - requestedCatalogSession?.profileId, + { + sessionId: requestedSessionId, + profileId: requestedCatalogSession?.profileId, + }, ); useActiveSessionEvents({ publishTranscript, uiLocale, activeId: requestedHostSession?.id, - observationAuthorityRevision: observationAuthorityRef.current.revision, + observationAuthorityRevision: observationAuthorityRef.current.generation, activeIdRef, handleEvent, - beginObservationSeed, + beginObservationSeed: (sessionId) => beginObservationSeed(sessionId), setExecution: sessionUiController.setExecution, - completeObservationSeed, setMessageLoadErrorBySession: sessionUiController.setMessageLoadErrorBySession, clearMessageLoadError: sessionUiController.clearMessageLoadError, setMessageLoadPending, @@ -2003,10 +1980,10 @@ function AppShellContent({ return ( // Feature controllers live below the shell. Task Entry publishes a stable // shell projection plus reader-local Host/Workspace Picker projections; - // Workbar, Goal state and Module Hub ownership likewise wake only their - // narrow readers. Composer mentions still wrap the frame so one - // projection serves every composer, including side-chat panels, without - // rebuilding the frame on catalog moves. + // Goal state and Module Hub ownership likewise wake only their narrow + // readers. Composer mentions still wrap the frame so one projection serves + // every composer, including side-chat panels, without rebuilding the frame + // on catalog moves. - {(workspacePicker) => ( - - {(guest) => ( - ( + + {(guest) => ( + - )} - - )} + /> + )} + + )} } @@ -2434,9 +2412,9 @@ function AppShellContent({ onLoadEarlierHistory={() => transcriptReadingCommands.current?.loadEarlier()} transcriptTurnIndex={activeId && transcriptTurnIndex?.sessionId === activeId ? transcriptTurnIndex.turns : undefined} onLoadTranscriptTurn={(turn) => transcriptReadingCommands.current?.loadEarlier(turn.sequence)} - liveContentSeedRevision={liveContent.liveContentSeedRevision(activeEventSeed, activeId)} + liveContentSeedGeneration={Conversation.visibleLiveContentGeneration(liveContentSeed, activeId)} messages={messages} - transientMessages={transcriptTransientMessages} + transientMessages={transientMessages} messageLoading={activeMessageLoading} onStreamingSettled={ activeId ? (messageId) => settleAssistantStreaming(activeId, messageId) : undefined diff --git a/apps/desktop/src/renderer/application/contracts/agent-graph-panel-visibility.ts b/apps/desktop/src/renderer/application/contracts/agent-graph-panel-visibility.ts new file mode 100644 index 00000000000..ae67c4ac8f6 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/agent-graph-panel-visibility.ts @@ -0,0 +1,155 @@ +/* + * 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 type { AgentGraphClientSnapshot } from '@maka/runtime/stream-graph-read-model'; + +type AgentGraphPanelStatus = AgentGraphClientSnapshot['status']; +type AgentGraphPanelDismissals = Readonly>; + +interface AgentGraphPanelModelState { + readonly rootSessionId: string; + readonly selectedGraphId: string | undefined; + readonly followCurrent: boolean; + readonly collapsed: boolean | undefined; + readonly dismissedBySession: AgentGraphPanelDismissals; +} + +type AgentGraphPanelSnapshot = Pick; + +type AgentGraphPanelModelAction = + | { type: 'enter-session'; rootSessionId: string } + | { type: 'select-epoch'; graphId: string; current: boolean } + | { type: 'follow-current' } + | { type: 'commit-snapshot'; snapshot: AgentGraphPanelSnapshot; current: boolean } + | { type: 'dismiss'; graphId: string } + | { type: 'toggle-collapse' }; + +const DISMISSIBLE_STATUSES = new Set(['completed', 'stopped', 'failed']); +const LIVE_STATUSES = new Set(['active', 'waiting', 'closing']); + +export function createAgentGraphPanelModel(rootSessionId: string): AgentGraphPanelModelState { + return { + rootSessionId, + selectedGraphId: undefined, + followCurrent: true, + collapsed: undefined, + dismissedBySession: {}, + }; +} + +export function reduceAgentGraphPanelModel( + state: AgentGraphPanelModelState, + action: AgentGraphPanelModelAction, +): AgentGraphPanelModelState { + switch (action.type) { + case 'enter-session': + if (state.rootSessionId === action.rootSessionId) return state; + return { + ...state, + rootSessionId: action.rootSessionId, + selectedGraphId: undefined, + followCurrent: true, + collapsed: undefined, + }; + case 'select-epoch': + return { ...state, selectedGraphId: action.graphId, followCurrent: action.current }; + case 'follow-current': + return { ...state, selectedGraphId: undefined, followCurrent: true }; + case 'commit-snapshot': { + if (action.snapshot.rootSessionId !== state.rootSessionId) return state; + let dismissedBySession = state.dismissedBySession; + const dismissed = dismissedBySession[state.rootSessionId]; + if ( + action.current && + dismissed !== undefined && + (dismissed !== action.snapshot.graphId || !isAgentGraphPanelDismissible(action.snapshot.status)) + ) { + dismissedBySession = withoutSessionDismissal(dismissedBySession, state.rootSessionId); + } + return { + ...state, + selectedGraphId: action.snapshot.graphId, + collapsed: state.collapsed ?? action.snapshot.status === 'completed', + dismissedBySession, + }; + } + case 'dismiss': + return { + ...state, + dismissedBySession: { ...state.dismissedBySession, [state.rootSessionId]: action.graphId }, + }; + case 'toggle-collapse': + return { ...state, collapsed: !(state.collapsed ?? false) }; + } +} + +export function isAgentGraphPanelDismissible(status: AgentGraphPanelStatus | undefined): boolean { + return status !== undefined && DISMISSIBLE_STATUSES.has(status); +} + +export function isAgentGraphLive(status: AgentGraphPanelStatus | undefined): boolean { + return status !== undefined && LIVE_STATUSES.has(status); +} + +export function dismissAgentGraphPanel( + dismissedBySession: AgentGraphPanelDismissals, + sessionId: string, + graphId: string, +): AgentGraphPanelDismissals { + if (dismissedBySession[sessionId] === graphId) return dismissedBySession; + return { ...dismissedBySession, [sessionId]: graphId }; +} + +export function reconcileAgentGraphPanelDismissals( + dismissedBySession: AgentGraphPanelDismissals, + sessionId: string, + snapshot: { rootSessionId: string; graphId: string; status: AgentGraphPanelStatus } | undefined, +): AgentGraphPanelDismissals { + const dismissed = dismissedBySession[sessionId]; + if (!dismissed || !snapshot || snapshot.rootSessionId !== sessionId) return dismissedBySession; + if (snapshot.graphId === dismissed && isAgentGraphPanelDismissible(snapshot.status)) return dismissedBySession; + return withoutSessionDismissal(dismissedBySession, sessionId); +} + +export function shouldShowAgentGraphPanel(input: { + enabled: boolean; + hasGraphActivity: boolean; + sessionId: string; + graphId?: string; + status?: AgentGraphPanelStatus; + dismissedBySession: AgentGraphPanelDismissals; +}): boolean { + if ( + input.graphId !== undefined && + input.dismissedBySession[input.sessionId] === input.graphId && + isAgentGraphPanelDismissible(input.status) + ) { + return false; + } + return input.enabled || input.hasGraphActivity; +} + +function withoutSessionDismissal( + dismissedBySession: AgentGraphPanelDismissals, + sessionId: string, +): AgentGraphPanelDismissals { + const next = { ...dismissedBySession }; + delete next[sessionId]; + return next; +} diff --git a/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts b/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts index 1b9712d79cd..47a6266f18a 100644 --- a/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts +++ b/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts @@ -25,6 +25,7 @@ import type { /** The last Host queue snapshot a surface renders from. */ export interface MessageQueueProjection { readonly ts: number; + readonly queueRevision?: number; readonly entries: readonly MessageQueueEntryProjection[]; } @@ -38,6 +39,7 @@ export function deriveMessageQueueProjection( ].map((entry) => structuredClone(entry)); return { ts: event.ts, + queueRevision: event.queueRevision, entries, }; } diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index fd7fdadc2e7..1d3e0a5f44d 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -68,8 +68,8 @@ interface ChatMessageSurfaceProps extends Omit< sessionUiController: AppShellSessionUiStateController; /** The shell's selected session. Not derived from `activeSession`, which the shell substitutes for an unsaved chat. */ activeSessionId: string | undefined; - /** Advances after the active session's current observation generation finishes seeding. */ - liveContentSeedRevision: number; + /** Identifies the active session observation whose seed is visible. */ + liveContentSeedGeneration: number; sessionHealthNotice?: SessionHealthNoticeView; sessionHealthModelPickerAvailable: boolean; workspaceReadinessRecovery?: WorkspaceReadinessRecovery; @@ -100,7 +100,7 @@ function captureLiveContent(liveTurn: LiveTurnProjection | undefined) { export function ChatMessageSurface({ sessionUiController, activeSessionId, - liveContentSeedRevision, + liveContentSeedGeneration, sessionHealthNotice, sessionHealthModelPickerAvailable, workspaceReadinessRecovery, @@ -139,20 +139,20 @@ export function ChatMessageSurface({ }; const liveTurns = useExternalStoreSelector(sessionUiController, selectLiveTurns, activeSessionId); const liveTurn = liveTurns?.find((turn) => turn.turnId === chatViewRest.activeTurn?.turnId) ?? liveTurns?.at(-1); - const seededLiveTurns = liveContentSeedRevision > 0 ? liveTurns : undefined; + const seededLiveTurns = liveContentSeedGeneration > 0 ? liveTurns : undefined; const [activation, setActivation] = useState(() => ({ sessionId: activeSessionId, - seedRevision: liveContentSeedRevision, - initialLiveContent: liveContentSeedRevision > 0 ? captureLiveContent(liveTurn) : undefined, + seedGeneration: liveContentSeedGeneration, + initialLiveContent: liveContentSeedGeneration > 0 ? captureLiveContent(liveTurn) : undefined, })); if ( activation.sessionId !== activeSessionId - || activation.seedRevision !== liveContentSeedRevision + || activation.seedGeneration !== liveContentSeedGeneration ) { setActivation({ sessionId: activeSessionId, - seedRevision: liveContentSeedRevision, - initialLiveContent: liveContentSeedRevision > 0 ? captureLiveContent(liveTurn) : undefined, + seedGeneration: liveContentSeedGeneration, + initialLiveContent: liveContentSeedGeneration > 0 ? captureLiveContent(liveTurn) : undefined, }); } else if ( activation.initialLiveContent @@ -162,7 +162,7 @@ export function ChatMessageSurface({ ) { setActivation({ sessionId: activeSessionId, - seedRevision: liveContentSeedRevision, + seedGeneration: liveContentSeedGeneration, initialLiveContent: undefined, }); } diff --git a/apps/desktop/src/renderer/features/conversation/controller/app-shell-queue-actions.ts b/apps/desktop/src/renderer/features/conversation/controller/app-shell-queue-actions.ts new file mode 100644 index 00000000000..eac4ff6f4f8 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/app-shell-queue-actions.ts @@ -0,0 +1,75 @@ +/* + * 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 type { MessageQueueEntryProjection } from "@maka/core/events"; + +interface AppShellQueueActionPorts { + activeSessionId(): string | undefined; + entries: readonly MessageQueueEntryProjection[]; + reportFailure(sessionId: string, error: unknown): void; + removeTransientMessage(sessionId: string, messageId: string): void; + retract(sessionId: string, entryId: string): Promise; + promote(sessionId: string, entryId: string): Promise; + update( + sessionId: string, + entryId: string, + expectedQueueRevision: number, + text: string, + ): Promise; + reorder( + sessionId: string, + entryIds: readonly string[], + expectedQueueRevision: number, + ): Promise; +} + +export function createAppShellQueueActions(ports: AppShellQueueActionPorts) { + async function run(action: (sessionId: string) => Promise): Promise { + const sessionId = ports.activeSessionId(); + if (!sessionId) return undefined; + try { + await action(sessionId); + return sessionId; + } catch (error) { + if (ports.activeSessionId() === sessionId) ports.reportFailure(sessionId, error); + throw error; + } + } + + return { + update(entryId: string, expectedQueueRevision: number, text: string): Promise { + return run((sessionId) => + ports.update(sessionId, entryId, expectedQueueRevision, text), + ).then(() => undefined); + }, + async retract(entryId: string): Promise { + const messageId = ports.entries.find((entry) => entry.entryId === entryId)?.messageId; + const sessionId = await run((activeSessionId) => ports.retract(activeSessionId, entryId)); + if (sessionId && messageId) ports.removeTransientMessage(sessionId, messageId); + }, + promote(entryId: string): Promise { + return run((sessionId) => ports.promote(sessionId, entryId)).then(() => undefined); + }, + reorder(entryIds: readonly string[], expectedQueueRevision: number): Promise { + return run((sessionId) => + ports.reorder(sessionId, entryIds, expectedQueueRevision), + ).then(() => undefined); + }, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-session-message-queue.ts b/apps/desktop/src/renderer/features/conversation/controller/use-session-message-queue.ts index 0b079b217df..381f924571d 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-session-message-queue.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-session-message-queue.ts @@ -63,7 +63,8 @@ export function useSessionMessageQueue(options: { promoteQueuedEntry: (entryId: string) => Promise; editQueuedEntry: (entry: Pick) => Promise; deleteQueuedEntry: (entryId: string) => Promise; - reorderQueuedEntries: (entryIds: readonly string[]) => Promise; + updateQueuedEntry: (entryId: string, expectedQueueRevision: number, text: string) => Promise; + reorderQueuedEntries: (entryIds: readonly string[], expectedQueueRevision?: number) => Promise; } { const { sessionId, queue, transientMessages, activeSessionId } = options; const services = useConversationServices(); @@ -90,7 +91,7 @@ export function useSessionMessageQueue(options: { draftContextRestorer.current?.(targetSessionId, draft); const handle = composer.current; if (!handle || !draft.text.trim()) return; - handle.appendDraft(targetSessionId, draft.text); + handle.appendDraft?.(targetSessionId, draft.text); }, []); // Surfaces the failure, then rethrows so the pending plate can settle its // in-flight action state without guessing with a timer. @@ -128,6 +129,19 @@ export function useSessionMessageQueue(options: { restoreDraft: (draft) => restoreDraft(targetSessionId, draft), })), deleteQueuedEntry: (entryId) => runAction((targetSessionId) => services.sessions.retractQueueEntry(targetSessionId, entryId)), - reorderQueuedEntries: (entryIds) => runAction((targetSessionId) => services.sessions.reorderQueueEntries(targetSessionId, entryIds)), + updateQueuedEntry: (entryId, expectedQueueRevision, text) => + services.sessions.updateQueueEntry + ? runAction((targetSessionId) => + services.sessions.updateQueueEntry!(targetSessionId, entryId, expectedQueueRevision, text), + ) + : Promise.reject(new Error('Message queue updates are unavailable')), + reorderQueuedEntries: (entryIds, expectedQueueRevision = queue?.queueRevision) => { + if (expectedQueueRevision === undefined) { + return Promise.reject(new Error('Message queue is not ready for reordering')); + } + return runAction((targetSessionId) => + services.sessions.reorderQueueEntries(targetSessionId, entryIds, expectedQueueRevision), + ); + }, }; } diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index e6852c0f005..e922e89efeb 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -74,6 +74,8 @@ export { activeHostTurn, chatTurnActivity } from '../../application/contracts/se export { selectLiveTurns, sessionUiSelectors } from './model/session-ui-selectors.js'; export { LiveTurnReconciler } from './controller/live-turn-reconciler.js'; export { sessionIdSetsEqual, type LiveTurnSnapshot } from './model/live-turn-snapshot.js'; +export { createAppShellQueueActions } from './controller/app-shell-queue-actions.js'; +export * from './model/observation-visibility.js'; export { useExecutorSelection } from './controller/use-executor-selection.js'; export * from './model/shell-chat-model-selection.js'; diff --git a/apps/desktop/src/renderer/features/conversation/model/observation-visibility.ts b/apps/desktop/src/renderer/features/conversation/model/observation-visibility.ts new file mode 100644 index 00000000000..95021f09fe1 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/observation-visibility.ts @@ -0,0 +1,84 @@ +/* + * 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. + */ + +export interface ObservationAuthority { + readonly sessionId?: string; + readonly profileId?: string; + readonly generation: number; +} + +export const INITIAL_OBSERVATION_AUTHORITY = Object.freeze({ + generation: 0, +}); + +export function reconcileObservationAuthority( + current: ObservationAuthority, + next: { readonly sessionId?: string; readonly profileId?: string }, +): ObservationAuthority { + if (current.sessionId !== next.sessionId) { + return { ...next, generation: current.generation + 1 }; + } + if (!next.profileId || next.profileId === current.profileId) return current; + if (!current.profileId) return { ...current, profileId: next.profileId }; + return { ...next, generation: current.generation + 1 }; +} + +export interface LiveContentSeedState { + readonly sessionId?: string; + readonly generation: number; + readonly revealed: boolean; +} + +export type LiveContentSeedToken = Readonly>>; + +export const INITIAL_LIVE_CONTENT_SEED = Object.freeze({ + generation: 0, + revealed: false, +}); + +export function beginLiveContentSeed( + current: LiveContentSeedState, + nextSessionId: string, +): { readonly state: LiveContentSeedState; readonly token: LiveContentSeedToken } { + const generation = current.generation + 1; + return { + state: { sessionId: nextSessionId, generation, revealed: false }, + token: { sessionId: nextSessionId, generation }, + }; +} + +export const ownsLiveContentSeed = ( + current: LiveContentSeedState, + token: LiveContentSeedToken, +): boolean => current.sessionId === token.sessionId && current.generation === token.generation; + +export function revealLiveContentSeed( + current: LiveContentSeedState, + token: LiveContentSeedToken, +): LiveContentSeedState { + return ownsLiveContentSeed(current, token) && !current.revealed + ? { ...current, revealed: true } + : current; +} + +export const visibleLiveContentGeneration = ( + current: LiveContentSeedState, + selectedSessionId?: string, +): number => + current.revealed && current.sessionId === selectedSessionId ? current.generation : 0; diff --git a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts index 6abec486add..97164c6529a 100644 --- a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts @@ -45,6 +45,7 @@ export interface AppShellSessionUiState { export interface MessageQueueUiState { readonly ts: number; + readonly queueRevision?: number; readonly entries: readonly MessageQueueEntryProjection[]; } diff --git a/apps/desktop/src/renderer/features/conversation/ports.ts b/apps/desktop/src/renderer/features/conversation/ports.ts index 0a029b65483..83ffa98476b 100644 --- a/apps/desktop/src/renderer/features/conversation/ports.ts +++ b/apps/desktop/src/renderer/features/conversation/ports.ts @@ -68,7 +68,12 @@ export interface ConversationServices extends Pick< readExecutionBoundary(sessionId: string): Promise; promoteQueueEntry(sessionId: string, entryId: string): Promise; retractQueueEntry(sessionId: string, entryId: string): Promise; - reorderQueueEntries(sessionId: string, entryIds: readonly string[]): Promise; + updateQueueEntry?(sessionId: string, entryId: string, expectedQueueRevision: number, text: string): Promise; + reorderQueueEntries( + sessionId: string, + entryIds: readonly string[], + expectedQueueRevision: number, + ): Promise; }; readonly runtimeHosts: { subscribeChanges(handler: (event: ConversationHostChange) => void): () => void; diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index e209e3a8f4e..fa5a65f722d 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -64,6 +64,7 @@ export function stubConversationServices( }, promoteQueueEntry: async () => undefined, retractQueueEntry: async () => undefined, + updateQueueEntry: async () => undefined, reorderQueueEntries: async () => undefined, ...sessions, }, diff --git a/apps/desktop/src/renderer/features/overlays/index.ts b/apps/desktop/src/renderer/features/overlays/index.ts index d0ca3716e71..c95213cdc2c 100644 --- a/apps/desktop/src/renderer/features/overlays/index.ts +++ b/apps/desktop/src/renderer/features/overlays/index.ts @@ -26,3 +26,8 @@ export { SearchModalHost } from './ui/search-modal-host.js'; export type { OverlaysServices } from './ports.js'; export type { Command } from './model/command.js'; export type { OverlaysShellProjection } from './model/overlays-projection.js'; +export { + createAgentGraphPanelModel, + reduceAgentGraphPanelModel, + shouldShowAgentGraphPanel, +} from './model/agent-graph-panel-model.js'; diff --git a/apps/desktop/src/renderer/features/overlays/model/agent-graph-panel-model.ts b/apps/desktop/src/renderer/features/overlays/model/agent-graph-panel-model.ts new file mode 100644 index 00000000000..22814e15a45 --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/model/agent-graph-panel-model.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +export { + createAgentGraphPanelModel, + reduceAgentGraphPanelModel, + shouldShowAgentGraphPanel, +} from '../../../application/contracts/agent-graph-panel-visibility.js'; diff --git a/apps/desktop/src/renderer/features/session-collaboration/controller/use-guest-turn-requests.ts b/apps/desktop/src/renderer/features/session-collaboration/controller/use-guest-turn-requests.ts index 9b40302e37e..7be7d3bf754 100644 --- a/apps/desktop/src/renderer/features/session-collaboration/controller/use-guest-turn-requests.ts +++ b/apps/desktop/src/renderer/features/session-collaboration/controller/use-guest-turn-requests.ts @@ -66,14 +66,15 @@ export function useGuestTurnRequests( const attempts = useRef(new Map()); const apply = useCallback( - (target: string, result: { canRequestTurns: boolean; requests: readonly SessionTurnAccessRequest[] }) => { - setProjection({ sessionId: target, canRequestTurns: result.canRequestTurns, authorityAvailable: true, requests: result.requests }); - const attempt = attempts.current.get(target); + (result: { canRequestTurns: boolean; requests: readonly SessionTurnAccessRequest[] }) => { + if (!sessionId) return undefined; + setProjection({ sessionId, canRequestTurns: result.canRequestTurns, authorityAvailable: true, requests: result.requests }); + const attempt = attempts.current.get(sessionId); if (!attempt || !result.requests.some((request) => request.intent.turnId === attempt.turnId)) return undefined; - attempts.current.delete(target); + attempts.current.delete(sessionId); return attempt; }, - [], + [sessionId], ); const markUnavailable = useCallback((target: string) => { @@ -95,8 +96,8 @@ export function useGuestTurnRequests( if (disposed) return; // A request whose response was lost can surface here later; its text // leaves the draft only if the user has not changed it since. - const settled = apply(sessionId, result); - if (settled) composerRef.current?.clearDraft(sessionId, settled.text); + const settled = apply(result); + if (settled) composerRef.current?.clearDraft(sessionId); } catch { if (!disposed) markUnavailable(sessionId); } finally { @@ -134,7 +135,7 @@ export function useGuestTurnRequests( markUnavailable(sessionId); return false; } - if (apply(sessionId, current)) { + if (apply(current)) { toast.success(copy.turnRequestSent); return true; } @@ -147,7 +148,7 @@ export function useGuestTurnRequests( if (!sessionId) return; try { if (withdraw && !(await services.withdrawTurnRequest(sessionId, requestId)).withdrawn) { - apply(sessionId, await services.getTurnRequests(sessionId)); + apply(await services.getTurnRequests(sessionId)); return; } if (!withdraw) await services.acknowledgeTurnRequest(sessionId, requestId); diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-list-layout.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-list-layout.ts index 107962f1f76..abe64aee121 100644 --- a/apps/desktop/src/renderer/features/session-navigation/model/session-list-layout.ts +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-list-layout.ts @@ -17,38 +17,46 @@ * under the License. */ -import type { SessionViewMode } from '@maka/ui'; import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-storage.js'; export const SESSION_LIST_EXPANDED_DEFAULT_WIDTH = 260; export const SESSION_LIST_EXPANDED_MIN_WIDTH = 180; export const SESSION_LIST_EXPANDED_MAX_WIDTH = 480; - +const SESSION_LIST_WIDTH_KEY = 'maka-chat-list-width-v1'; +const SESSION_LIST_COLLAPSED_KEY = 'maka-chat-list-collapsed-v1'; const SESSION_LIST_VIEW_MODE_KEY = 'maka-chat-list-view-mode-v1'; -export function readSessionListViewMode(): SessionViewMode { - const stored = safeLocalStorageGet(SESSION_LIST_VIEW_MODE_KEY); - if (stored === 'project' || stored === 'conversation') return stored; - return 'conversation'; -} - -export function writeSessionListViewMode(mode: SessionViewMode): void { - safeLocalStorageSet(SESSION_LIST_VIEW_MODE_KEY, mode); -} - export function readSessionListWidth(): number { - const stored = Number(safeLocalStorageGet('maka-chat-list-width-v1')); + const stored = Number(safeLocalStorageGet(SESSION_LIST_WIDTH_KEY)); if (Number.isFinite(stored) && stored > 0) return clampSessionListWidth(stored); return SESSION_LIST_EXPANDED_DEFAULT_WIDTH; } export function readSessionListCollapsed(): boolean { - const stored = safeLocalStorageGet('maka-chat-list-collapsed-v1'); + const stored = safeLocalStorageGet(SESSION_LIST_COLLAPSED_KEY); if (stored === 'false') return false; if (stored === 'true') return true; return true; } +export function readSessionListViewMode(): 'conversation' | 'project' { + return safeLocalStorageGet(SESSION_LIST_VIEW_MODE_KEY) === 'project' + ? 'project' + : 'conversation'; +} + +export function writeSessionListCollapsed(value: boolean): void { + safeLocalStorageSet(SESSION_LIST_COLLAPSED_KEY, value ? 'true' : 'false'); +} + +export function writeSessionListWidth(value: number): void { + safeLocalStorageSet(SESSION_LIST_WIDTH_KEY, String(value)); +} + +export function writeSessionListViewMode(value: 'conversation' | 'project'): void { + safeLocalStorageSet(SESSION_LIST_VIEW_MODE_KEY, value); +} + function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts index c6793a8a337..c38cf84096a 100644 --- a/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts @@ -19,7 +19,6 @@ import type { SideNavImperativeCollapseHandle } from '@astryxdesign/core/SideNav'; import type { SessionViewMode } from '@maka/ui'; -import { safeLocalStorageSet } from '../../../browser-storage.js'; import { createObservableState } from '../../../application/contracts/session-catalog/observable-state.js'; import { clampSessionListWidth, @@ -27,7 +26,9 @@ import { readSessionListViewMode, readSessionListWidth, SESSION_LIST_EXPANDED_MIN_WIDTH, + writeSessionListCollapsed, writeSessionListViewMode, + writeSessionListWidth, } from './session-list-layout.js'; const LAYOUT_PERSIST_DEBOUNCE_MS = 200; @@ -69,7 +70,7 @@ export function createSessionRailLayoutStore() { const current = state.getState(); if (current.collapsed === next) return; state.replaceState({ ...current, collapsed: next }); - safeLocalStorageSet('maka-chat-list-collapsed-v1', next ? 'true' : 'false'); + writeSessionListCollapsed(next); }, /** Debounced: a drag reports a width per frame and only the last one is worth storing. */ setWidth(next: number): void { @@ -87,7 +88,7 @@ export function createSessionRailLayoutStore() { state.replaceState({ ...current, width }); if (widthPersistHandle !== undefined) clearTimeout(widthPersistHandle); widthPersistHandle = setTimeout(() => { - safeLocalStorageSet('maka-chat-list-width-v1', String(width)); + writeSessionListWidth(width); }, LAYOUT_PERSIST_DEBOUNCE_MS); }, setViewMode(next: SessionViewMode): void { diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts index 21c3d167cd9..1ccb48f5c06 100644 --- a/apps/desktop/src/renderer/features/session-navigation/testing.ts +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -53,9 +53,7 @@ export { type SessionSelection, } from './model/session-selection.js'; export { - readSessionListViewMode, SESSION_LIST_EXPANDED_DEFAULT_WIDTH, - writeSessionListViewMode, } from './model/session-list-layout.js'; export { createSessionRailLayoutStore } from './model/session-rail-layout-store.js'; diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index d8d79917f5e..440d36ea294 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -252,7 +252,7 @@ export interface SideChatSessionPort { ): Promise; retractQueueEntry(sessionId: string, entryId: string): Promise; promoteQueueEntry(sessionId: string, entryId: string): Promise; - reorderQueueEntries(sessionId: string, entryIds: readonly string[]): Promise; + reorderQueueEntries(sessionId: string, entryIds: readonly string[], expectedQueueRevision: number): Promise; setPermissionMode( sessionId: string, mode: PermissionMode, diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index dc6a99ac567..208c36bb4da 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -352,6 +352,7 @@ export function QuoteCompanionPanel(props: { streaming={companion.streaming} processing={companion.processing} queuedMessages={companion.queuedMessages} + queuedMessageRevision={companion.queuedMessageRevision} pendingMessages={companion.transientMessages} onPromoteQueuedEntry={companion.promoteQueuedEntry} onEditQueuedEntry={companion.editQueuedEntry} diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 0803381ee8f..14c80d7259f 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -189,6 +189,8 @@ export interface UseQuoteCompanionResult { transientMessages: readonly TransientUserMessageProjection[]; /** Host-authoritative pending steering and follow-up messages. */ queuedMessages: readonly MessageQueueEntryProjection[]; + /** Revision fence for Host-authoritative queue mutations. */ + queuedMessageRevision: number | undefined; liveTurns: LiveTurnBuffer | undefined; activeTurn: ReturnType; streaming: boolean; @@ -229,7 +231,10 @@ export interface UseQuoteCompanionResult { promoteQueuedEntry: (entryId: string) => Promise; editQueuedEntry: (entry: Pick) => Promise; deleteQueuedEntry: (entryId: string) => Promise; - reorderQueuedEntries: (entryIds: readonly string[]) => Promise; + reorderQueuedEntries: ( + entryIds: readonly string[], + expectedQueueRevision: number, + ) => Promise; setPermissionMode: (mode: PermissionMode) => Promise; stop: () => Promise; respondToSandboxBoundary: (response: SandboxBoundaryResponse) => Promise; @@ -354,6 +359,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ); const [messageQueue, setMessageQueue] = useState<{ readonly entries: readonly MessageQueueEntryProjection[]; + readonly queueRevision?: number; readonly ts?: number; }>({ entries: [] }); // Reseed reconciliation reads the queue between React flushes, so every @@ -1639,8 +1645,9 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan [dropOptimisticUserMessage, messageQueue.entries, runQueueEntryAction, sideChat], ); const reorderQueuedEntries = useCallback( - (entryIds: readonly string[]) => - runQueueEntryAction((id) => sideChat.reorderQueueEntries(id, entryIds)), + (entryIds: readonly string[], expectedQueueRevision: number) => + runQueueEntryAction((id) => + sideChat.reorderQueueEntries(id, entryIds, expectedQueueRevision)), [runQueueEntryAction, sideChat], ); @@ -1766,6 +1773,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan messages, transientMessages, queuedMessages: messageQueue.entries, + queuedMessageRevision: messageQueue.queueRevision, liveTurns, activeTurn: chatTurnActivity(execution), streaming, diff --git a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts index 2086c721d9a..4e7cfb31bc8 100644 --- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts +++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts @@ -64,7 +64,7 @@ interface SendAttempt { } interface MessagePresentation { transientMessages: TransientUserMessageProjection[]; - messageQueue: { entries: readonly MessageQueueEntryProjection[]; ts?: number }; + messageQueue: { entries: readonly MessageQueueEntryProjection[]; ts?: number; queueRevision?: number }; } export function useWorkHubController( onSubmit: (() => void) | undefined, @@ -745,9 +745,13 @@ export function useWorkHubController( editQueuedEntry: queuedEntryDraft ? (entry: Pick) => retractQueuedEntryToDraft(entry, queuedEntryDraft) : undefined, + updateQueuedEntry: (entryId: string, expectedQueueRevision: number, text: string) => + mutateQueue((target) => services.updateQueueEntry(target, entryId, expectedQueueRevision, text)), deleteQueuedEntry, promoteQueuedEntry: (entryId: string) => mutateQueue((target) => services.promoteQueueEntry(target, entryId)), - reorderQueuedEntries: (entryIds: readonly string[]) => mutateQueue((target) => services.reorderQueueEntries(target, entryIds)), + reorderQueuedEntries: (entryIds: readonly string[], expectedQueueRevision: number) => + mutateQueue((target) => + services.reorderQueueEntries(target, entryIds, expectedQueueRevision)), viewportNavigation, liveTurn, liveTurns, diff --git a/apps/desktop/src/renderer/features/workhub/ports.ts b/apps/desktop/src/renderer/features/workhub/ports.ts index f2f0d8cceed..d5efe30f223 100644 --- a/apps/desktop/src/renderer/features/workhub/ports.ts +++ b/apps/desktop/src/renderer/features/workhub/ports.ts @@ -67,7 +67,8 @@ export interface WorkHubServices extends WorkHubWorkspaceServices { queryMessageExecutions(sessionId: string, messageIds: readonly string[]): Promise; retractQueueEntry(sessionId: string, entryId: string): Promise; promoteQueueEntry(sessionId: string, entryId: string): Promise; - reorderQueueEntries(sessionId: string, entryIds: readonly string[]): Promise; + updateQueueEntry(sessionId: string, entryId: string, expectedQueueRevision: number, text: string): Promise; + reorderQueueEntries(sessionId: string, entryIds: readonly string[], expectedQueueRevision: number): Promise; configureModel( sessionId: string, input: OperationInput<'workhub.coordination.configureModel'>, diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx index 186481a2083..276872f235b 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx @@ -24,6 +24,7 @@ import { useUiLocale, type ComposerHandle, type ComposerProps, + type ComposerMessageQueueHostProps, } from '@maka/ui'; import { useComposerAttachments } from '@maka/ui/use-composer-attachments'; import { toSubmittedAttachments } from '@maka/ui/composer-attachments'; @@ -35,7 +36,7 @@ import type { RestoredDraftContent } from '../../../application/contracts/transi import { useWorkHubServices } from '../services.js'; import { workHubLiveCopy } from '../locales/workhub-live-copy.js'; -export type WorkHubComposerProps = Omit & { +export type WorkHubComposerProps = Omit & ComposerMessageQueueHostProps & { sessionId?: string; onSend(text: string, attachments: AttachmentRef[], followUpMode?: FollowUpMode): Promise; /** @@ -86,7 +87,7 @@ export const WorkHubComposer = forwardRef( if (draft.attachments?.length) staged.restoreAttachments(key, draft.attachments); const handle = self.current; if (!handle || !draft.text.trim()) return; - handle.appendDraft(key, draft.text); + handle.appendDraft?.(key, draft.text); }; } return profile hydration is not a - * new authority and must not tear down the ready stream. A known profile - * changing to another known profile is a real authority handoff and does need - * a fresh observation. - */ -export function advanceSessionObservationAuthority( - current: SessionObservationAuthority, - sessionId: string | undefined, - profileId: string | undefined, -): SessionObservationAuthority { - if (current.sessionId !== sessionId) { - return { sessionId, profileId, revision: current.revision + 1 }; - } - if (profileId === undefined || profileId === current.profileId) return current; - if (current.profileId === undefined) return { ...current, profileId }; - return { sessionId, profileId, revision: current.revision + 1 }; -} - -export const EMPTY_LIVE_CONTENT_SEED: LiveContentSeed = { - sessionId: undefined, - generation: 0, - readyGeneration: 0, -}; - -export function beginLiveContentSeed( - current: LiveContentSeed, - sessionId: string, -): LiveContentSeed { - return { - sessionId, - generation: current.generation + 1, - readyGeneration: 0, - }; -} - -export function completeLiveContentSeed( - current: LiveContentSeed, - sessionId: string, -): LiveContentSeed { - if (current.sessionId !== sessionId) { - return current; - } - return { - sessionId, - generation: current.generation, - readyGeneration: current.generation, - }; -} - -export function liveContentSeedRevision( - seed: LiveContentSeed, - activeSessionId: string | undefined, -): number { - if ( - !activeSessionId - || seed.sessionId !== activeSessionId - || seed.generation === 0 - || seed.readyGeneration !== seed.generation - ) { - return 0; - } - return seed.generation; -} diff --git a/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts b/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts index 1db77113328..ff8520f3730 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts @@ -54,7 +54,13 @@ export function createDesktopConversationServices( return () => window.removeEventListener('storage', onStorage); }, }, - sessions: bridge.sessions, + sessions: { + ...bridge.sessions, + updateQueueEntry: (sessionId: string, entryId: string, expectedQueueRevision: number, text: string) => + bridge.sessions.updateQueueEntry(sessionId, entryId, expectedQueueRevision, text), + reorderQueueEntries: (sessionId: string, entryIds: readonly string[], expectedQueueRevision: number) => + bridge.sessions.reorderQueueEntries(sessionId, entryIds, expectedQueueRevision), + }, runtimeHosts: { subscribeChanges: (handler) => bridge.runtimeHostProfiles.subscribeChanges(handler), }, diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index c6554dd9453..319fdb82e5a 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -205,8 +205,8 @@ export function createDesktopWorkbarServices( bridge.sessions.retractQueueEntry(sessionId, entryId), promoteQueueEntry: (sessionId, entryId) => bridge.sessions.promoteQueueEntry(sessionId, entryId), - reorderQueueEntries: (sessionId, entryIds) => - bridge.sessions.reorderQueueEntries(sessionId, entryIds), + reorderQueueEntries: (sessionId, entryIds, revision) => + bridge.sessions.reorderQueueEntries(sessionId, entryIds, revision), setPermissionMode: async (sessionId, mode) => expectSessionUpdate(await bridge.sessions.setPermissionMode(sessionId, mode)), respondToSandboxBoundary: (sessionId, response) => diff --git a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts index 33c66b8f73a..909c92a7056 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts @@ -114,7 +114,8 @@ export function createDesktopWorkHubServices( bridge.sessions.queryMessageExecutions(sessionId, messageIds), retractQueueEntry: (sessionId, entryId) => bridge.sessions.retractQueueEntry(sessionId, entryId), promoteQueueEntry: (sessionId, entryId) => bridge.sessions.promoteQueueEntry(sessionId, entryId), - reorderQueueEntries: (sessionId, entryIds) => bridge.sessions.reorderQueueEntries(sessionId, entryIds), + updateQueueEntry: (sessionId, entryId, revision, text) => bridge.sessions.updateQueueEntry(sessionId, entryId, revision, text), + reorderQueueEntries: (sessionId, entryIds, revision) => bridge.sessions.reorderQueueEntries(sessionId, entryIds, revision), configureModel: (sessionId, input) => bridge.workHub.configureModel(sessionId, input), getNewWorkDefaults: (sessionId) => bridge.workHub.getNewWorkDefaults(sessionId), setNewWorkDefaults: (sessionId, defaults) => diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 3059b4e8184..ddabbaee9f1 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -57,6 +57,7 @@ @import "./styles/hero.css" layer(components); @import "./styles/chat-message.css" layer(components); @import "./styles/composer.css" layer(components); +@import "./styles/message-queue.css" layer(components); @import "./styles/composer-mention.css" layer(components); @import "./styles/workbar/shell.css" layer(components); @import "./styles/workbar/review.css" layer(components); diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index 6549f9172f4..aec0e177582 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -18,15 +18,6 @@ */ /* Composer — outer container, controls, pickers, tool buttons, send/workspace/branch/streaming. Composer block merged in from tool-output.css (was misplaced there) — issue #546 PR3. Runtime interaction prompt styles live in interaction-prompts.css. */ -.maka-composer[data-drag-active="true"] .maka-composer-astryx { - border-color: oklch(from var(--focus-ring) l c h / 0.46); - background: oklch(from var(--focus-ring) 0.98 calc(c * 0.20) h / 0.35); - box-shadow: - 0 0 0 1px oklch(from var(--focus-ring) l c h / 0.22), - 0 0 0 4px oklch(from var(--focus-ring) l c h / 0.08), - 0 1px 3px oklch(from var(--foreground) l c h / 0.04); -} - .composer { flex: 0 0 auto; display: flex; @@ -86,33 +77,33 @@ } .maka-composer-queue { - width: 100%; - min-width: 0; - padding-bottom: var(--space-1); -} - -.maka-composer-queue-list li { - padding-right: 0; -} - -.maka-composer-drawer-divider { - width: calc(100% - (2 * var(--space-3))); - border-top: var(--border-width-hairline) solid var(--border); - margin: var(--space-0-5) var(--space-3); + box-sizing: border-box; + width: min(var(--maka-reading-measure), calc(100% - 2 * var(--space-6))); + max-width: var(--maka-reading-measure); + margin-inline: auto; + margin-block-end: var(--space-1); + padding-block: var(--space-1); + padding-inline: var(--space-1); + border-radius: var(--radius-chat, 8px); + background: var(--background-elevated); + box-shadow: inset 0 0 0 var(--border-width-hairline) var(--border); + --_button-radius: max( + var(--radius-element), + calc(var(--radius-chat) - var(--spacing-3)) + ); } .maka-composer-queue-list { - width: 100%; min-width: 0; max-height: 132px; - overflow-y: auto; + overflow: hidden auto; } .maka-composer-queue-text { display: block; min-width: 0; - overflow: hidden; white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; } @@ -123,26 +114,24 @@ flex: 0 0 auto; } -.maka-composer-queue-grip { - display: grid; - place-items: center; - width: 16px; - color: var(--muted-foreground); - cursor: grab; - opacity: 0; - transition: opacity 120ms ease; -} - -.maka-composer-queue-list li:hover .maka-composer-queue-grip, -.maka-composer-queue-grip:focus-visible { - opacity: 1; -} - -.maka-composer-queue-grip:active { - cursor: grabbing; +.maka-composer-queue-edit { + width: 100%; + min-width: 0; + border: 0; + border-block-end: var(--border-width-hairline) solid var(--border-strong); + outline: 0; + background: transparent; + color: inherit; + font: inherit; + line-height: inherit; + resize: vertical; } @media (max-width: 640px) { + .maka-composer-queue { + width: min(var(--maka-reading-measure), calc(100% - 2 * var(--space-3))); + } + .maka-composer-queue-actions { gap: 0; } @@ -684,6 +673,22 @@ gap: var(--space-3); } +.maka-composer-queue-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-1) var(--space-2); + color: var(--muted-foreground); + font-size: 11px; + white-space: nowrap; +} + +.maka-composer-queue-delivery { + font-size: 11px; + color: var(--muted-foreground); +} + /* Share a grid cell so a wrapping suggestion reserves exactly the same height as the accepted draft, without measuring layout or mutating the editor. */ .maka-composer-input-suggestion-wrap { display: grid; min-width: 0; } diff --git a/apps/desktop/src/renderer/styles/message-queue.css b/apps/desktop/src/renderer/styles/message-queue.css new file mode 100644 index 00000000000..efd8ae98fa4 --- /dev/null +++ b/apps/desktop/src/renderer/styles/message-queue.css @@ -0,0 +1,38 @@ +/* + * 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. + */ + +.maka-composer-queue-grip { + display: grid; + align-items: center; + justify-items: center; + inline-size: 16px; + flex: 0 0 auto; + color: var(--muted-foreground); + cursor: grab; + opacity: 0; + transition: opacity 120ms ease-out; +} + +.maka-composer-queue-list li:is(:hover, :focus-within) .maka-composer-queue-grip { + opacity: 1; +} + +.maka-composer-queue-grip:active { + cursor: grabbing; +} diff --git a/apps/desktop/stories/agent-graph-panel.stories.tsx b/apps/desktop/stories/agent-graph-panel.stories.tsx index dcae4eed153..e5c28b46efc 100644 --- a/apps/desktop/stories/agent-graph-panel.stories.tsx +++ b/apps/desktop/stories/agent-graph-panel.stories.tsx @@ -32,8 +32,8 @@ import { withScopedMakaBridge } from './maka-bridge'; // // Real host: app-shell.tsx mounts in the conversation column // when the active session runs in `graph` orchestration mode. The panel reads -// its snapshot from `window.maka.graphs` itself (not props or context), so each -// story installs a scoped bridge that serves one pinned snapshot rather than +// its snapshot through the explicit backend port, so each story installs a +// scoped bridge that serves one pinned snapshot rather than // driving a live graph. The panel renders operators as a flat list — it draws // no edges or hierarchy — so tree depth and cycles have no distinct rendering // and are not enumerated here. diff --git a/apps/desktop/stories/composer-message-queue.stories.tsx b/apps/desktop/stories/composer-message-queue.stories.tsx deleted file mode 100644 index f673536bb4c..00000000000 --- a/apps/desktop/stories/composer-message-queue.stories.tsx +++ /dev/null @@ -1,325 +0,0 @@ -/* - * 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 { useCallback, useMemo, useRef, useState } from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, userEvent, waitFor, within } from 'storybook/test'; -import type { ComponentProps, ReactElement, ReactNode } from 'react'; -import type { MessageQueueEntryProjection } from '@maka/core/events'; -import type { SessionSummary } from '@maka/core/session'; -import { Composer } from '@maka/ui'; -import type { ChatModelChoice, ComposerHandle, TransientUserMessageProjection } from '@maka/ui'; -import { - ConversationServicesProvider, - SessionLocalMessages, - type ConversationServices, -} from '../src/renderer/features/conversation'; -import { stubConversationServices } from '../src/renderer/features/conversation/testing'; -import type { DesktopLocalMessage } from '../src/shared/session-local-contract.js'; - -const NOW = Date.UTC(2026, 6, 1, 9, 30, 0); -const SESSION_ID = 's'; - -type DeliveryState = 'queued' | 'unconfirmed' | 'failed'; - -function localDeliveryMessages(state: DeliveryState): DesktopLocalMessage[] { - const base = { - sessionId: SESSION_ID, - attachments: [], - inlineReferences: [], - placement: 'next_turn' as const, - }; - if (state === 'unconfirmed') { - return [ - { - ...base, - messageId: 'local-unconfirmed', - createdAt: NOW - 60_000, - state: 'unknown', - canCancel: false, - text: 'Check whether the queue drained before retrying the compaction audit.', - error: 'Connection dropped before the receipt arrived', - }, - { - ...base, - messageId: 'local-waiting', - createdAt: NOW - 30_000, - state: 'saved', - canCancel: true, - text: 'Summarize the retry budget for the pending follow-ups.', - }, - ]; - } - if (state === 'failed') { - return [ - { - ...base, - messageId: 'local-failed', - createdAt: NOW - 60_000, - state: 'failed', - canCancel: true, - text: 'Attach the runtime.sqlite compaction log to the report.', - error: 'Message preparation failed. The local copy is retained.', - }, - ]; - } - return []; -} - -function localDeliveryServices(state: DeliveryState): ConversationServices { - let messages = localDeliveryMessages(state); - return stubConversationServices({ - listMessages: async () => messages, - cancelMessage: async (_sessionId, messageId) => { - messages = messages.filter((message) => message.messageId !== messageId); - }, - }); -} - -const meta = { - title: 'Product/Composer Message Queue', - component: QueuedComposer, - parameters: { layout: 'fullscreen' }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; -type ComposerProps = ComponentProps; - -function noop() { - return undefined; -} - -function session(): SessionSummary { - return { - id: 's', - name: '排查 Context summary failed', - isFlagged: false, - isArchived: false, - labels: [], - hasUnread: false, - lastMessageAt: NOW, - lastMessagePreview: '查一下 PR #3526 相关的 session。', - status: 'running', - backend: 'ai-sdk', - llmConnectionId: 'connection-anthropic-main', - llmConnectionSlug: 'anthropic-main', - connectionLocked: false, - model: 'claude-sonnet-4-5', - permissionMode: 'ask', - }; -} - -const modelChoices: ChatModelChoice[] = [ - { - connectionId: 'connection-anthropic-main', - connectionSlug: 'anthropic-main', - providerType: 'anthropic', - providerLabel: 'Anthropic', - model: 'claude-sonnet-4-5', - label: 'Claude Sonnet 4.5', - isDefault: true, - thinkingLevels: [], - }, -]; - -function followUpEntry(entryId: string, text: string): MessageQueueEntryProjection { - return { - entryId, - messageId: `message-${entryId}`, - content: { text }, - placement: 'next_turn', - state: 'queued', - }; -} - -/** - * Local stand-in for the Runtime Host queue projection: promote hands an - * entry to the active Turn (it leaves the plate), edit retracts it back into - * the draft, retract drops it, and reorder applies the drag order. The component contract - * (projection in, mutations - * out) is the real one; only the authority is simulated. - */ -// A production queue snapshot also carries queued steering; the drawer filters -// it out — steering renders in the transcript instead (see the -// QueuedMessageLifecycleFlow story in app-shell). -const DEFAULT_QUEUE: MessageQueueEntryProjection[] = [ - { - entryId: 'entry-steer', - messageId: 'message-steer', - content: { text: '先把刚才的判断改成只检查当前工作树。' }, - placement: 'current_turn', - state: 'queued', - }, - followUpEntry('entry-1', '先不要改协议。'), - followUpEntry('entry-2', '查一下 PR #3526 相关的 session 及其 compaction 诊断记录。'), - followUpEntry('entry-3', '把 runtime.sqlite 里的 compaction 日志也带上。'), -]; - -function QueuedComposer({ - deliveryState, - stagedContext = true, - entries = DEFAULT_QUEUE, -}: { - deliveryState: DeliveryState; - stagedContext?: boolean; - entries?: MessageQueueEntryProjection[]; -}) { - const composerRef = useRef(null); - const [followup, setFollowup] = useState(entries); - - const base: ComposerProps = { - draftKey: 'storybook-composer-queue', - onSend: noop, - onStop: noop, - modelLabel: 'K3-256k', - activeSession: session(), - activeModel: 'claude-sonnet-4-5', - activeModelLabel: 'K3-256k', - modelChoices, - permissionMode: 'ask', - onPermissionModeChange: noop, - onPickAttachments: noop, - streaming: true, - // Real mid-turn state can stage context while follow-ups wait: the two - // sections stack inside the one staging drawer. - pendingQuotes: stagedContext - ? [{ text: 'queue.entries 表在 Host 端是唯一权威', label: '设计笔记' }] - : undefined, - pendingAttachments: stagedContext - ? [{ kind: 'other', displayName: 'compaction-audit.md', mimeType: 'text/markdown', size: 12_400 }] - : undefined, - }; - - const [published, setPublished] = useState(new Map()); - const publish = useCallback((_sessionId: string, message: TransientUserMessageProjection) => { - setPublished((current) => new Map(current).set(message.id, message)); - }, []); - const retire = useCallback((_sessionId: string, messageId: string) => { - setPublished((current) => { - const next = new Map(current); - next.delete(messageId); - return next; - }); - }, []); - const services = useMemo(() => localDeliveryServices(deliveryState), [deliveryState]); - - return ( - - { - composerRef.current?.setText(draft.text); - composerRef.current?.focus(); - }} - /> - { - setFollowup((current) => current.filter((candidate) => candidate.entryId !== entryId)); - }} - onEditQueuedEntry={(entry) => { - setFollowup((current) => current.filter((candidate) => candidate.entryId !== entry.entryId)); - composerRef.current?.setText(entry.content.displayText ?? entry.content.text); - }} - onDeleteQueuedEntry={(entryId) => { - setFollowup((current) => current.filter((candidate) => candidate.entryId !== entryId)); - }} - onReorderQueuedEntries={(entryIds) => { - setFollowup((current) => - entryIds.flatMap((entryId) => - current.filter((candidate) => candidate.entryId === entryId), - ), - ); - }} - /> - - ); -} - -function storyFrame(children: ReactNode): ReactElement { - return
{children}
; -} - -// Real path: mid-turn sends while a quote and a file are staged — the staging -// drawer holds the queue section above a hairline and the context chips below. -// Drag follow-ups to reorder; 直接发送 promotes one, 编辑 takes it back into the draft. -export const PendingPlate: Story = { - args: { deliveryState: 'queued' }, - argTypes: { - deliveryState: { - options: ['queued', 'unconfirmed', 'failed'], - control: { type: 'radio' }, - }, - }, - render: (args) => - storyFrame( - , - ), -}; - -// Real path: the user folds the staging slab; every staged item collapses into -// the count strip until it is reopened. -export const StagingCollapsed: Story = { - args: { deliveryState: 'queued' }, - render: () => storyFrame(), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole('button', { name: /收起|collapse/i })); - await waitFor(() => { - // Collapsed content stays mounted under the grid-row collapse — assert on - // the disclosure state, not DOM removal. - const toggle = canvas.getByRole('button', { name: /展开|expand/i }); - expect(toggle).toHaveAttribute('aria-expanded', 'false'); - expect(toggle).toHaveTextContent(/5|附加内容|staged/i); - }); - }, -}; - -// Real path: a long Turn while the user keeps queueing — the list scrolls -// inside the drawer instead of growing the composer. -export const OverflowingQueue: Story = { - args: { deliveryState: 'queued' }, - render: () => - storyFrame( - - followUpEntry(`entry-${index + 1}`, `排队跟进 ${index + 1}:检查 compaction 诊断记录的第 ${index + 1} 段。`), - )} - />, - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const list = canvasElement.querySelector('.maka-composer-queue-list'); - await waitFor(() => { - expect(list).not.toBeNull(); - expect(list!.scrollHeight).toBeGreaterThan(list!.clientHeight); - }); - await expect(canvas.getAllByText(/^排队跟进/).length).toBe(9); - }, -}; diff --git a/apps/desktop/stories/message-queue.stories.tsx b/apps/desktop/stories/message-queue.stories.tsx new file mode 100644 index 00000000000..21d0524c607 --- /dev/null +++ b/apps/desktop/stories/message-queue.stories.tsx @@ -0,0 +1,155 @@ +/* + * 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 { useReducer } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { MessageQueueEntryProjection } from "@maka/core/events"; +import type { SessionSummary } from "@maka/core/session"; +import { Composer, type ChatModelChoice } from "@maka/ui"; + +interface QueueStoryState { + readonly entries: readonly MessageQueueEntryProjection[]; + readonly revision: number; +} + +type QueueStoryAction = + | { readonly kind: "delete"; readonly entryId: string } + | { readonly kind: "edit"; readonly entryId: string; readonly text: string } + | { readonly kind: "promote"; readonly entryId: string } + | { readonly kind: "reorder"; readonly entryIds: readonly string[] }; + +const STORY_SESSION: SessionSummary = { + id: "queue-story-session", + name: "Investigate context compaction", + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + lastMessageAt: Date.UTC(2026, 8, 27, 9, 30), + lastMessagePreview: "Inspect the session diagnostics for PR #3526.", + status: "running", + backend: "ai-sdk", + llmConnectionId: "connection-anthropic-main", + llmConnectionSlug: "anthropic-main", + connectionLocked: false, + model: "claude-sonnet-4-5", + permissionMode: "ask", +}; + +const STORY_MODELS: readonly ChatModelChoice[] = [{ + connectionId: "connection-anthropic-main", + connectionSlug: "anthropic-main", + providerType: "anthropic", + providerLabel: "Anthropic", + model: "claude-sonnet-4-5", + label: "Claude Sonnet 4.5", + isDefault: true, + thinkingLevels: [], +}]; + +const INITIAL_STATE: QueueStoryState = { + revision: 4, + entries: [ + queued("steer", "Limit the investigation to the current worktree.", "current_turn"), + queued("logs", "Inspect the compaction diagnostics for PR #3526."), + queued("database", "Include the runtime.sqlite compaction records."), + queued("summary", "Summarize the evidence before changing the protocol."), + ], +}; + +function queued( + suffix: string, + text: string, + placement: MessageQueueEntryProjection["placement"] = "next_turn", +): MessageQueueEntryProjection { + return { + entryId: `entry-${suffix}`, + messageId: `message-${suffix}`, + content: { text }, + placement, + state: "queued", + }; +} + +function updateStoryQueue(state: QueueStoryState, action: QueueStoryAction): QueueStoryState { + let entries: readonly MessageQueueEntryProjection[]; + switch (action.kind) { + case "delete": + entries = state.entries.filter((entry) => entry.entryId !== action.entryId); + break; + case "edit": + entries = state.entries.map((entry) => entry.entryId === action.entryId + ? { ...entry, content: { ...entry.content, text: action.text, displayText: action.text } } + : entry); + break; + case "promote": + entries = state.entries.map((entry) => entry.entryId === action.entryId + ? { ...entry, placement: "current_turn" as const } + : entry); + break; + case "reorder": { + const positions = new Map(action.entryIds.map((entryId, index) => [entryId, index])); + const reordered = state.entries + .filter((entry) => positions.has(entry.entryId)) + .sort((left, right) => positions.get(left.entryId)! - positions.get(right.entryId)!); + let cursor = 0; + entries = state.entries.map((entry) => positions.has(entry.entryId) ? reordered[cursor++]! : entry); + break; + } + } + return { entries, revision: state.revision + 1 }; +} + +function InteractiveMessageQueue() { + const [state, dispatch] = useReducer(updateStoryQueue, INITIAL_STATE); + return ( +
+ undefined} + onStop={() => undefined} + modelLabel="K3-256k" + activeSession={STORY_SESSION} + activeModel="claude-sonnet-4-5" + activeModelLabel="K3-256k" + modelChoices={[...STORY_MODELS]} + permissionMode="ask" + onPermissionModeChange={() => undefined} + onPickAttachments={() => undefined} + streaming + queuedMessages={state.entries} + queuedMessageRevision={state.revision} + onPromoteQueuedEntry={(entryId) => dispatch({ kind: "promote", entryId })} + onUpdateQueuedEntry={(entryId, _revision, text) => dispatch({ kind: "edit", entryId, text })} + onDeleteQueuedEntry={(entryId) => dispatch({ kind: "delete", entryId })} + onReorderQueuedEntries={(entryIds) => dispatch({ kind: "reorder", entryIds })} + /> +
+ ); +} + +const meta = { + title: "Product/Message Queue", + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const InteractiveQueue: Story = { render: () => }; diff --git a/apps/desktop/stories/shared-session-guest.stories.tsx b/apps/desktop/stories/shared-session-guest.stories.tsx index 8702b2b39d1..b6d9f72018e 100644 --- a/apps/desktop/stories/shared-session-guest.stories.tsx +++ b/apps/desktop/stories/shared-session-guest.stories.tsx @@ -221,7 +221,7 @@ export const RequestQueue: Story = { const rows = [...canvasElement.querySelectorAll('.maka-composer-queue-text')].map((row) => row.textContent); expect(rows).toEqual(['请检查这个连接恢复方案,并给出可以直接执行的修复建议。', '重新生成回答']); }); - const labels = [...canvasElement.querySelectorAll('.maka-composer-queue button[aria-label]')].map((button) => button.getAttribute('aria-label')); + const labels = [...canvasElement.querySelectorAll('.maka-composer-queue-actions button[aria-label]')].map((button) => button.getAttribute('aria-label')); await expect(labels).toEqual(['撤回', '关闭']); }, }; diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index a30a0280ee9..bbf57c78fd4 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -101,7 +101,7 @@ function makeServices(failFirst: boolean, withHistory: boolean | 'usage', colore subscribeSessionEvents: () => () => {}, subscribeUsageChanges: () => () => {}, }, - retractQueueEntry: async () => {}, promoteQueueEntry: async () => {}, + retractQueueEntry: async () => {}, promoteQueueEntry: async () => {}, updateQueueEntry: async () => {}, reorderQueueEntries: async () => {}, enqueueMessage: async () => 'admitted', queryMessageExecutions: async () => ({ resolutions: [] }), diff --git a/docs/architecture/bot-onboarding-runtime.zh-CN.md b/docs/architecture/bot-onboarding-runtime.zh-CN.md index 0a80efff4b0..0218a0f361d 100644 --- a/docs/architecture/bot-onboarding-runtime.zh-CN.md +++ b/docs/architecture/bot-onboarding-runtime.zh-CN.md @@ -122,11 +122,11 @@ Snapshot 字段固定为: - `sessionId`, `provider`, optional `brand` - `state` -- optional QR data URL、expiry、next poll delay +- optional QR data URL、expiry、next poll delay,以及短暂轮询故障期间的 `retryHealth` - `canOpenInBrowser` - optional non-sensitive identity `{ id, displayName }` -- redacted user-facing error -- optional `warning`:凭证已落盘,但 live bridge 未在 commit window 内达到 running/healthy;不是 onboarding 失败,也不携带 credential +- redacted user-facing `error`;终态错误可附带稳定的 `errorCode` +- optional `warningCode: 'saved_not_connected'` 与脱敏的 `warningDetail`:凭证已落盘,但 live bridge 未在 commit window 内达到 running/healthy;不是 onboarding 失败,也不携带 credential `poll()` 对同一 session 的并发调用共享一个 in-flight promise,避免 duplicate provider polling。新建同 provider session 会 abort 旧 session;不同 provider 可以独立进行。 @@ -138,7 +138,7 @@ confirmed 后执行一个受 current-session fence 保护的 commit sequence: 2. 把 provider credential 映射为 channel patch,并设置 `enabled: true`、`readiness: configured`。 3. 原子写入 settings。 4. 调用 `applySettingsRuntimeEffects`,让 `BotRegistry` reconcile channel。 -5. 读取 runtime identity,并把非敏感字段投影给 renderer。bridge 未 running 时快照带 `warning`,会话仍为 `connected`。 +5. 读取 runtime identity,并把非敏感字段投影给 renderer。bridge 未 running 时快照带 `warningCode`,可附带 `warningDetail`,会话仍为 `connected`。 每个 await 边界后都重新检查 `currentByProvider` 与 `AbortSignal`。如果用户在 settings write 或 runtime effect 期间取消,service 只回滚本次 onboarding 拥有的字段。 @@ -185,12 +185,12 @@ Rollback 使用 `SettingsStore.updateIf(predicate, patch)`:只有 current chan - Feishu channel 在快捷模式内提供飞书 / Lark brand 切换。 - Modal 明确区分生成中、等待扫码、已扫码、正在连接、已连接、过期、拒绝、取消和错误。 - 用户可刷新 QR、取消、在 HTTPS browser 中打开 verification URL;过期或失败后可重新生成。 -- 成功状态只展示 non-sensitive bot identity,不展示 credential。bridge 未起来时用 `warning`,不用失败态。 +- 成功状态只展示 non-sensitive bot identity,不展示 credential。bridge 未起来时通过 `warningCode` 呈现提示,不用失败态。 - QR frame 在 desktop viewport 居中,light/dark 与窄窗口保持同一 action hierarchy。 ## 9. 验收标准 -1. Unit tests 覆盖 input validation、poll dedupe、slow-down、expiry、cancel、runtime effect、credential rollback,以及 connected 后 bridge 未 running 的 `warning`。 +1. Unit tests 覆盖 input validation、poll dedupe、slow-down、expiry、cancel、runtime effect、credential rollback,以及 connected 后 bridge 未 running 的 `warningCode`。 2. 并发测试证明 cancel 能跨过 runtime-effect commit window,且不会覆盖 concurrent manual edit。 3. Runtime tests覆盖 Feishu/WeCom message mapping,以及 failed-handshake cleanup 顺序。 4. Electron E2E 通过 renderer → preload → IPC → main session 打开 Settings › 远程接入(`settings-bots-onboarding` fixture)。该场景只把 modal 固定在 `waiting` 以供界面校验;main unit tests 直接覆盖 `waiting → connected`,部分 dev/test adapters 能产生 `scanned → connected`,但 Playwright 当前没有运行完整 onboarding 行程的 spec。 diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 693fae23d15..f2c53cd5b85 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.2` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 305 files — blocker 0, reimplementation 0, polish 3, aligned 302. +**Totals:** 308 files — blocker 0, reimplementation 0, polish 4, aligned 304. ## Exclusions (explicit) @@ -211,6 +211,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/styles/help.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/hero.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/interaction-prompts.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | +| `apps/desktop/src/renderer/styles/message-queue.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/model-switcher.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/module-pages.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/module-pages/capability-audit.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | @@ -273,7 +274,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/client-plugin-runtime.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/client-plugin-slots.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/components.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | -| `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | IconButton, List, ListItem | aligned — uses Astryx (IconButton, List, ListItem) | aligned | +| `packages/ui/src/composer-message-queue-view.tsx` | shell-chrome-or-panel | Button, IconButton, List, ListItem, Tooltip | raw ` + +# Grok-assisted contribution review + +**Baseline:** `d2f19f400` (2026-09-27). **Status:** all 25 original-commit +inventories and current behaviors are reviewed. The current branch independently +reimplements the primary live cores for the nine active implementation entries +identified below and reduces branch-head squash attribution from 3,935 to 2,015 +lines. The remaining attribution still needs to be split across mixed authorship, +tests, later edits, removed behavior, and retained supporting slices. Project and +ASF legal acceptance also remains open. + +This is a provenance and engineering inventory, not a legal classification or +an assertion that these changes must be removed. The scope is merged PRs whose +mainline squash commits are dated on or after 2026-08-14, the effective date +of the xAI Acceptable Use Policy change under discussion. A squash date is +**not** the date a tool produced the original contribution. The scan matches +`Generated-by: .*Grok` in commits reachable from `origin/main`, then checks PR +disclosures for contributions without that trailer. A trailer does not establish +which individual lines were generated; a PR without one can still disclose use. + +| PR | Main commit | Area | Evidence | +|---:|---|---|---| +| #3008 | `2632b8506` | Eval egress | Commit trailer | +| #2967 | `1ce0bc308` | Eval egress | Commit trailer | +| #3048 | `a75640f43` | Desktop transcript | Commit trailer; mixed-tool PR | +| #3063 | `0dc3e5ec3` | Runtime cleanup | Commit trailer | +| #3066 | `d6bef01d3` | Eval framework | Commit trailer | +| #3070 | `9fbdc99a2` | Desktop projects | Commit trailer | +| #3078 | `887af0c9d` | CI inventory | Commit trailer | +| #3082 | `2666a579a` | Runtime PTY | Commit trailer | +| #3099 | `4bded3b21` | Desktop rail | Commit trailer | +| #3101 | `84a579db6` | Desktop tests | Commit trailer | +| #3102 | `5ef39c350` | Desktop bridge | Commit trailer | +| #3104 | `8a11c9f2b` | Runtime graph | Commit trailer | +| #3106 | `a79f69185` | Runtime Host tests | Commit trailer | +| #3111 | `cdf023dc0` | Desktop fixtures | Commit trailer | +| #3115 | `c92d0a22a` | Rate limits | Commit trailer | +| #3117 | `12d2ab54e` | Storage cleanup | Commit trailer | +| #3118 | `dd02553aa` | xAI model discovery | Commit trailer | +| #3119 | `802fe8977` | Desktop Plan Mode | Commit trailer | +| #3123 | `2e3c82e10` | Desktop tests | Commit trailer | +| #3069 | `4ffc823fd` | Desktop search | Commit trailer | +| #3459 | `b434a491a` | Terminal handling | Commit trailer; mixed PR | +| #3364 | `3ab0605b3` | Relative time | Commit trailer; mixed PR | +| #3544 | `dc9d2f0dd` | Composer queue | Commit trailer; mixed-tool PR | +| #4345 | `472fea02b` | Architecture docs | Commit trailer; mixed-tool PR | +| #5223 | `aa3f8e538` | Project path validation | PR AI-use disclosure; no Grok trailer | + +PR #2956 disclosed Grok but merged on 2026-08-13, before the selected date, +so it is outside this inventory. Closed, unmerged PRs are also outside the +current-mainline inventory: #4809, #4899, #5102, #5342, #5349, #5350, and +#5497. + +The original PR commit histories narrow this initial scope further. #2967 has +five Grok-tagged original commits, starting on 2026-08-13 and continuing +through 2026-08-15; it needs commit-level attribution rather than treating the +whole PR as post-change output. #3123 has a Grok trailer on the squash commit, +but none of its three original PR commits have that trailer and its original +PR description has no AI-use disclosure. Confirm that provenance before +classifying it. #5223 has no Grok-tagged commit but explicitly discloses +Cursor/Grok assistance in the PR description. The remaining 22 trailer-bearing +PRs have at least one Grok-tagged original commit dated 2026-08-14 or later. + +A second pass read the original commit titles and trailers for all 25 PRs. +#3048, #3544, and #4345 also contain Codex-authored commits. #3459 contains +two untagged feature commits before its three Grok-tagged commits. In #3364, +the three substantive relative-time commits are tagged Maka; its only +Grok-tagged original commit adds an ASF header to a test file. Reverting the +whole #3364 change would therefore remove work not attributed to Grok. +#3101, #3102, #3104, #3106, #3111, #3117, and #3069 also have untagged +merge or follow-up commits: inspect their merged diffs, not just their branch +commit lists. For example, #3118's original branch includes a Plan Mode import +fix also merged separately in #3119, but #3118's squash diff contains only +the xAI discovery/test deletions. + +## First-pass engineering triage + +This pass inspected each merge's changed paths and size against the baseline. +"Present" means the path still exists, **not** that the original lines survive +or that its behavior is unchanged. The line-level and behavioral evidence that +follows supersedes path presence as the disposition basis. + +| PR | Changed paths present now | Review target | +|---:|---:|---| +| #3008 | 3/3 | Eval CONNECT/TCP egress rules and denial tests | +| #2967 | 6/6 | Eval audit completeness and fail-closed boundary | +| #3048 | 14/14 | Mixed-author live transcript reseed and catch-up behavior | +| #3063 | 0/1 | Confirm deleted compact-cleanup has no successor or reader | +| #3066 | 11/11 | Eval framework selection across Python and TS | +| #3070 | 3/3 | Nested project registration and storage tests | +| #3078 | 5/5 | Generated inventory and CI drift gate | +| #3082 | 2/2 | PTY finalization/persist race | +| #3099 | 3/5 | Current rail grouping state and E2E successor | +| #3101 | 1/1 | Streaming remount painted-frame E2E | +| #3102 | 12/14 | Removed bridge endpoints and current IPC callers | +| #3104 | 8/9 | Removed runtime graph drive and Host graph authority | +| #3106 | 10/12 | Candidate launcher and E2E execution authority | +| #3111 | 2/3 | Daily Review fixture writer and deleted archive store | +| #3115 | 16/16 | Provider retry classification and user-facing wait state | +| #3117 | 10/14 | Removed JSON connection store and current storage paths | +| #3118 | 2/2 | xAI OAuth discovery filter is absent in current registry | +| #3119 | 1/1 | Plan Mode copy import at current module boundary | +| #3123 | 0/1 | Original prompt-rail E2E file is absent; find successor | +| #3069 | 0/2 | Both original search tests are absent; find current search tests | +| #3459 | 2/4 | Mixed-author terminal control-character handling | +| #3364 | 3/3 | Mixed-author relative-time formatting and refresh | +| #3544 | 31/35 | Mixed-tool queue protocol, Host receipts, and UI; 35-file change | +| #4345 | 1/1 | Mixed-tool architecture documentation accuracy | +| #5223 | 2/2 | Directory type validation still runs in project resolution | + +### Squash-line survival on current main and the PR head + +The following measurement resolves each mainline squash commit to its full SHA, +counts lines added by that commit, and uses `git blame -w -M --line-porcelain` +on `origin/main` at `d2f19f400` and on PR head `77d6987e5`. It is a stronger +survival signal than path presence, but it is not a generated-line classifier: +mixed-origin PRs are not split by original commit, `-C` copy detection is not +enabled, and `-M` can reattribute moved lines. Counts can increase after later +edits or moves, so every exception still needs commit-level review. + +| PR | Added lines | Base survival | PR-head survival | +|---:|---:|---:|---:| +| #3008 | 180 | 166 | 81 | +| #2967 | 678 | 651 | 258 | +| #3048 | 462 | 287 | 175 | +| #3063 | 0 | 0 | 0 | +| #3066 | 201 | 129 | 57 | +| #3070 | 177 | 137 | 24 | +| #3078 | 114 | 89 | 25 | +| #3082 | 116 | 66 | 23 | +| #3099 | 129 | 86 | 12 | +| #3101 | 54 | 42 | 42 | +| #3102 | 39 | 4 | 4 | +| #3104 | 20 | 17 | 17 | +| #3106 | 279 | 139 | 139 | +| #3111 | 59 | 57 | 73 | +| #3115 | 429 | 407 | 124 | +| #3117 | 282 | 252 | 252 | +| #3118 | 0 | 0 | 0 | +| #3119 | 2 | 1 | 1 | +| #3123 | 48 | 0 | 0 | +| #3069 | 200 | 0 | 0 | +| #3459 | 288 | 150 | 154 | +| #3364 | 118 | 111 | 111 | +| #3544 | 1713 | 1090 | 420 | +| #4345 | 22 | 22 | 18 | +| #5223 | 43 | 32 | 5 | + +The base measurement finds 3,935 surviving squash-attributed lines; the PR +head retains 2,015. Four entries have no surviving added output: #3063 and +#3118 are deletion-only, while the only #3123 file and both #3069 files were +later deleted. The reduction records material replacement of the active cores, +but it is not a completion metric: the largest remaining concentrations are +#3544, #2967, #3117, #3048, and #3459, and several are mixed-author or test-heavy +PRs. The behavioral and attribution review below handles those current +boundaries rather than treating squash blame as a generated-line classifier. + +The largest original change, #3544, touched 35 files and changed more than +2,000 lines; #3117 removed over 1,600 lines. A single wholesale rewrite would +cross separate storage, protocol, Desktop, Eval, and security review boundaries. +The deleted-only #3063 and absent test paths in #3123/#3069 have no direct +current file to "rewrite". Review should identify a present behavior before +proposing a replacement, and high-risk egress/Host paths need their own focused +tests and review. + +### Revert feasibility probe + +Before the baseline advanced, a disposable worktree at historical main commit +`d89ccce01` attempted `git revert --no-commit dc9d2f0dd` for #3544. +The revert stopped with 31 unmerged paths across Desktop IPC and renderer, +Runtime Host protocol/coordinator, UI, tests, and two modify/delete cases. +The probe was aborted and its worktree removed; no revert was committed or +pushed. This is direct evidence that a wholesale revert cannot be applied +mechanically to today's tree. The current behavior and later dependents must +be resolved before a replacement can be tested. + +Some entries are removals rather than added implementation. For example, +#3118 removed one discovery filter and #3063 deleted an unused module. A +revert followed by the same deletion would leave the final product unchanged; +such a commit pair alone does not establish an independent replacement. + +### Narrow slices and revert-state probes + +The following work was done against the 2026-09-27 baseline, preserving later +changes. It does not erase the original history or settle ASF legal questions. + +| PR | Revert-state evidence | Current change and status | +|---:|---|---| +| #3082 | Removing the PTY exit reconciliation made the delayed-persist test return `running` instead of `completed`. | Reconcile the control reply after persistence against finalization, then mark terminal observations. Replaced the Grok-authored test with a fresh test that pauses storage and observes driver exit without monkeypatching the driver prototype. Runtime build, shell-run-manager suite (62 pass, 4 platform skips), and Biome passed. | +| #5223 | Removing the directory check made the regular-file test fail with `Missing expected rejection`. | Check the canonical path's stat before Git discovery and reject non-directories with `TypeError`. Replaced the original test with Git/non-Git file cases, registration non-mutation, and a directory control. Storage build, project-catalog suite (23 pass), and Biome passed. | +| #3070 | Removing explicit nested-project selection made registration return the parent project's ID and broke relinking to a child directory. | Make resolution intent explicit: selected paths retain a nested folder identity, while historical paths and selected repository roots keep Git identity. Tests pass; current-head attribution is 24 lines pending classification. | +| #3099 | Disconnecting the migrated rail store's grouping read/write made a fresh store lose the selected grouping (`undefined` persisted value). | Keep all rail layout persistence behind one layout module and store. Tests pass; current-head attribution is 12 lines pending classification. | + +The #3082 and #5223 revert-state commits are separate from the replacement +commit to make the failure evidence inspectable. They are not safe to merge +without the following replacement. #3070 and #3099 have tested behavior fixes +but remain open for implementation replacement. The remaining 23 entries are +tracked below: nine active implementations require further replacement, six +paths appear deleted or superseded, two dispositions are reopened, two are +mixed or uncertain attribution, three are test/fixture or mixed-feature +regressions, and one is documentation-only. + +Six narrow paths have been traced to a no-code disposition rather than an +identical revert/reapply. Two additional candidates (#3117 and #3106) have +plausible supersession evidence but are reopened because their squash-attributed +line counts did not change. None is counted as a replacement: + +- #3063 removed `history-compact-cleanup.ts`; the file and references to it + are absent now (a repository search finds only this review document). + Restoring and removing it again would not replace live code. +- #3118 removed the `fallback-models` filter for xAI OAuth discovery, which is + still absent in `packages/core/src/provider-registry.ts`. The other changed + line removed an unused test import. Reintroducing the filter just to delete + it again would temporarily restore a model-discovery regression without + changing the final behavior. +- #3119 moved Plan Mode types to core subpath imports. The imports survive, + with later UI-locale lookup behavior added at the same boundary in + `apps/desktop/src/renderer/locales/plan-mode-copy.ts`. Restoring the removed + root barrel import would break typecheck; an identical import fix is not an + independent implementation. +- #3102 removed unused Desktop bridge/IPC endpoints. The deleted channel names + (including `git-review:mutate`, the memory mutations, onboarding milestone + clearing, MCP reconnect, artifact get, and skill starter/details) have no + present Desktop callers or registered handlers in the source tree. Its + original branch also includes an untagged merge and an E2E follow-up. Do not + resurrect the discarded IPC authority merely to delete it again. Its two + touched E2E files were later deleted (#5567 and #4752); the surviving + `/compact` Electron E2E passes (1 test), and the slash availability and + Skills controller tests pass (21 tests). The original E2E paths no longer + provide a live test target. +- #3104 removed a dead `runAgentGraphToQuiescence` loop and its test suite. A + current source search finds no call sites for that function; the migrated + `stream-graph-dispatch.ts` exports supervisor types still used by the live + coordinator and reconciliation code. Restoring an inactive second graph + driver would create another execution authority. Its remaining coordinator + and protocol test adjustments pass with the current Runtime read-model and + coordinator suites (31 tests). The deleted loop has no behavior to + independently rewrite. +- #3117 **(reopened)** deleted the legacy `llm-connections.json` store. Current storage and + Desktop main source has no production reference to that file or + `createConnectionStore`; the fixture writes to the Runtime Policy catalog + through its current storage authority. Its fixture test passes and asserts + the old JSON file is not created (rerun against the current Desktop build). + The fixture and related migration edits remain active and require their + own review. Split those surviving lines by behavior and later authorship + before accepting the supersession disposition. +- #3069 only added local transcript-search tests; both original test paths + were subsequently deleted (#4877 and #5531). Search now uses the Recall + pipeline. The current multi-host Recall search tests pass (9 tests), but + restoring the old tests would target a retired local-scan implementation. +- #3106 **(reopened)** originally sent `--desktop-e2e` through the production candidate CLI + and launcher. #3226 later separated the E2E execution entry into a + `test-only` module and made the production candidate reject that flag. The + current candidate/desktop tests pass (35 tests), including isolation from + test-only modules. Do not restore the old production flag, but split the + surviving startup and candidate wiring by behavior and later authorship + before accepting the supersession disposition. + +Two mixed/uncertain entries require attribution decisions, not a wholesale +rollback: + +- #3364's original Grok-tagged commit (`e65809320`) added only the ASF header + to `packages/core/src/__tests__/relative-time.test.ts`. The relative-time + behavior and tests came from Maka-tagged commits, with a later untagged + follow-up. Keep the required license header and do not revert the formatter + under the squash commit's aggregate Grok trailer. The current formatter and + refresh tests pass (8 tests). +- #3123's three original PR commits and PR description do not disclose Grok, + despite the squash commit's trailer. Its only changed file, + `apps/desktop/e2e/prompt-rail.spec.ts`, was deleted by #4741. The original + provenance is unresolved; there is no surviving test file to revert and + reimplement mechanically. #4741 removed the old layout E2E after repeated + compositor-dependent failures; that removal does not attribute its earlier + test lines to any model. + +#3008 is only partly recognizable in today's Eval egress filter. Its CONNECT +host classification remains, but #3017 subsequently replaced the raw-TCP +layer-closing path, added HTTP/TLS prefix handling, and introduced live +mitmproxy tests. Reverting the #3008 squash against today's code would also +unwind later security behavior. Treat the surviving CONNECT validation as a +separate security review with unit and live-proxy regression checks. + +#3459 has two untagged substantive commits that introduced terminal query +handling. Its three Grok-tagged follow-ups suppress XTVERSION replies, preserve +cursor-position reports, and mark CLI `/transcript` as local while a turn runs. +The implementation moved to the workbar terminal feature; the three behaviors +and their unit tests survive. A new registered-handler test verifies status, +CPR, and XTVERSION routing at the parser boundary. The Desktop query suite +(7 tests) and CLI mid-turn `/transcript` test pass. Reverting the squash would remove the +untagged implementation too. Review the tagged behaviors at their current +boundaries, not the terminal feature as a wholly Grok-authored change. + +#4345 is documentation-only. The first original commit is Grok-tagged and +describes bot onboarding; a later Codex-tagged commit corrected its claim about +test coverage. The surviving document was checked against the current +`BotOnboardingSnapshot`, main-process service, renderer, and tests: its stale +generic `warning` claim was corrected to `warningCode`/`warningDetail`, and +`retryHealth`/`errorCode` were documented. This is a scoped factual correction, +not an independently rewritten architecture or erasure of the original history. + +#3048's first two original commits (Grok-tagged) added catch-up seeding and +observation-generation gating; the next two (Codex-tagged) added retry and +ordering assertions. The replacement removes `completeLiveContentSeed` and its +parallel generation guard from AppShell, and makes the observation attempt the +single visibility authority. The current live-content, observer, handoff, and +streaming-remount regressions pass. Squash attribution falls from 287 base lines +to 175 at the current head; the remainder needs original-commit and later-edit +classification rather than another blind AppShell rewrite. + +#3101's only squash change modified the streaming-remount E2E to sample on +animation frames instead of body mutations. The current test has since gained +other assertions; the full file passes in a real Electron window (4 tests). +This validates the existing assertions, not an independently rewritten test. + +#3544's first six original commits are Grok-tagged and the last three are +Codex-tagged. The replacement removes the retained per-entry mutation flow and +rebuilds it around an exact Host-owned queue mutation protocol, one executor, +queue-state helpers, Desktop IPC actions, and a UI controller. Reorder commands +carry the drag-start revision to the Host; stale mutations reach the existing +error path instead of being silently discarded in the Client. Empty reorder +remains a compatible no-op, so only one protocol epoch is required. Runtime +Host, UI, Desktop, typecheck, and production renderer builds pass. Squash +attribution falls from 1,090 base lines to 420 at the current head; because the +original PR is mixed-tool and test-heavy, those remaining lines require +commit-level classification rather than treating all 35 files as one authored +unit. + +#3115's rate-limit classification, Host retry projection/continuity, and UI +countdown remain live. The replacement isolates retryability, normalized +headers, bounded delay parsing, and retry-reason projection in a pure policy; +Runtime imports that policy directly and the Host projection is rebuilt around +the policy result. Standard `Headers`, invalid millisecond fallback, reconnect, +reduced-motion, and clock-rollback regressions pass. Squash attribution falls +from 407 base lines to 124 at the current head; remaining UI copy and tests need +classification, not another retry-policy implementation. + +#3111's Daily Review fixture still writes through the interactive storage +authority with nested writer/owner cleanup. Its archive-seeding test passes; +a new failure-path test rejects an invalid archive, reacquires the same root +owner, and confirms that no partial archive was written (2 tests pass). This +validates cleanup rather than independently replacing the live fixture writer; +the old Desktop archive store was deleted and has no direct current file. + +#3070 and #3099 were replaced in the current branch after reviewing their +migrated implementations. #3070's historical cwd resolution remains distinct +from explicit nested selection. #3099's renderer reload test survives at +`apps/desktop/e2e/sidebar-project-reload.spec.ts`; its direct store tests pass, +and the Electron renderer-reload E2E passes in this branch. + +### Replacement evidence and remaining attribution + +- #3066: framework selection is process-local because the selected relay base + class is fixed at module import and each production trial has its own process. + `run_trial` installs the argv-selected framework once before framework module + imports; the unsupported `ContextVar` isolation claim and duplicate runner + scope are removed. The focused Python 3.12 suite passes 58 tests (2 skipped). +- #3078: the inventory checker now exposes a pure drift comparison, with tests + for exact bytes, independently stale Markdown, and missing/extra paths. The + CI planner also recognizes the new test. The current 302-file inventory check + (after merging upstream), 22 Astryx tests, 40 CI planner tests, and Biome + passed. A further planner assertion confirms that edits to the generator's + test file select the general code lane, which always runs the Astryx gate; + no CI rule change was needed. The generator and its later fail-closed + dependency parser remain unchanged. +- #3115: another ablation found that an invalid `Retry-After-Ms` suppressed + an otherwise valid `Retry-After` on the same retryable response. The parser + now validates each candidate independently, preferring valid milliseconds + and falling back to valid seconds or an HTTP date. The new regression failed + before the change and passed afterward (20 classification tests); Runtime + build and Biome passed. This regression is retained by the later independent + retry-policy and Host-projection replacement. +- #3544: UI dragging requires a known Host revision and always submits the + captured revision. The Host, not a client-side projection comparison, decides + whether that revision is stale and returns the conflict through the existing + action error path. Eight focused UI queue tests and the Desktop queue workflow + pass. +- #2967: a new boundary test showed the audit writer could append a record + across `MAX_AUDIT_BYTES` without recording `audit_truncated` until another + event arrived. The writer now checks the encoded record length before + appending and emits the marker immediately on overflow. Another ablation + showed permissive UTF-8 decoding counted a corrupt JSON string as a valid + `policy_error`; the artifact reader now decodes each raw line strictly, + preserving the existing score and missing-log rules. Python 3.13 Harbor + tests (93 total, 13 skips) and 22 Eval audit artifact tests passed. These + regressions are retained by the replacement audit writer/reader boundaries; + the first Grok-tagged commit still predates the selected policy date and + requires separate attribution. +- #3008: a new test exposed that CONNECT classification trusted `pretty_host` + over the actual tunnel destination, allowing a spoofed Host header to hide a + blocklisted target. It now classifies `request.host`; missing targets fail + closed. A further ablation showed malformed CONNECT authorities (userinfo + and path delimiters) and out-of-range ports were still accepted as benign + destinations. Host/port parsing now rejects those inputs while retaining + valid IPv6; Python 3.13 Harbor tests (96 total, 13 skips) passed. The live + mitmproxy regression also passed in PR CI; it remains unavailable locally + because the Docker daemon does not respond. #3017 owns the later raw-TCP + closure behavior; do not roll that implementation back as part of #3008. + +### Independent replacement pass (September 27, 2026) + +The current branch replaces the primary live authority for each of the nine +active implementation entries, removes the superseded paths, and adds focused +regressions at the new boundaries. Squash attribution still survives in tests, +mixed-author commits, later-modified orchestration, and supporting code; those +lines remain review work and are not automatically classified as generated. + +| PR | Independent replacement | Retained boundary and remaining review | +|---|---|---| +| #3008 | CONNECT target normalization is a pure `(host, port) -> URL` function; the mitmproxy hook only adapts request fields and fails closed. | #3017's later raw TCP/TLS handling remains authoritative; classify the 81 surviving squash-attributed lines. | +| #2967 | Audit writing uses one byte encoder and append/truncate path; reading uses strict line decoding and one statistics pass. | Trial attribution and scoring semantics remain; classify the 258 surviving lines, including the pre-policy original commit. | +| #3048 | Observation attempts are the single live-content visibility authority; the duplicate AppShell seed completion path is removed. | Transcript publication and later recovery remain consumers; split 175 surviving mixed-tool lines. | +| #3066 | The runner installs one process-local framework selection before importing relay modules. | Relay modules intentionally bind their base class at import; classify 57 surviving lines. | +| #3070 | Explicit chooser paths and historical cwd resolution use separate project-location intents. | Classify 24 surviving lines after later catalog changes. | +| #3078 | The checker is a pure generated-versus-committed comparison with direct drift tests; the CLI only performs I/O and reporting. | #3883's later fail-closed parser remains authoritative; classify 25 surviving lines. | +| #3099 | All rail layout keys and read/write helpers live behind one layout module and store. | Classify 12 surviving lines after later navigation work. | +| #3115 | Retryability, header normalization, bounded delay parsing, retry reason, and Host projection consume one pure policy. | Classify 124 surviving UI/test/support lines. | +| #3544 | Queue mutations use one Host protocol/executor/state path, Desktop actions, and UI controller; drag-start revision is fenced by the Host. | Split 420 surviving lines across mixed Grok/Codex commits, tests, and later queue orchestration. | + +The subtraction pass removed the obsolete seed completion path, duplicate +CONNECT parsing, classification-local retry tables/header parsing, separate +queue mutation paths, and split rail persistence. No compatibility branch or +fallback was added. The remaining squash-attributed lines still require +commit-level attribution and behavior review before final disposition. + +Verification after merging `origin/main` at `d2f19f400`: + +- 96 Python Harbor tests pass (13 skipped); 22 egress artifact and 41 Eval + lifecycle tests pass. +- 89 Desktop seed/observer/streaming tests, 23 Runtime retry/classification + tests, 177 Runtime Host coordinator/protocol tests, the focused 87-test + protocol suite, 6 UI queue tests, and 3 Core order-policy tests pass. +- Desktop and all workspace dependencies build; the 302-file inventory gate, + 22 Astryx tests, and 40 CI planner tests pass. +- The final upstream merge boundary passes 72 selected Desktop main tests and + 11 UI queue/attachment tests; Desktop preload, main, renderer, and Storybook + typechecks pass. +- The final Runtime and Runtime Host upstream merges plus the rewritten retry + and protocol policies pass 207 focused computer-use, transport, provider, + and protocol tests. +- The latest overlapping provider-classification merge passes 307 AI SDK, + provider classification, and pure retry-policy tests. +- The final UI upstream merge passes 4 focused Extensions tests, the 302-file + inventory gate, and all 22 Astryx inventory tests. +- The four streaming-remount Electron tests, the Side Chat native reorder / + reconnect test, and the WorkHub queue/steering lifecycle test pass through + the Desktop workspace test entrypoint. +- Direct root-level Playwright invocations were discarded as invalid evidence: + they launched Electron with the repository root as `.` and failed during + fixture setup before product assertions ran. +- The review-fix pass at `77d6987e5` passes 8 UI queue tests, 57 Runtime retry + and classification tests, 24 Storage catalog tests, 89 focused Runtime Host + protocol tests, 28 Desktop navigation/queue tests, and 58 Python 3.12 Eval + tests (2 skipped). Desktop typechecks, the production renderer build, Biome, + ASF headers, and `git diff --check` also pass. + +### Current disposition after the replacement pass + +A removed feature is not restored merely to revert it again, and squash-level +blame is not used to reclassify mixed-author or later-modified lines. + +| Disposition | PRs | Next evidence or action | +|---|---|---| +| Deleted or superseded original path (6) | #3063, #3118, #3119, #3102, #3104, #3069 | Confirm that the surviving incidental lines do not implement the original behavior. | +| Disposition reopened (2) | #3117, #3106 | Their branch-head survival counts are unchanged; separate current production behavior, tests, and later-author work before deciding whether they are superseded. | +| Mixed or uncertain attribution (2) | #3364, #3123 | Keep Maka-authored formatter and required ASF header; resolve #3123 squash-versus-original provenance with maintainers. | +| Reimplemented active cores; remaining attribution review (9) | #3008, #2967, #3048, #3066, #3070, #3078, #3099, #3115, #3544 | Classify the surviving test, mixed-author, later-edit, and supporting slices; run final ablations against the replacements. | +| Active test, fixture, or mixed feature with passing regression only (3) | #3101, #3111, #3459 | Preserve the current test/fixture behavior: #3101's current Electron assertion passes, #3111 now covers owner release on failed publish, and #3459's tagged follow-ups are isolated and tested without reverting its untagged feature commits. | +| Documentation-only mixed-tool PR (1) | #4345 | Preserve the current document with the factual correction; include it in the project/legal provenance decision. | + +The branch is built against `d2f19f400`; the focused suites listed above, +Desktop production renderer build, typechecks, Biome, ASF headers, and six real +Electron tests pass. These checks do not substitute for project/legal +acceptance. CI for the current head is the final cross-platform compatibility +check. + +## Project, legal, and release handoff + +This review deliberately does not make a legal determination. The ASF +[Generative Tooling Guidance](https://www.apache.org/legal/generative-tooling.html) +requires terms that do not restrict use of generated output inconsistently +with the Open Source Definition and directs newly discovered concerns about a +tool's terms to `legal-private@apache.org`. The ASF +[Legal Affairs Committee](https://www.apache.org/legal/) also identifies LEGAL +JIRA and `legal-discuss` as the normal public channels for policy questions. +The project should choose the appropriate channel and record the resulting +decision; this PR supplies the engineering inventory and remediation evidence. + +Release tag `v0.2.0-incubating-rc2` (`54542021a`) contains all 25 candidate +squash commits. If the project/legal conclusion accepts the contributions or +this remediation, that tag needs no code change from this review. If it +requires different remediation, `main` must first contain the accepted result +and a later release candidate must be cut from that state; recutting the same +tree would not change the inventory. + +[PR #5746](https://github.com/apache/maka/pull/5746) pauses future +Grok-generated contributions. Draft PR #5747 and this document review the +historical merged contributions. Keep this inventory attached to #5747 while +the decision is pending; after disposition, maintainers can retain the final +record here or move process tracking to the selected issue/legal channel. + +## Review protocol + +For each row, verify the original behavior and tests against current `main`, +identify subsequent changes or removals, and record the current affected files +and an explicit disposition. Do not count a rewritten commit message, a +reformat, or a no-op reimplementation as a provenance remedy. Any replacement +must be an independently reasoned change with behavior and regression evidence; +the earlier history and contributor disclosures remain intact. The scope and +acceptability of any remediation need Apache project/legal review. + +## Open work + +- [x] Triage all 25 original changes by path, size, and current path presence. +- [x] Read original commit trailers and compare branch history with the merged + diff for the 25 candidate PRs. +- [x] Finish the behavior and test tracing for the remaining active changes; + no-code deletions and mixed-author PRs are tracked separately above. +- [x] Record which changes are still material, superseded, or mixed with other work. +- [ ] Decide the appropriate action with the project and ASF legal discussion. +- [x] Implement and locally verify the narrow #3082 and #5223 replacements. +- [ ] Split mixed-origin, test-only, later-modified, and independently authored + exceptions from retained production implementation. +- [x] Independently replace the primary live cores from #3008, #2967, #3048, + #3066, #3070, #3078, #3099, #3115, and #3544 and remove the superseded + implementation paths. +- [ ] Classify the 2,015 surviving squash-attributed lines across original + commits, tests, later edits, mixed authorship, and retained supporting + slices; replace any remaining production implementation that survives + that classification. +- [ ] Re-evaluate #3117 and #3106, whose branch-head survival counts are + unchanged, and any other disposition unsupported by line/behavior evidence. +- [x] Run #3008's live mitmproxy regression in PR CI with a responsive Docker + daemon. +- [ ] Rerun subtraction and failure-injection ablations against the completed + replacements and retain later-dependent orchestration only where its own + focused regressions pass. +- [ ] Publish final per-PR before/after survival evidence and converge CI, + project review, and ASF legal review on PR #5747. diff --git a/package.json b/package.json index ba0b9adb456..c01b4e6f3f2 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "astryx:theme": "node scripts/build-astryx-theme.mjs", "astryx:surface-inventory": "node scripts/check-astryx-surface-inventory.mjs", "astryx:surface-inventory:write": "node scripts/generate-astryx-surface-inventory.mjs", - "astryx:surface-inventory:test": "node --test scripts/generate-astryx-surface-inventory.test.mjs", + "astryx:surface-inventory:test": "node --test scripts/generate-astryx-surface-inventory.test.mjs scripts/check-astryx-surface-inventory.test.mjs", "sync:model-metadata": "node scripts/sync-model-metadata.mjs", "refresh:model-metadata": "node scripts/sync-model-metadata.mjs --refresh", "check:model-metadata": "node scripts/sync-model-metadata.mjs --check", diff --git a/packages/cli/src/workspace-root.ts b/packages/cli/src/workspace-root.ts index c9f71f920c0..db89c061ea3 100644 --- a/packages/cli/src/workspace-root.ts +++ b/packages/cli/src/workspace-root.ts @@ -17,8 +17,8 @@ * under the License. */ -// Workspace-root resolution now lives in @maka/storage. This file remains -// as a re-export so existing relative CLI importers keep working. +// Keep the CLI compatibility path while storage owns workspace-root resolution. +// New callers should import the storage subpath directly. export { deriveMakaDataRoots, resolveMakaClientDataRoot, diff --git a/packages/core/package.json b/packages/core/package.json index 0254051d747..ab6f92bdba4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -29,6 +29,7 @@ "./execution-inspect": "./dist/execution-inspect.js", "./events": "./dist/events.js", "./provider-retry-countdown": "./dist/provider-retry-countdown.js", + "./message-queue-order": "./dist/message-queue-order.js", "./interaction": "./dist/interaction.js", "./session": "./dist/session.js", "./turn-origin": "./dist/turn-origin.js", diff --git a/packages/core/src/__tests__/message-queue-order.test.ts b/packages/core/src/__tests__/message-queue-order.test.ts new file mode 100644 index 00000000000..7ab6c9ff57a --- /dev/null +++ b/packages/core/src/__tests__/message-queue-order.test.ts @@ -0,0 +1,47 @@ +/* + * 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 { exactQueueReorder, moveQueueEntryId } from '../message-queue-order.js'; + +const entries = [{ entryId: 'a' }, { entryId: 'b' }, { entryId: 'c' }] as const; + +test('exact reorder accepts one permutation of every current entry', () => { + const reordered = exactQueueReorder(entries, ['b', 'a', 'c']); + assert.deepEqual( + reordered?.entries.map((entry) => entry.entryId), + ['b', 'a', 'c'], + ); + assert.equal(reordered?.changed, true); + assert.equal(exactQueueReorder(entries, ['a', 'b', 'c'])?.changed, false); +}); + +test('exact reorder rejects missing, extra, duplicate, and unknown ids', () => { + assert.equal(exactQueueReorder(entries, ['a', 'b']), undefined); + assert.equal(exactQueueReorder(entries, ['a', 'b', 'c', 'd']), undefined); + assert.equal(exactQueueReorder(entries, ['a', 'a', 'c']), undefined); + assert.equal(exactQueueReorder(entries, ['a', 'b', 'd']), undefined); +}); + +test('drag movement preserves the existing target-index behavior', () => { + assert.deepEqual(moveQueueEntryId(['a', 'b', 'c'], 'b', 'a'), ['b', 'a', 'c']); + assert.deepEqual(moveQueueEntryId(['a', 'b', 'c'], 'a', 'c'), ['b', 'c', 'a']); + assert.equal(moveQueueEntryId(['a', 'b'], 'a', 'missing'), undefined); +}); diff --git a/packages/core/src/__tests__/relative-time-contract.test.ts b/packages/core/src/__tests__/relative-time-contract.test.ts new file mode 100644 index 00000000000..c7c779ad408 --- /dev/null +++ b/packages/core/src/__tests__/relative-time-contract.test.ts @@ -0,0 +1,142 @@ +/* + * 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 { describe, it } from 'node:test'; +import { + formatAbsoluteTimestamp, + formatCompactTimestamp, + formatRelativeTimestamp, + formatSidebarTimestamp, + nextRelativeRefreshDelay, + nextSidebarRefreshDelay, + resetRelativeTimeFormatters, +} from '../relative-time.js'; + +const NOW = Date.UTC(2026, 7, 21, 12, 0, 0); +const DAY = 24 * 60 * 60_000; +const MONTH = 30 * DAY; + +describe('relative-time contract', () => { + it('keeps all surfaces on just now until the minute boundary', () => { + resetRelativeTimeFormatters(); + for (const age of [0, 1_000, 30_000, 59_999]) { + assert.equal(formatRelativeTimestamp(NOW - age, NOW, 'zh-CN'), '刚刚'); + assert.equal(formatRelativeTimestamp(NOW - age, NOW, 'en'), 'just now'); + assert.equal(formatCompactTimestamp(NOW - age, NOW, 'zh-CN'), '刚刚'); + assert.equal(formatSidebarTimestamp(NOW - age, NOW, 'zh-CN'), '刚刚'); + } + assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'zh-CN'), '1分钟前'); + assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'en'), '1 minute ago'); + }); + + it('returns the next visible refresh boundary', () => { + assert.deepEqual( + [nextRelativeRefreshDelay(NOW, NOW), nextRelativeRefreshDelay(NOW - 30_000, NOW)], + [60_000, 30_000], + ); + assert.equal(nextRelativeRefreshDelay(NOW - 60_000, NOW), 60_000); + + const nearDayBoundary = NOW - (17 * 24 * 60 + 11 * 60 + 58) * 60_000; + assert.equal(formatSidebarTimestamp(nearDayBoundary, NOW, 'en'), '17d'); + assert.equal(nextSidebarRefreshDelay(nearDayBoundary, NOW), 2 * 60_000); + assert.equal(nextSidebarRefreshDelay(NOW - 17 * DAY, NOW), 12 * 60 * 60_000); + assert.equal(nextSidebarRefreshDelay(NOW - MONTH, NOW), 15 * DAY); + }); + + it('uses stable abbreviated sidebar buckets and a date-only compact fallback', () => { + const cases = [ + [60_000, '1min'], + [46 * 60_000, '46min'], + [13 * 60 * 60_000, '13h'], + [3 * DAY, '3d'], + [29 * DAY, '29d'], + [MONTH, '1mo'], + [2 * MONTH, '2mo'], + [365 * DAY, '1y'], + ] as const; + for (const locale of ['zh-CN', 'en'] as const) { + for (const [age, expected] of cases) { + assert.equal(formatSidebarTimestamp(NOW - age, NOW, locale), expected); + } + } + + const old = NOW - 17 * DAY; + const expected = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format( + new Date(old), + ); + assert.equal(formatCompactTimestamp(old, NOW, 'en'), expected); + }); + + it('clamps future timestamps and bounds refresh delays', () => { + const future = NOW + MONTH; + assert.equal(nextRelativeRefreshDelay(future, NOW), 60_000); + assert.equal(formatRelativeTimestamp(future, NOW, 'en'), 'just now'); + assert.equal(formatCompactTimestamp(future, NOW, 'en'), 'just now'); + assert.equal(formatSidebarTimestamp(future, NOW, 'en'), 'just now'); + + for (const timestamp of [ + NOW - 60_000, + NOW - 60 * 60_000, + NOW, + future, + Infinity, + NaN, + -Infinity, + ]) { + const delay = nextRelativeRefreshDelay(timestamp, NOW); + assert.ok(delay === null || (Number.isFinite(delay) && delay > 0 && delay <= 10 * 60_000)); + } + }); + + it('reuses relative and absolute Intl formatters independently', () => { + resetRelativeTimeFormatters(); + const originalDate = Intl.DateTimeFormat; + const originalRelative = Intl.RelativeTimeFormat; + let constructions = 0; + const count = (name: 'DateTimeFormat' | 'RelativeTimeFormat', original: unknown) => { + function Counting(...args: unknown[]) { + constructions += 1; + return new (original as new (...params: unknown[]) => unknown)(...args); + } + Object.defineProperty(Intl, name, { value: Counting, configurable: true, writable: true }); + }; + count('DateTimeFormat', originalDate); + count('RelativeTimeFormat', originalRelative); + try { + for (let index = 0; index < 5; index += 1) { + formatRelativeTimestamp(NOW - 60_000, NOW, 'en'); + formatAbsoluteTimestamp(NOW - 60_000, 'en'); + } + assert.equal(constructions, 2); + } finally { + Object.defineProperty(Intl, 'DateTimeFormat', { + value: originalDate, + configurable: true, + writable: true, + }); + Object.defineProperty(Intl, 'RelativeTimeFormat', { + value: originalRelative, + configurable: true, + writable: true, + }); + resetRelativeTimeFormatters(); + } + }); +}); diff --git a/packages/core/src/__tests__/relative-time.test.ts b/packages/core/src/__tests__/relative-time.test.ts deleted file mode 100644 index 9cb6cc031de..00000000000 --- a/packages/core/src/__tests__/relative-time.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/* - * 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 { describe, it } from 'node:test'; -import { - formatAbsoluteTimestamp, - formatCompactTimestamp, - formatRelativeTimestamp, - formatSidebarTimestamp, - nextRelativeRefreshDelay, - nextSidebarRefreshDelay, - resetRelativeTimeFormatters, -} from '../relative-time.js'; - -const NOW = Date.UTC(2026, 7, 21, 12, 0, 0); -const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; - -describe('relative timestamp labels', () => { - it('holds a just-now label for the whole first minute, then switches to minutes', () => { - resetRelativeTimeFormatters(); - - for (const ageMs of [0, 1_000, 30_000, 59_999]) { - assert.equal(formatRelativeTimestamp(NOW - ageMs, NOW, 'zh-CN'), '刚刚'); - assert.equal(formatRelativeTimestamp(NOW - ageMs, NOW, 'en'), 'just now'); - assert.equal(formatCompactTimestamp(NOW - ageMs, NOW, 'zh-CN'), '刚刚'); - assert.equal(formatSidebarTimestamp(NOW - ageMs, NOW, 'zh-CN'), '刚刚'); - } - - assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'zh-CN'), '1分钟前'); - assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'en'), '1 minute ago'); - }); - - it('delays the ticker until the just-now window ends', () => { - assert.equal(nextRelativeRefreshDelay(NOW, NOW), 60_000); - assert.equal(nextRelativeRefreshDelay(NOW - 30_000, NOW), 30_000); - assert.equal(nextRelativeRefreshDelay(NOW - 60_000, NOW), 60_000); - }); - - it('uses scan-friendly units for sidebar timestamps', () => { - for (const locale of ['zh-CN', 'en'] as const) { - for (const [ageMs, expected] of [ - [60_000, '1min'], - [46 * 60_000, '46min'], - [13 * 60 * 60_000, '13h'], - [3 * 24 * 60 * 60_000, '3d'], - [17 * 24 * 60 * 60_000, '17d'], - [29 * 24 * 60 * 60_000, '29d'], - [30 * 24 * 60 * 60_000, '1mo'], - [60 * 24 * 60 * 60_000, '2mo'], - [365 * 24 * 60 * 60_000, '1y'], - ] as const) { - assert.equal(formatSidebarTimestamp(NOW - ageMs, NOW, locale), expected); - } - } - }); - - it('keeps the existing compact date fallback outside the sidebar', () => { - const ts = NOW - 17 * 24 * 60 * 60_000; - const expected = new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - }).format(new Date(ts)); - - assert.equal(formatCompactTimestamp(ts, NOW, 'en'), expected); - }); - - it('refreshes sidebar timestamps at the next visible bucket', () => { - const nearRoundedDayBucketBoundary = NOW - (17 * 24 * 60 + 11 * 60 + 58) * 60_000; - - assert.equal(formatSidebarTimestamp(nearRoundedDayBucketBoundary, NOW, 'en'), '17d'); - assert.equal(nextSidebarRefreshDelay(nearRoundedDayBucketBoundary, NOW), 2 * 60_000); - assert.equal(nextSidebarRefreshDelay(NOW - 17 * 24 * 60 * 60_000, NOW), 12 * 60 * 60_000); - assert.equal(nextSidebarRefreshDelay(NOW - THIRTY_DAYS_MS, NOW), 15 * 24 * 60 * 60_000); - assert.equal(nextSidebarRefreshDelay(NOW - 365 * 24 * 60 * 60_000, NOW), 24 * 24 * 60 * 60_000); - }); - - it('treats a finite future timestamp as just now and schedules recovery', () => { - const futureTs = NOW + THIRTY_DAYS_MS; - const delay = nextRelativeRefreshDelay(futureTs, NOW); - - assert.equal(delay, 60_000); - assert.equal(formatRelativeTimestamp(futureTs, NOW, 'en'), 'just now'); - assert.equal(formatCompactTimestamp(futureTs, NOW, 'en'), 'just now'); - assert.equal(formatSidebarTimestamp(futureTs, NOW, 'en'), 'just now'); - }); - - it('keeps timestamp refresh delays within the scheduler bound', () => { - for (const ts of [ - NOW - 60_000, - NOW - 60 * 60_000, - NOW, - NOW + THIRTY_DAYS_MS, - Number.POSITIVE_INFINITY, - Number.NaN, - Number.NEGATIVE_INFINITY, - ]) { - const delay = nextRelativeRefreshDelay(ts, NOW); - assert.ok(delay === null || (Number.isFinite(delay) && delay > 0 && delay <= 10 * 60_000)); - } - }); - - it('reuses both formatters when relative and absolute readings alternate', () => { - resetRelativeTimeFormatters(); - const OriginalDateTimeFormat = Intl.DateTimeFormat; - const OriginalRelativeTimeFormat = Intl.RelativeTimeFormat; - let constructions = 0; - function countConstructions(name: 'DateTimeFormat' | 'RelativeTimeFormat'): void { - const Original = Intl[name] as unknown as new (...args: unknown[]) => unknown; - function Counting(...args: unknown[]): unknown { - constructions += 1; - return new Original(...args); - } - Object.defineProperty(Intl, name, { value: Counting, configurable: true, writable: true }); - } - countConstructions('DateTimeFormat'); - countConstructions('RelativeTimeFormat'); - try { - for (let round = 0; round < 5; round += 1) { - // The sidebar reads both per row: the relative label and, for the - // accessible name and the tooltip, the absolute one. - formatRelativeTimestamp(NOW - 60_000, NOW, 'en'); - formatAbsoluteTimestamp(NOW - 60_000, 'en'); - } - assert.equal(constructions, 2, 'one formatter of each kind for one locale'); - } finally { - Object.defineProperty(Intl, 'DateTimeFormat', { - value: OriginalDateTimeFormat, - configurable: true, - writable: true, - }); - Object.defineProperty(Intl, 'RelativeTimeFormat', { - value: OriginalRelativeTimeFormat, - configurable: true, - writable: true, - }); - resetRelativeTimeFormatters(); - } - }); -}); diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index 1a76472b9f4..35896f100ad 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -23,7 +23,7 @@ import type { UiLocale } from './ui-locale.js'; /** Scenarios that are consumed by a current E2E, audit, or smoke entry point. */ export type E2eFixtureScenario = - | 'settings-models' + | 'settings-connections' | 'turn-narrative' | 'turn-narrative-browser' | 'chat-prompt-rail' diff --git a/packages/core/src/message-queue-order.ts b/packages/core/src/message-queue-order.ts new file mode 100644 index 00000000000..b811d519381 --- /dev/null +++ b/packages/core/src/message-queue-order.ts @@ -0,0 +1,61 @@ +/* + * 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. + */ + +export interface QueueEntryIdentity { + readonly entryId: string; +} + +export interface ExactQueueReorder { + readonly entries: readonly T[]; + readonly changed: boolean; +} + +export function exactQueueReorder( + current: readonly T[], + requestedEntryIds: readonly string[], +): ExactQueueReorder | undefined { + if (requestedEntryIds.length !== current.length) return undefined; + const remaining = new Map(current.map((entry) => [entry.entryId, entry])); + if (remaining.size !== current.length) return undefined; + const entries: T[] = []; + for (const entryId of requestedEntryIds) { + const entry = remaining.get(entryId); + if (!entry) return undefined; + remaining.delete(entryId); + entries.push(entry); + } + return { + entries, + changed: entries.some((entry, index) => current[index] !== entry), + }; +} + +export function moveQueueEntryId( + currentEntryIds: readonly string[], + sourceEntryId: string, + targetEntryId: string, +): readonly string[] | undefined { + const sourceIndex = currentEntryIds.indexOf(sourceEntryId); + const targetIndex = currentEntryIds.indexOf(targetEntryId); + if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) return undefined; + const entries = [...currentEntryIds]; + entries.splice(sourceIndex, 1); + entries.splice(targetIndex, 0, sourceEntryId); + return entries; +} diff --git a/packages/core/src/relative-time-policy.ts b/packages/core/src/relative-time-policy.ts new file mode 100644 index 00000000000..b2ee1d8d79a --- /dev/null +++ b/packages/core/src/relative-time-policy.ts @@ -0,0 +1,84 @@ +/* + * 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 type { UiCatalog } from './ui-locale.js'; + +export const RELATIVE_HORIZON_MS = 7 * 24 * 60 * 60 * 1000; +export const JUST_NOW_MS = 60_000; +export const MINUTE_MS = 60_000; +export const HOUR_MS = 60 * MINUTE_MS; +export const DAY_MS = 24 * HOUR_MS; +export const MONTH_MS = 30 * DAY_MS; +export const YEAR_MS = 12 * MONTH_MS; +const MAX_SIDEBAR_REFRESH_MS = 24 * DAY_MS; + +export const JUST_NOW: UiCatalog = { + 'zh-CN': '刚刚', + 'zh-TW': '剛剛', + en: 'just now', +}; + +const SIDEBAR_TIME_BUCKETS = [ + { unitMs: MINUTE_MS, maxValue: 60, suffix: 'min' }, + { unitMs: HOUR_MS, maxValue: 24, suffix: 'h' }, + { unitMs: DAY_MS, maxValue: 30, suffix: 'd' }, + { unitMs: MONTH_MS, maxValue: 12, suffix: 'mo' }, + { unitMs: YEAR_MS, maxValue: Number.POSITIVE_INFINITY, suffix: 'y' }, +] as const; + +export function relativeAgeMs(timestamp: number, now: number): number { + return Math.max(0, now - timestamp); +} + +export type SidebarTimeBucket = { + value: number; + unitMs: number; + suffix: (typeof SIDEBAR_TIME_BUCKETS)[number]['suffix']; +}; + +export function sidebarTimeBucket(ageMs: number): SidebarTimeBucket { + for (const bucket of SIDEBAR_TIME_BUCKETS) { + const value = Math.round(ageMs / bucket.unitMs); + if (value < bucket.maxValue) return { value, unitMs: bucket.unitMs, suffix: bucket.suffix }; + } + throw new Error('Sidebar time buckets must end with an unbounded bucket'); +} + +function nextRoundedBoundaryDelay(ageMs: number, unitMs: number, value: number): number { + return Math.max(1, Math.ceil((value + 0.5) * unitMs - ageMs)); +} + +export function nextRelativeRefreshDelay(timestamp: number, now: number): number | null { + const ageMs = relativeAgeMs(timestamp, now); + if (ageMs > RELATIVE_HORIZON_MS) return null; + if (ageMs < JUST_NOW_MS) return JUST_NOW_MS - ageMs; + if (ageMs < HOUR_MS) return MINUTE_MS; + return 10 * MINUTE_MS; +} + +export function nextSidebarRefreshDelay(timestamp: number, now: number): number | null { + const ageMs = relativeAgeMs(timestamp, now); + if (!Number.isFinite(ageMs)) return null; + if (ageMs < JUST_NOW_MS) return JUST_NOW_MS - ageMs; + const bucket = sidebarTimeBucket(ageMs); + return Math.min( + nextRoundedBoundaryDelay(ageMs, bucket.unitMs, bucket.value), + MAX_SIDEBAR_REFRESH_MS, + ); +} diff --git a/packages/core/src/relative-time.ts b/packages/core/src/relative-time.ts index e2d41d4fdd8..ea5e8fb1153 100644 --- a/packages/core/src/relative-time.ts +++ b/packages/core/src/relative-time.ts @@ -25,38 +25,16 @@ * useful than a relative label like "300 天前". */ -import { uiLocaleToIntlLocale, type UiCatalog, type UiLocale } from './ui-locale.js'; - -/** Maximum age (ms) that still gets a relative bucket. Older → absolute. */ -const RELATIVE_HORIZON_MS = 7 * 24 * 60 * 60 * 1000; - -/** Age below this stays on one just-now label instead of counting seconds. */ -const JUST_NOW_MS = 60_000; -const MINUTE_MS = 60_000; -const HOUR_MS = 60 * MINUTE_MS; -const DAY_MS = 24 * HOUR_MS; -const MONTH_MS = 30 * DAY_MS; -const YEAR_MS = 12 * MONTH_MS; -/** Stays below the browser timer ceiling while avoiding needless daily wakes. */ -const MAX_SIDEBAR_REFRESH_MS = 24 * DAY_MS; -const SIDEBAR_TIME_BUCKETS = [ - { unitMs: MINUTE_MS, maxValue: 60, suffix: 'min' }, - { unitMs: HOUR_MS, maxValue: 24, suffix: 'h' }, - { unitMs: DAY_MS, maxValue: 30, suffix: 'd' }, - { unitMs: MONTH_MS, maxValue: 12, suffix: 'mo' }, - { unitMs: YEAR_MS, maxValue: Number.POSITIVE_INFINITY, suffix: 'y' }, -] as const; - -const JUST_NOW: UiCatalog = { - 'zh-CN': '刚刚', - 'zh-TW': '剛剛', - en: 'just now', -}; - -/** Future timestamps are treated as age zero and therefore display as just now. */ -function relativeAgeMs(ts: number, now: number): number { - return Math.max(0, now - ts); -} +import { uiLocaleToIntlLocale, type UiLocale } from './ui-locale.js'; +import { + JUST_NOW, + JUST_NOW_MS, + RELATIVE_HORIZON_MS, + relativeAgeMs, + nextRelativeRefreshDelay as nextRelativeRefreshDelayPolicy, + nextSidebarRefreshDelay as nextSidebarRefreshDelayPolicy, + sidebarTimeBucket, +} from './relative-time-policy.js'; // One cache per formatter. They used to share `cachedLocale` and clear each // other on a miss, so alternating relative and absolute reads — which is what @@ -193,37 +171,9 @@ export function resetRelativeTimeFormatters(): void { * past the horizon (never re-render). */ export function nextRelativeRefreshDelay(ts: number, now: number = Date.now()): number | null { - const ageMs = relativeAgeMs(ts, now); - if (ageMs > RELATIVE_HORIZON_MS) return null; - if (ageMs < JUST_NOW_MS) return JUST_NOW_MS - ageMs; - if (ageMs < 60 * 60_000) return 60_000; - return 10 * 60_000; -} - -function nextRoundedBoundaryDelay(ageMs: number, unitMs: number, value: number): number { - return Math.max(1, Math.ceil((value + 0.5) * unitMs - ageMs)); -} - -function sidebarTimeBucket(ageMs: number): { - value: number; - unitMs: number; - suffix: (typeof SIDEBAR_TIME_BUCKETS)[number]['suffix']; -} { - for (const bucket of SIDEBAR_TIME_BUCKETS) { - const value = Math.round(ageMs / bucket.unitMs); - if (value < bucket.maxValue) return { value, unitMs: bucket.unitMs, suffix: bucket.suffix }; - } - throw new Error('Sidebar time buckets must end with an unbounded bucket'); + return nextRelativeRefreshDelayPolicy(ts, now); } -/** Refreshes at the next visible sidebar bucket, capped below the timer ceiling. */ export function nextSidebarRefreshDelay(ts: number, now: number = Date.now()): number | null { - const ageMs = relativeAgeMs(ts, now); - if (!Number.isFinite(ageMs)) return null; - if (ageMs < JUST_NOW_MS) return JUST_NOW_MS - ageMs; - const bucket = sidebarTimeBucket(ageMs); - return Math.min( - nextRoundedBoundaryDelay(ageMs, bucket.unitMs, bucket.value), - MAX_SIDEBAR_REFRESH_MS, - ); + return nextSidebarRefreshDelayPolicy(ts, now); } diff --git a/packages/eval/README.md b/packages/eval/README.md index 232128e6b52..95e46203c3d 100644 --- a/packages/eval/README.md +++ b/packages/eval/README.md @@ -258,8 +258,8 @@ variable: the live cell needs a kernel that can load the checked-in `table inet` text — including that `127.0.0.11` is rejected before `fib daddr type local accept` — is locked by `lifecycle-boundaries.test.ts` and the Harbor contract tests. This URL policy is a blocklist for known benchmark and public-solution contamination surfaces, not a complete defense against a deliberately -invented lookup channel. It classifies HTTP(S) requests and `CONNECT` hosts against the blocklist, and -kills tunnels that fall back to raw TCP. Collected Maka runtime files +invented lookup channel. It checks ordinary HTTP(S) URLs and CONNECT authorities before forwarding, +then records and terminates any transport that falls back to unclassified raw TCP. Collected Maka runtime files and egress audit logs are represented in attempt artifacts with byte counts and SHA-256 digests. What the verifier scores is the environment the task was left in, so a subject that exits on its own diff --git a/packages/eval/harbor/egress-proxy/entrypoint.sh b/packages/eval/harbor/egress-proxy/entrypoint.sh index 44844d2c917..53a8ee8acc6 100755 --- a/packages/eval/harbor/egress-proxy/entrypoint.sh +++ b/packages/eval/harbor/egress-proxy/entrypoint.sh @@ -22,10 +22,10 @@ STATE_DIR=/opt/maka-egress-state CERT_DIR=/opt/maka-egress mkdir -p "$STATE_DIR" "$CERT_DIR" -# Create the audit log if this is the first start. Truncating here would erase -# hits already written if the proxy restarts mid-trial, and the trial would -# look like an honest zero-hit run. -touch "$STATE_DIR/hits.jsonl" +AUDIT_LOG="$STATE_DIR/hits.jsonl" +# Proxy restarts share one append-only evidence file for the whole trial. +# Only create it when absent so an earlier process cannot lose recorded hits. +test -e "$AUDIT_LOG" || touch "$AUDIT_LOG" # confdir holds the CA private key and this cell's audit log, so it stays # container-private and only the certificate reaches the volume the subject diff --git a/packages/eval/harbor/egress_filter.py b/packages/eval/harbor/egress_filter.py index 84c24956aba..011121a1cb3 100644 --- a/packages/eval/harbor/egress_filter.py +++ b/packages/eval/harbor/egress_filter.py @@ -23,7 +23,10 @@ import os import re import time +from contextlib import suppress +from ipaddress import IPv6Address from pathlib import Path +from typing import NamedTuple from urllib.parse import unquote, urlsplit PINNED_REVISION = "d49e28f1e4ddd13d289e85a5f312a66750951932" @@ -36,6 +39,14 @@ TERMINAL_BENCH = re.compile(r"terminal[-_.%/+\s]*bench", re.IGNORECASE) +class ConnectTarget(NamedTuple): + """Validated CONNECT authority adapted into the URL policy's input shape.""" + + host: str + port: int | None + url: str + + def contamination_rule(raw_url: str) -> tuple[str, str, str] | None: normalized = normalize_url(raw_url) url = urlsplit(normalized) @@ -150,12 +161,12 @@ def configure(updated: object) -> None: def request(flow: object) -> None: - apply_http_policy(flow, flow.request.pretty_url) + enforce_url_policy(flow, flow.request.pretty_url) def response(flow: object) -> None: - response = getattr(flow, "response", None) - if getattr(response, "status_code", None) != 101: + reply = getattr(flow, "response", None) + if getattr(reply, "status_code", None) != 101: return # mitmproxy 12.2.3 sets flow.websocket before HttpResponseHook only when # the 101 is a real WebSocket upgrade (Upgrade + version 13 + option on). @@ -163,36 +174,34 @@ def response(flow: object) -> None: # under rawtcp=false and must be audited. if getattr(flow, "websocket", None) is not None: return - record_raw_tunnel(flow) + reject_raw_transport(flow, close=False) def http_connect(flow: object) -> None: + request = getattr(flow, "request", None) try: - raw_url = connect_target_url(flow) - except Exception: - raw_url = "" - apply_http_policy(flow, raw_url) + target = parse_connect_target( + getattr(request, "host", None), + getattr(request, "port", None), + ).url + except (TypeError, ValueError): + target = "" + enforce_url_policy(flow, target) def tcp_start(flow: object) -> None: - # Last resort if a TCPLayer is still admitted (tcp_hosts / ignore). - # CONNECT raw is closed by next_layer → CloseRawLayer; HTTP 101 raw is - # closed by rawtcp=false. Neither of those paths starts a TCPLayer. - record_raw_tunnel(flow) - kill_flow(flow) + # This is a last-resort hook for a raw layer admitted by an override. + reject_raw_transport(flow) def tcp_message(flow: object) -> None: messages = getattr(flow, "messages", None) - if messages: + if messages and hasattr(messages[-1], "content"): messages[-1].content = b"" - kill_flow(flow) + close_flow(flow) def next_layer(nextlayer: object) -> None: - # Script addons run before the built-in classifier assigns layer. If we - # set CloseRawLayer here, NextLayer leaves it in place. Waiting for - # isinstance(..., TCPLayer) never fires on the production CONNECT path. current = getattr(nextlayer, "layer", None) context = getattr(nextlayer, "context", None) if current is None: @@ -204,20 +213,20 @@ def next_layer(nextlayer: object) -> None: server_tls.child_layer = ClientTLSLayer(context) nextlayer.layer = server_tls return - if not looks_like_raw_tcp(nextlayer): + if not initial_stream_is_raw(nextlayer): return - record_raw_tunnel(context) - nextlayer.layer = CloseRawLayer(context) + reject_raw_transport(context, close=False) + nextlayer.layer = RejectRawTransport(context, proxy_commands) return if TCPLayer is None or not isinstance(current, TCPLayer): return - record_raw_tunnel(context) - closer = CloseRawLayer(context) + reject_raw_transport(context, close=False) + closer = RejectRawTransport(context, proxy_commands) replace_layer(context, current, closer) nextlayer.layer = closer -def apply_http_policy(flow: object, raw_url: str) -> None: +def enforce_url_policy(flow: object, raw_url: str) -> None: if http is None: raise RuntimeError("mitmproxy is required to run the Eval egress filter") try: @@ -225,7 +234,7 @@ def apply_http_policy(flow: object, raw_url: str) -> None: if not matched: return rule_id, host, normalized_path = matched - append_audit(rule_id, host, normalized_path) + audit_event(rule_id, host, normalized_path) flow.response = blocked_response(rule_id) except Exception as error: flow.response = http.Response.make( @@ -237,27 +246,50 @@ def apply_http_policy(flow: object, raw_url: str) -> None: }, ) try: - append_audit("policy_error", "", type(error).__name__) + audit_event("policy_error", "", type(error).__name__) except Exception: pass -def connect_target_url(flow: object) -> str: - request = flow.request - host = (getattr(request, "pretty_host", None) or getattr(request, "host", "") or "").strip() - if not host: - raise ValueError("empty CONNECT host") - if ":" in host and not host.startswith("["): - host = f"[{host}]" - port = getattr(request, "port", None) - if port in (None, 443): - return f"https://{host}/" - if port == 80: - return f"http://{host}/" - return f"https://{host}:{port}/" +def connect_url(host: object, port: object) -> str: + return parse_connect_target(host, port).url -def looks_like_raw_tcp(nextlayer: object) -> bool: +def parse_connect_target(host: object, port: object) -> ConnectTarget: + normalized_host = _normalize_connect_host(host) + normalized_port = _normalize_connect_port(port) + scheme = "http" if normalized_port == 80 else "https" + authority = normalized_host if normalized_port in (None, 443, 80) else f"{normalized_host}:{normalized_port}" + return ConnectTarget( + host=normalized_host, + port=normalized_port, + url=f"{scheme}://{authority}/", + ) + + +def _normalize_connect_host(host: object) -> str: + if not isinstance(host, str) or not host or host != host.strip(): + raise ValueError("empty CONNECT host") + if host.startswith("[") and host.endswith("]"): + IPv6Address(host[1:-1]) + return host + if ":" in host: + IPv6Address(host) + return f"[{host}]" + if re.search(r"[\s/@?#\\\[\]]", host): + raise ValueError("invalid CONNECT host") + return host + + +def _normalize_connect_port(port: object) -> int | None: + if port is None: + return None + if type(port) is not int or not 1 <= port <= 65535: + raise ValueError("invalid CONNECT port") + return port + + +def initial_stream_is_raw(nextlayer: object) -> bool: """Close only when the bytes cannot still become HTTP or TLS. Script next_layer runs before mitmproxy 12.2.3's classifier. Copying its @@ -309,9 +341,9 @@ def _next_layer_bytes(nextlayer: object, name: str) -> bytes: return bytes(data) if isinstance(data, (bytes, bytearray)) else b"" -def tcp_peer(flow: object) -> tuple[str, str]: - server = getattr(flow, "server_conn", None) or getattr(flow, "server", None) - address = getattr(server, "address", None) if server is not None else None +def peer_label(owner: object) -> tuple[str, str]: + server = getattr(owner, "server_conn", None) or getattr(owner, "server", None) + address = getattr(server, "address", None) if isinstance(address, (tuple, list)) and address: host = str(address[0])[:255] port = address[1] if len(address) > 1 else "" @@ -319,19 +351,21 @@ def tcp_peer(flow: object) -> tuple[str, str]: return "", "" -def record_raw_tunnel(flow: object) -> None: - host, path = tcp_peer(flow) +def reject_raw_transport(owner: object, *, close: bool = True) -> None: + host, port = peer_label(owner) try: - append_audit("raw_tunnel", host, path) + audit_event("raw_tunnel", host, port) except Exception: pass + if close: + close_flow(owner) -def kill_flow(flow: object) -> None: - kill = getattr(flow, "kill", None) - if callable(kill) and getattr(flow, "killable", True): +def close_flow(flow: object) -> None: + terminate = getattr(flow, "kill", None) + if callable(terminate) and getattr(flow, "killable", True): try: - kill() + terminate() except Exception: pass @@ -351,8 +385,9 @@ def replace_layer(context: object, current: object, closer: object) -> None: layers.insert(min(index, len(layers)), closer) -class CloseRawLayer(Layer): - def __init__(self, context: object) -> None: +class RejectRawTransport(Layer): + def __init__(self, context: object, commands: object) -> None: + self._close_connection = getattr(commands, "CloseConnection", None) if Layer is object: self.context = context return @@ -363,13 +398,13 @@ def __init__(self, context: object) -> None: super().__init__(context) def handle_event(self, event: object): - if proxy_commands is None: + if self._close_connection is None: return yield for name in ("client", "server"): connection = getattr(self.context, name, None) if connection is not None: - yield proxy_commands.CloseConnection(connection) + yield self._close_connection(connection) def blocked_response(rule_id: str): @@ -383,53 +418,67 @@ def blocked_response(rule_id: str): ) -def append_audit(rule_id: str, host: str, normalized_path: str) -> None: - AUDIT_PATH.parent.mkdir(parents=True, exist_ok=True) - if AUDIT_PATH.exists() and AUDIT_PATH.stat().st_size >= MAX_AUDIT_BYTES: - write_truncation_marker() - return - record = { - "ts": int(time.time() * 1000), - "ruleId": rule_id, - "host": host[:255], - "normalizedPath": normalized_path[:4096], - } - with AUDIT_PATH.open("a", encoding="utf-8") as stream: - stream.write(json.dumps(record, ensure_ascii=True, separators=(",", ":")) + "\n") - - -def write_truncation_marker() -> None: - if audit_already_truncated(): - return - record = { - "ts": int(time.time() * 1000), - "ruleId": "audit_truncated", - "host": "", - "normalizedPath": "", - } - prefix = "" - if AUDIT_PATH.exists() and AUDIT_PATH.stat().st_size > 0: - with AUDIT_PATH.open("rb") as stream: - stream.seek(-1, os.SEEK_END) - if stream.read(1) != b"\n": - prefix = "\n" - with AUDIT_PATH.open("a", encoding="utf-8") as stream: - stream.write(prefix + json.dumps(record, ensure_ascii=True, separators=(",", ":")) + "\n") - - -def audit_already_truncated() -> bool: - if not AUDIT_PATH.exists(): - return False - with AUDIT_PATH.open("rb") as stream: - stream.seek(0, os.SEEK_END) - size = stream.tell() - stream.seek(max(0, size - 4096)) - tail = stream.read().decode("utf-8", errors="ignore") - lines = [line for line in tail.splitlines() if line.strip()] - if not lines: - return False - try: - parsed = json.loads(lines[-1]) - except json.JSONDecodeError: - return False - return isinstance(parsed, dict) and parsed.get("ruleId") == "audit_truncated" +class AuditJournal: + def __init__(self, path: Path, byte_limit: int) -> None: + self.path = path + self.byte_limit = byte_limit + + def record(self, rule_id: str, host: str, normalized_path: str) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + entry = self._encode(rule_id, host[:255], normalized_path[:4096]) + existing_bytes = self.path.stat().st_size if self.path.exists() else 0 + if existing_bytes + len(entry) <= self.byte_limit: + with self.path.open("ab") as stream: + stream.write(entry) + return + self.mark_full() + + def mark_full(self) -> None: + if self.has_full_marker(): + return + with self.path.open("ab") as stream: + stream.write(self._separator() + self._encode("audit_truncated", "", "")) + + def has_full_marker(self) -> bool: + last = self._last_record() + return last is not None and last.get("ruleId") == "audit_truncated" + + def _separator(self) -> bytes: + if not self.path.exists() or self.path.stat().st_size == 0: + return b"" + final_offset = self.path.stat().st_size - 1 + with self.path.open("rb") as stream: + stream.seek(final_offset) + return b"" if stream.read(1) == b"\n" else b"\n" + + def _last_record(self) -> dict[str, object] | None: + if not self.path.exists() or self.path.stat().st_size == 0: + return None + size = self.path.stat().st_size + with self.path.open("rb") as stream: + stream.seek(max(0, size - 4096)) + lines = stream.read().decode("utf-8", errors="ignore").splitlines() + for line in reversed(lines): + if not line.strip(): + continue + decoded = None + with suppress(json.JSONDecodeError): + decoded = json.loads(line) + return decoded if isinstance(decoded, dict) else None + return None + + @staticmethod + def _encode(rule_id: str, host: str, normalized_path: str) -> bytes: + record = { + "ts": int(time.time() * 1000), + "ruleId": rule_id, + "host": host, + "normalizedPath": normalized_path, + } + return (json.dumps(record, ensure_ascii=True, separators=(",", ":")) + "\n").encode( + "utf-8" + ) + + +def audit_event(rule_id: str, host: str, normalized_path: str) -> None: + AuditJournal(AUDIT_PATH, MAX_AUDIT_BYTES).record(rule_id, host, normalized_path) diff --git a/packages/eval/harbor/eval_framework.py b/packages/eval/harbor/eval_framework.py index c14666c4312..b4a273b1f21 100644 --- a/packages/eval/harbor/eval_framework.py +++ b/packages/eval/harbor/eval_framework.py @@ -15,26 +15,49 @@ # specific language governing permissions and limitations # under the License. -"""Authoritative Eval harness framework selection. - -`run_trial.py` validates the argv framework and installs it here before Harbor, -Pier, or the shared relay can import. The relay must not read the environment. -""" +"""Process-local framework authority shared by the trial runner and relay.""" from __future__ import annotations -_FRAMEWORKS = frozenset({"harbor", "pier"}) -_framework: str | None = None +from typing import NamedTuple + + +class FrameworkSpec(NamedTuple): + name: str + distribution: str + agent_module: str -def install(framework: str) -> None: - if framework not in _FRAMEWORKS: +_FRAMEWORKS = { + "harbor": FrameworkSpec("harbor", "harbor", "harbor.agents.base"), + "pier": FrameworkSpec("pier", "datacurve-pier", "pier.agents.base"), +} +_active: FrameworkSpec | None = None + + +def framework_spec(name: str) -> FrameworkSpec: + try: + return _FRAMEWORKS[name] + except KeyError: raise RuntimeError("framework must be harbor or pier") - global _framework - _framework = framework -def selected() -> str: - if _framework not in _FRAMEWORKS: +def install(name: str) -> None: + """Select the framework before importing its process-wide relay module.""" + + global _active + _active = framework_spec(name) + + +def current_framework() -> str: + if _active is None: raise RuntimeError("Eval framework selection is not installed") - return _framework + return _active.name + + +def framework_distribution(name: str) -> str: + return framework_spec(name).distribution + + +def framework_agent_module(name: str) -> str: + return framework_spec(name).agent_module diff --git a/packages/eval/harbor/relay_agent.py b/packages/eval/harbor/relay_agent.py index 584af7e5b25..a968bb5368e 100644 --- a/packages/eval/harbor/relay_agent.py +++ b/packages/eval/harbor/relay_agent.py @@ -23,6 +23,7 @@ import base64 import contextlib import hashlib +import importlib import json import os import re @@ -31,13 +32,10 @@ from pathlib import Path from typing import Any -from eval_framework import selected +from eval_framework import current_framework, framework_agent_module -framework = selected() -if framework == "harbor": - from harbor.agents.base import BaseAgent -else: - from pier.agents.base import BaseAgent +framework = current_framework() +BaseAgent = importlib.import_module(framework_agent_module(framework)).BaseAgent class RelayTransportClosed(RuntimeError): diff --git a/packages/eval/harbor/run_trial.py b/packages/eval/harbor/run_trial.py index 9419ef67a16..2592d9b8578 100644 --- a/packages/eval/harbor/run_trial.py +++ b/packages/eval/harbor/run_trial.py @@ -120,7 +120,13 @@ async def run_trial(framework: str, expected_version: str, config_file: Path) -> from eval_framework import install install(framework) - distribution = {"harbor": "harbor", "pier": "datacurve-pier"}[framework] + await _run_selected_trial(framework, expected_version, config_file) + + +async def _run_selected_trial(framework: str, expected_version: str, config_file: Path) -> None: + from eval_framework import framework_distribution + + distribution = framework_distribution(framework) if importlib.metadata.version(distribution) != expected_version: raise FrameworkVersionMismatch try: @@ -184,10 +190,11 @@ async def create_harbor_trial(trial_type: type, config: object) -> object: async def main() -> None: - from eval_framework import install - framework, expected_version, config_path = sys.argv[1:] - install(framework) + await _run_main(framework, expected_version, config_path) + + +async def _run_main(framework: str, expected_version: str, config_path: str) -> None: task = asyncio.current_task() assert task is not None loop = asyncio.get_running_loop() diff --git a/packages/eval/harbor/test_egress_audit_journal.py b/packages/eval/harbor/test_egress_audit_journal.py new file mode 100644 index 00000000000..fd903b24405 --- /dev/null +++ b/packages/eval/harbor/test_egress_audit_journal.py @@ -0,0 +1,103 @@ +# 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 importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +MODULE_PATH = Path(__file__).with_name("egress_filter.py") +SPEC = importlib.util.spec_from_file_location("maka_eval_egress_journal", MODULE_PATH) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class EgressAuditJournalTest(unittest.TestCase): + def journal(self, path: Path, limit: int | None = None): + return MODULE.AuditJournal(path, limit or MODULE.MAX_AUDIT_BYTES) + + def test_serializes_one_ascii_json_record_per_line(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "hits.jsonl" + normalized_path = "/tasks/\u2028hidden" + journal = self.journal(path) + + journal.record("tbench_domain", "tbench.ai", normalized_path) + + raw = path.read_text(encoding="utf-8") + self.assertEqual(raw.count("\n"), 1) + self.assertNotIn("\u2028", raw) + self.assertIn("\\u2028", raw) + self.assertEqual(json.loads(raw)["normalizedPath"], normalized_path) + self.assertFalse(journal.has_full_marker()) + + def test_appends_exactly_one_marker_after_capacity_is_exhausted(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "hits.jsonl" + path.write_bytes(b"x" * MODULE.MAX_AUDIT_BYTES) + journal = self.journal(path) + + journal.record("tbench_domain", "tbench.ai", "/tasks") + size_with_marker = path.stat().st_size + journal.record("tbench_domain", "tbench.ai", "/other") + + self.assertEqual(path.stat().st_size, size_with_marker) + markers = [ + record + for record in self.json_records(path) + if record.get("ruleId") == "audit_truncated" + ] + self.assertEqual(len(markers), 1) + + def test_marks_a_record_that_would_cross_the_limit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "hits.jsonl" + path.write_bytes(b"{}\n" * 5) + journal = self.journal(path, path.stat().st_size + 1) + + journal.record("tbench_domain", "tbench.ai", "/tasks") + + self.assertEqual(self.json_records(path)[-1]["ruleId"], "audit_truncated") + self.assertTrue(journal.has_full_marker()) + + def test_non_object_or_broken_tail_is_not_a_marker(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "hits.jsonl" + for tail in ('123\n"x"\n', '{broken\n'): + with self.subTest(tail=tail): + path.write_text(tail) + journal = self.journal(path) + self.assertFalse(journal.has_full_marker()) + journal.mark_full() + self.assertTrue(journal.has_full_marker()) + + @staticmethod + def json_records(path: Path) -> list[dict[str, object]]: + records = [] + for line in path.read_text().splitlines(): + if not line.startswith("{"): + continue + decoded = json.loads(line) + if isinstance(decoded, dict): + records.append(decoded) + return records + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/eval/harbor/test_egress_filter.py b/packages/eval/harbor/test_egress_filter.py index 9e786997113..44b9508fabe 100644 --- a/packages/eval/harbor/test_egress_filter.py +++ b/packages/eval/harbor/test_egress_filter.py @@ -74,65 +74,73 @@ def test_preserves_unrelated_network_and_rejects_malformed_urls(self) -> None: with self.assertRaises(ValueError, msg=url): MODULE.contamination_rule(url) - def _install_response_stub(self) -> None: - class Response: + def _enable_http_responses(self) -> None: + class FakeResponse: @staticmethod def make(status, body, headers): return {"status": status, "body": body, "headers": headers} - MODULE.http = SimpleNamespace(Response=Response) + MODULE.http = SimpleNamespace(Response=FakeResponse) + + @staticmethod + def _request(url: str): + flow = SimpleNamespace(request=SimpleNamespace(pretty_url=url)) + MODULE.request(flow) + return flow - def test_request_blocks_a_contamination_url_and_appends_one_audit_record(self) -> None: + def test_http_request_outcomes_are_auditable_and_fail_closed(self) -> None: + expected = { + "https://tbench.ai/tasks": (451, "tbench_domain"), + "https://example.com/": (None, None), + "https://example.test/%ZZ": (503, "policy_error"), + } with tempfile.TemporaryDirectory() as directory: - self._install_response_stub() MODULE.AUDIT_PATH = Path(directory) / "hits.jsonl" - flow = type( - "Flow", - (), - {"request": type("Request", (), {"pretty_url": "https://tbench.ai/tasks"})()}, - )() - MODULE.request(flow) - self.assertEqual(flow.response["status"], 451) + self._enable_http_responses() + for url, (status, rule_id) in expected.items(): + with self.subTest(url=url): + flow = self._request(url) + if status is None: + self.assertNotIn("response", vars(flow)) + continue + self.assertEqual(flow.response["status"], status) + self.assertEqual( + flow.response["headers"]["X-Maka-Eval-Egress-Rule"], rule_id + ) + records = [json.loads(line) for line in MODULE.AUDIT_PATH.read_text().splitlines()] self.assertEqual( - flow.response["headers"]["X-Maka-Eval-Egress-Rule"], "tbench_domain" + [record["ruleId"] for record in records], + ["tbench_domain", "policy_error"], + ) + self.assertEqual( + (records[0]["host"], records[0]["normalizedPath"]), + ("tbench.ai", "/tasks"), ) - lines = MODULE.AUDIT_PATH.read_text().splitlines() - self.assertEqual(len(lines), 1) - record = json.loads(lines[0]) - self.assertEqual(record["ruleId"], "tbench_domain") - self.assertEqual(record["host"], "tbench.ai") - self.assertEqual(record["normalizedPath"], "/tasks") - - def test_request_leaves_an_unrelated_url_unanswered(self) -> None: - with tempfile.TemporaryDirectory() as directory: - self._install_response_stub() - MODULE.AUDIT_PATH = Path(directory) / "hits.jsonl" - flow = type( - "Flow", - (), - {"request": type("Request", (), {"pretty_url": "https://example.com/"})()}, - )() - MODULE.request(flow) - self.assertFalse(hasattr(flow, "response")) - self.assertFalse(MODULE.AUDIT_PATH.exists()) - def test_audit_is_bounded_and_policy_errors_fail_closed(self) -> None: + def test_connect_classification_blocks_only_contamination_targets(self) -> None: with tempfile.TemporaryDirectory() as directory: - self._install_response_stub() + self._enable_http_responses() MODULE.AUDIT_PATH = Path(directory) / "hits.jsonl" - flow = type( - "Flow", - (), - {"request": type("Request", (), {"pretty_url": "https://example.test/%ZZ"})()}, - )() - MODULE.request(flow) - self.assertEqual(flow.response["status"], 503) + for host, expected_status in ( + ("tbench.ai", 451), + ("example.com", None), + ("github.com", None), + ("ssh.github.com", None), + ): + with self.subTest(host=host): + flow = SimpleNamespace( + request=SimpleNamespace(host=host, port=443), response=None + ) + MODULE.http_connect(flow) + actual = None if flow.response is None else flow.response["status"] + self.assertEqual(actual, expected_status) record = json.loads(MODULE.AUDIT_PATH.read_text().splitlines()[0]) - self.assertEqual(record["ruleId"], "policy_error") - self.assertIn("host", record) - self.assertIn("normalizedPath", record) + self.assertEqual( + (record["ruleId"], record["host"]), + ("tbench_domain", "tbench.ai"), + ) - def test_http_connect_refuses_blocklisted_hosts_before_the_tunnel_opens(self) -> None: + def test_connect_policy_uses_the_tunnel_target_not_a_spoofed_host_header(self) -> None: class Response: @staticmethod def make(status, body, headers): @@ -141,65 +149,95 @@ def make(status, body, headers): with tempfile.TemporaryDirectory() as directory: MODULE.http = SimpleNamespace(Response=Response) MODULE.AUDIT_PATH = Path(directory) / "hits.jsonl" - blocked = type( - "Flow", - (), - { - "request": type( - "Request", - (), - {"pretty_host": "tbench.ai", "host": "tbench.ai", "port": 443}, - )() - }, - )() - MODULE.http_connect(blocked) - self.assertEqual(blocked.response["status"], 451) - self.assertEqual(blocked.response["headers"]["X-Maka-Eval-Egress-Rule"], "tbench_domain") - record = json.loads(MODULE.AUDIT_PATH.read_text().splitlines()[0]) - self.assertEqual(record["ruleId"], "tbench_domain") - self.assertEqual(record["host"], "tbench.ai") - - for host in ("example.com", "github.com", "ssh.github.com"): - allowed = type( - "Flow", - (), - { - "request": type( - "Request", - (), - {"pretty_host": host, "host": host, "port": 443}, - )(), - "response": None, - }, - )() - MODULE.http_connect(allowed) - self.assertIsNone(allowed.response, host) - - def test_tcp_start_kills_raw_tunnels_and_records_them(self) -> None: + flow = SimpleNamespace( + request=SimpleNamespace( + host="tbench.ai", pretty_host="example.com", port=443 + ), + response=None, + ) + + MODULE.http_connect(flow) + + self.assertEqual(flow.response["status"], 451) + self.assertEqual( + json.loads(MODULE.AUDIT_PATH.read_text().splitlines()[0])["host"], + "tbench.ai", + ) + + flow.request.host = "" + flow.response = None + MODULE.http_connect(flow) + self.assertEqual(flow.response["status"], 503) + + def test_connect_rejects_malformed_authorities_and_ports(self) -> None: + self._enable_http_responses() with tempfile.TemporaryDirectory() as directory: MODULE.AUDIT_PATH = Path(directory) / "hits.jsonl" - killed: list[str] = [] - flow = type( - "Flow", - (), - {"server_conn": SimpleNamespace(address=("ssh.github.com", 443))}, - )() - flow.kill = lambda: killed.append("killed") + for host, port in ( + ("tbench.ai@safe.example", 443), + ("safe.example/path", 443), + ("safe.example", 0), + ("safe.example", 65536), + ): + with self.subTest(host=host, port=port): + flow = SimpleNamespace( + request=SimpleNamespace(host=host, port=port), response=None + ) + MODULE.http_connect(flow) + self.assertEqual(flow.response["status"], 503) + + valid = SimpleNamespace( + request=SimpleNamespace(host="2001:db8::1", port=8443), response=None + ) + MODULE.http_connect(valid) + self.assertIsNone(valid.response) + + def test_connect_target_normalization_is_pure_and_fail_closed(self) -> None: + target = MODULE.parse_connect_target("example.com", 8443) + self.assertEqual( + (target.host, target.port, target.url), + ("example.com", 8443, "https://example.com:8443/"), + ) + self.assertEqual(MODULE.connect_url("example.com", None), "https://example.com/") + self.assertEqual(MODULE.connect_url("example.com", 80), "http://example.com/") + self.assertEqual(MODULE.connect_url("example.com", 8443), "https://example.com:8443/") + self.assertEqual(MODULE.connect_url("2001:db8::1", 443), "https://[2001:db8::1]/") + for host, port in ( + ("user@example.com", 443), + ("example.com/path", 443), + (" example.com", 443), + ("example.com", True), + ("example.com", 65536), + ): + with self.subTest(host=host, port=port): + with self.assertRaises(ValueError): + MODULE.connect_url(host, port) + + def test_connect_adapter_does_not_accept_url_semantics_from_the_caller(self) -> None: + for host in ("https://tbench.ai", "tbench.ai/path", "tbench.ai?query=1"): + with self.subTest(host=host): + with self.assertRaises(ValueError): + MODULE.parse_connect_target(host, 443) + + def test_raw_tcp_hooks_erase_payload_close_flow_and_record_peer(self) -> None: + with tempfile.TemporaryDirectory() as directory: + MODULE.AUDIT_PATH = Path(directory) / "hits.jsonl" + closed: list[str] = [] + flow = SimpleNamespace( + server_conn=SimpleNamespace(address=("ssh.github.com", 443)), + messages=[SimpleNamespace(content=b"SSH-2.0-test\r\n")], + killable=True, + ) + flow.kill = lambda: closed.append("closed") MODULE.tcp_start(flow) - self.assertEqual(killed, ["killed"]) + MODULE.tcp_message(flow) + self.assertEqual(closed, ["closed", "closed"]) + self.assertEqual(flow.messages[-1].content, b"") record = json.loads(MODULE.AUDIT_PATH.read_text().splitlines()[0]) - self.assertEqual(record["ruleId"], "raw_tunnel") - self.assertEqual(record["host"], "ssh.github.com") - self.assertEqual(record["normalizedPath"], ":443") - - def test_tcp_message_drops_raw_payloads(self) -> None: - message = SimpleNamespace(content=b"SSH-2.0-test\r\n") - killed: list[str] = [] - flow = SimpleNamespace(messages=[message], killable=True) - flow.kill = lambda: killed.append("killed") - MODULE.tcp_message(flow) - self.assertEqual(message.content, b"") - self.assertEqual(killed, ["killed"]) + self.assertEqual( + (record["ruleId"], record["host"], record["normalizedPath"]), + ("raw_tunnel", "ssh.github.com", ":443"), + ) def test_next_layer_closes_raw_tcp_before_the_builtin_classifier(self) -> None: class CloseConnection: @@ -222,7 +260,7 @@ def data_client() -> bytes: layer=None, context=context, data_client=data_client, data_server=lambda: b"" ) MODULE.next_layer(nextlayer) - self.assertIsInstance(nextlayer.layer, MODULE.CloseRawLayer) + self.assertIsInstance(nextlayer.layer, MODULE.RejectRawTransport) commands = list(nextlayer.layer.handle_event(object())) self.assertEqual([command.connection for command in commands], [client, server]) record = json.loads(MODULE.AUDIT_PATH.read_text().splitlines()[0]) @@ -322,7 +360,7 @@ def test_next_layer_closes_bytes_that_cannot_become_http(self) -> None: data_server=lambda: b"", ) MODULE.next_layer(binary) - self.assertIsInstance(binary.layer, MODULE.CloseRawLayer) + self.assertIsInstance(binary.layer, MODULE.RejectRawTransport) def test_response_audits_a_non_websocket_101_upgrade(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -399,7 +437,7 @@ def __init__(self, connection: object) -> None: current = FakeTCPLayer(context) nextlayer = SimpleNamespace(layer=current, context=context) MODULE.next_layer(nextlayer) - self.assertIsInstance(nextlayer.layer, MODULE.CloseRawLayer) + self.assertIsInstance(nextlayer.layer, MODULE.RejectRawTransport) self.assertIsInstance(nextlayer.layer, MODULE.Layer) self.assertEqual(context.layers, [sibling, nextlayer.layer]) self.assertNotIn(current, context.layers) @@ -426,52 +464,5 @@ class HTTPLayer: MODULE.next_layer(nextlayer) self.assertIs(nextlayer.layer, original) - def test_audit_escapes_line_separators_so_python_and_typescript_agree(self) -> None: - with tempfile.TemporaryDirectory() as directory: - MODULE.AUDIT_PATH = Path(directory) / "hits.jsonl" - path = "/tasks/\u2028hidden" - MODULE.append_audit("tbench_domain", "tbench.ai", path) - raw = MODULE.AUDIT_PATH.read_text(encoding="utf-8") - self.assertNotIn("\u2028", raw) - self.assertIn("\\u2028", raw) - self.assertEqual(raw.count("\n"), 1) - self.assertEqual(len(raw.splitlines()), 1) - record = json.loads(raw) - self.assertEqual(record["normalizedPath"], path) - self.assertFalse(MODULE.audit_already_truncated()) - - def test_audit_writes_one_truncation_marker_when_the_byte_limit_is_reached(self) -> None: - with tempfile.TemporaryDirectory() as directory: - MODULE.AUDIT_PATH = Path(directory) / "hits.jsonl" - MODULE.AUDIT_PATH.write_bytes(b"x" * MODULE.MAX_AUDIT_BYTES) - MODULE.append_audit("tbench_domain", "tbench.ai", "/tasks") - records = [ - json.loads(line) - for line in MODULE.AUDIT_PATH.read_text().splitlines() - if line.startswith("{") - ] - self.assertEqual(records[-1]["ruleId"], "audit_truncated") - size_after_marker = MODULE.AUDIT_PATH.stat().st_size - MODULE.append_audit("tbench_domain", "tbench.ai", "/other") - self.assertEqual(MODULE.AUDIT_PATH.stat().st_size, size_after_marker) - records_after = [ - json.loads(line) - for line in MODULE.AUDIT_PATH.read_text().splitlines() - if line.startswith("{") - ] - self.assertEqual( - [record["ruleId"] for record in records_after if record["ruleId"] == "audit_truncated"], - ["audit_truncated"], - ) - - def test_truncation_probe_ignores_non_object_json_tails(self) -> None: - with tempfile.TemporaryDirectory() as directory: - MODULE.AUDIT_PATH = Path(directory) / "hits.jsonl" - MODULE.AUDIT_PATH.write_text('123\n"x"\n') - self.assertFalse(MODULE.audit_already_truncated()) - MODULE.write_truncation_marker() - self.assertTrue(MODULE.audit_already_truncated()) - - if __name__ == "__main__": unittest.main() diff --git a/packages/eval/harbor/test_egress_filter_live.py b/packages/eval/harbor/test_egress_filter_live.py index a0b3734a38f..87686475a90 100644 --- a/packages/eval/harbor/test_egress_filter_live.py +++ b/packages/eval/harbor/test_egress_filter_live.py @@ -490,9 +490,14 @@ def http_via_proxy( return cls._recv_until_close(sock) @classmethod - def connect_via_proxy(cls, host: str, port: int, payload: bytes = b"CLIENT\n") -> tuple[bytes, bytes]: + def connect_via_proxy( + cls, host: str, port: int, payload: bytes = b"CLIENT\n", host_header: str | None = None + ) -> tuple[bytes, bytes]: with socket.create_connection(("127.0.0.1", cls.proxy_port), 5) as sock: - sock.sendall(f"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n".encode()) + authority = host_header or f"{host}:{port}" + sock.sendall( + f"CONNECT {host}:{port} HTTP/1.1\r\nHost: {authority}\r\n\r\n".encode() + ) header = b"" sock.settimeout(CLOSE_TIMEOUT_S) while b"\r\n\r\n" not in header: @@ -642,6 +647,13 @@ def test_connect_to_a_blocklisted_host_is_451(self) -> None: self.assertIn(b"451", header.split(b"\r\n", 1)[0]) self.assertIn(b"tbench_domain", header) + def test_connect_target_is_blocked_even_with_a_different_host_header(self) -> None: + header, _ = self.connect_via_proxy( + "tbench.ai", 443, b"", host_header="example.com:443" + ) + self.assertIn(b"451", header.split(b"\r\n", 1)[0]) + self.assertIn(b"tbench_domain", header) + def test_raw_connect_relays_no_bytes_and_is_audited(self) -> None: closed_before = self.origin_stats()["raw_closed"] header, body = self.connect_via_proxy("origin", 19081) diff --git a/packages/eval/harbor/test_eval_framework.py b/packages/eval/harbor/test_eval_framework.py index 0a84008f0dc..2967001ca31 100644 --- a/packages/eval/harbor/test_eval_framework.py +++ b/packages/eval/harbor/test_eval_framework.py @@ -20,34 +20,67 @@ from pathlib import Path -class EvalFrameworkTest(unittest.TestCase): - def test_selected_fails_closed_before_install(self) -> None: - module = self._load_fresh() +def fresh_authority(): + source = Path(__file__).with_name("eval_framework.py") + spec = importlib.util.spec_from_file_location("isolated_eval_framework", source) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class FrameworkAuthorityTest(unittest.TestCase): + def test_unbound_context_has_no_implicit_default(self) -> None: + authority = fresh_authority() + with self.assertRaisesRegex(RuntimeError, "not installed"): - module.selected() + authority.current_framework() + + def test_supported_names_and_distributions_are_explicit(self) -> None: + authority = fresh_authority() + expected = { + "harbor": ("harbor", "harbor", "harbor.agents.base"), + "pier": ("pier", "datacurve-pier", "pier.agents.base"), + } + + for name, (expected_name, distribution, agent_module) in expected.items(): + with self.subTest(name=name): + spec = authority.framework_spec(name) + self.assertEqual( + (spec.name, spec.distribution, spec.agent_module), + (expected_name, distribution, agent_module), + ) + authority.install(name) + self.assertEqual(authority.current_framework(), name) + self.assertEqual(authority.framework_distribution(name), distribution) + self.assertEqual(authority.framework_agent_module(name), agent_module) - def test_invalid_framework_fails_closed(self) -> None: - module = self._load_fresh() + def test_invalid_names_cannot_replace_the_process_selection(self) -> None: + authority = fresh_authority() + invalid_operations = ( + lambda: authority.install("other"), + lambda: authority.framework_distribution("other"), + ) + + for operation in invalid_operations: + with self.subTest(operation=operation): + with self.assertRaisesRegex(RuntimeError, "harbor or pier"): + operation() + with self.assertRaisesRegex(RuntimeError, "not installed"): + authority.current_framework() + + def test_invalid_spec_lookup_does_not_change_an_installed_framework(self) -> None: + authority = fresh_authority() + authority.install("harbor") with self.assertRaisesRegex(RuntimeError, "harbor or pier"): - module.install("other") - with self.assertRaisesRegex(RuntimeError, "not installed"): - module.selected() - - def test_install_selects_harbor_and_pier(self) -> None: - module = self._load_fresh() - module.install("harbor") - self.assertEqual(module.selected(), "harbor") - module.install("pier") - self.assertEqual(module.selected(), "pier") - - @staticmethod - def _load_fresh(): - path = Path(__file__).with_name("eval_framework.py") - spec = importlib.util.spec_from_file_location("maka_eval_framework_under_test", path) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module + authority.framework_spec("other") + self.assertEqual(authority.current_framework(), "harbor") + + def test_install_replaces_the_process_selection(self) -> None: + authority = fresh_authority() + authority.install("harbor") + authority.install("pier") + self.assertEqual(authority.current_framework(), "pier") if __name__ == "__main__": diff --git a/packages/eval/harbor/test_relay_contract.py b/packages/eval/harbor/test_relay_contract.py index 6d44fb169fb..248431870fd 100644 --- a/packages/eval/harbor/test_relay_contract.py +++ b/packages/eval/harbor/test_relay_contract.py @@ -764,17 +764,24 @@ async def exec(self, command, cwd=None, env=None, timeout_sec=None, user=None): self.assertIn("pin the Eval egress proxy hostname", str(raised.exception)) -class FrameworkSelectionTest(unittest.TestCase): - def test_relay_loads_harbor_and_pier_from_the_installed_selection(self): - for framework in ("harbor", "pier"): - with self.subTest(framework): - relay = load_relay(framework) - self.assertEqual(relay.framework, framework) - - def test_relay_does_not_select_a_framework_from_the_environment(self): - os.environ["MAKA_EVAL_FRAMEWORK"] = "pier" - relay = load_relay("harbor") - self.assertEqual(relay.framework, "harbor") +class RelayFrameworkAuthorityTest(unittest.TestCase): + def test_relay_import_uses_only_the_context_selection(self): + cases = ( + ("harbor", None), + ("pier", None), + ("harbor", "pier"), + ) + + for selected, environment_value in cases: + with self.subTest(selected=selected, environment=environment_value): + environment = ( + {"MAKA_EVAL_FRAMEWORK": environment_value} + if environment_value is not None + else {} + ) + with patch.dict(os.environ, environment, clear=True): + relay = load_relay(selected) + self.assertEqual(relay.framework, selected) if __name__ == "__main__": diff --git a/packages/eval/harbor/test_run_trial_policy.py b/packages/eval/harbor/test_run_trial_policy.py index 13e7d398149..e8684fd2b34 100644 --- a/packages/eval/harbor/test_run_trial_policy.py +++ b/packages/eval/harbor/test_run_trial_policy.py @@ -53,26 +53,32 @@ def test_required_egress_fails_closed_without_the_proxy_host(self) -> None: with self.assertRaisesRegex(RuntimeError, "proxy host is unavailable"): MODULE.apply_subject_egress_policy(task) - def test_invalid_framework_fails_before_framework_import(self) -> None: + def test_rejects_unknown_framework_without_loading_its_modules(self) -> None: with patch.object(MODULE.importlib, "import_module") as imported: with self.assertRaisesRegex(RuntimeError, "harbor or pier"): asyncio.run(MODULE.run_trial("other", "1.0.0", Path("missing.json"))) imported.assert_not_called() - def test_main_installs_the_argv_framework_before_the_trial(self) -> None: + def test_trial_installs_its_framework_before_loading_modules(self) -> None: import eval_framework - installed: list[str] = [] + with patch.object(MODULE.importlib.metadata, "version", return_value="different"): + with self.assertRaises(MODULE.FrameworkVersionMismatch): + asyncio.run(MODULE.run_trial("harbor", "1.0.0", Path("missing.json"))) + self.assertEqual(eval_framework.current_framework(), "harbor") + + def test_main_binds_the_argv_framework_during_the_trial(self) -> None: + import eval_framework + + observed: list[tuple[str, str, Path]] = [] async def fake_trial(framework: str, expected_version: str, config_file: Path) -> None: - installed.append(eval_framework.selected()) - self.assertEqual(framework, "pier") - self.assertEqual(expected_version, "1.2.3") + observed.append((framework, expected_version, config_file)) with patch.object(sys, "argv", ["run_trial.py", "pier", "1.2.3", "config.json"]): with patch.object(MODULE, "run_trial", fake_trial): asyncio.run(MODULE.main()) - self.assertEqual(installed, ["pier"]) + self.assertEqual(observed, [("pier", "1.2.3", Path("config.json"))]) if __name__ == "__main__": diff --git a/packages/eval/src/__tests__/egress-audit-harness.test.ts b/packages/eval/src/__tests__/egress-audit-harness.test.ts new file mode 100644 index 00000000000..b4d93c16f77 --- /dev/null +++ b/packages/eval/src/__tests__/egress-audit-harness.test.ts @@ -0,0 +1,295 @@ +/* + * 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 { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { FileAttemptStore } from '../attempt-store.js'; +import { EGRESS_AUDIT_DESTINATION } from '../egress-audit.js'; +import type { ExperimentSpec, JsonObject } from '../experiment.js'; +import { createHarborExecutor } from '../harness-executor.js'; +import { type EvalResultStatus, selectCellResult } from '../result.js'; +import { runExperiment } from '../runner.js'; + +const TEST_REVISION = 'd49e28f1e4ddd13d289e85a5f312a66750951932'; +const EVAL_ROOT = fileURLToPath(new URL('../..', import.meta.url)); + +type AuditMode = 'missing' | 'empty' | 'truncated' | 'policy' | 'unreadable'; + +type Scenario = { + readonly label: string; + readonly mode: AuditMode; + readonly proxy: boolean; + readonly expectedStatus: EvalResultStatus; + readonly failure?: RegExp; + readonly auditField?: readonly ['truncated' | 'policyErrorCount', boolean | number]; + readonly exception?: string; + readonly reward?: number | null; +}; + +const scenarios: readonly Scenario[] = [ + { + label: 'missing required audit excludes the attempt', + mode: 'missing', + proxy: true, + expectedStatus: 'infra_failed', + failure: /egress audit log missing/u, + }, + { + label: 'truncated audit stays scored and visible', + mode: 'truncated', + proxy: true, + expectedStatus: 'completed', + auditField: ['truncated', true], + }, + { + label: 'policy failure stays scored and visible', + mode: 'policy', + proxy: true, + expectedStatus: 'completed', + auditField: ['policyErrorCount', 1], + }, + { + label: 'subject timeout retains subject attribution when evidence exists', + mode: 'truncated', + proxy: true, + exception: 'AgentTimeoutError', + reward: 0, + expectedStatus: 'subject_failed', + }, + { + label: 'missing audit outranks a subject timeout', + mode: 'missing', + proxy: true, + exception: 'AgentTimeoutError', + reward: 0, + expectedStatus: 'infra_failed', + failure: /egress audit log missing/u, + }, + { + label: 'missing audit outranks a missing reward', + mode: 'missing', + proxy: true, + reward: null, + expectedStatus: 'infra_failed', + failure: /egress audit log missing/u, + }, + { + label: 'empty audit proves a clean trial', + mode: 'empty', + proxy: true, + expectedStatus: 'completed', + }, + { + label: 'unreadable audit is infrastructure failure', + mode: 'unreadable', + proxy: true, + expectedStatus: 'infra_failed', + failure: /failed to read egress audit log/u, + }, + { + label: 'proxy-free trial has no audit obligation', + mode: 'missing', + proxy: false, + expectedStatus: 'completed', + }, +]; + +for (const scenario of scenarios) { + test(scenario.label, { timeout: 10_000 }, async (t) => { + const outcome = await executeScenario(t, scenario); + const attempt = outcome.attempts[0]; + assert.ok(attempt); + assert.equal(attempt.result.status, scenario.expectedStatus); + if (scenario.failure) assert.match(attempt.result.failureReason ?? '', scenario.failure); + assert.equal( + outcome.selected?.result.status, + scenario.expectedStatus === 'infra_failed' ? undefined : scenario.expectedStatus, + ); + + const audit = attempt.result.artifacts.find((item) => item.kind === 'egress-audit'); + if (scenario.proxy && scenario.mode !== 'missing' && scenario.mode !== 'unreadable') { + assert.ok(audit); + if (scenario.auditField) assert.equal(audit[scenario.auditField[0]], scenario.auditField[1]); + } else { + assert.equal(audit, undefined); + } + + assert.deepEqual( + outcome.trialArtifacts, + scenario.proxy + ? [ + { + source: '/opt/maka-egress-state/hits.jsonl', + destination: EGRESS_AUDIT_DESTINATION, + service: 'maka-eval-mitmproxy', + }, + ] + : null, + ); + }); +} + +async function executeScenario(t: test.TestContext, scenario: Scenario) { + const root = await mkdtemp(join(tmpdir(), 'maka-egress-harness-')); + t.after(() => rm(root, { recursive: true, force: true })); + const executable = join(root, 'trial-probe.mjs'); + const capturedArtifacts = join(root, 'captured-artifacts.json'); + await writeFile(executable, trialProbe()); + await chmod(executable, 0o755); + + const restore = installEnvironment({ + MAKA_TEST_PYTHON: executable, + MAKA_TEST_TRIALS: root, + MAKA_TEST_BUNDLE: EVAL_ROOT, + MAKA_TEST_AUDIT_MODE: scenario.mode, + MAKA_TEST_CAPTURED_ARTIFACTS: capturedArtifacts, + MAKA_TEST_REWARD: scenario.reward === null ? 'none' : String(scenario.reward ?? 1), + MAKA_TEST_EXCEPTION: scenario.exception ?? '', + }); + t.after(restore); + + const store = new FileAttemptStore(join(root, 'attempts')); + const results = await runExperiment({ + spec: experiment(), + store, + executor: createHarborExecutor(executorConfig(scenario.proxy), join(root, 'experiment.json')), + subjects: [ + { + kind: 'external', + execute: async ({ context }) => { + await context.execute({ command: '/bin/true', args: [], credentialEnvironment: {} }); + return { + usage: null, + costUsd: null, + durationMs: 1, + status: 'completed', + failureReason: null, + artifacts: [], + }; + }, + }, + ], + }); + const attempts = await store.list('task::1::external'); + return { + attempts, + selected: selectCellResult(attempts) ?? results.get('task::1::external'), + trialArtifacts: JSON.parse(await readFile(capturedArtifacts, 'utf8')) as unknown, + }; +} + +function experiment(): ExperimentSpec { + return { + schemaVersion: 'maka.eval.v1', + id: 'experiment', + benchmark: { id: 'benchmark', version: TEST_REVISION, config: { repository: 'repo' } }, + executor: { kind: 'harbor', config: executorConfig(false) }, + execution: { maxConcurrentTaskGroups: 1 }, + subjects: [{ id: 'external', kind: 'external', credentials: [], config: {} }], + tasks: [{ id: 'task', input: 'solve', config: { harbor: { path: 'tasks/task' } } }], + repetitions: 1, + budget: { timeoutMultiplier: 1 }, + verifier: { reward: 'reward' }, + }; +} + +function executorConfig(proxy: boolean): JsonObject { + return { + frameworkVersion: '0.20.0', + pythonPathEnv: 'MAKA_TEST_PYTHON', + trialsRootEnv: 'MAKA_TEST_TRIALS', + environment: {}, + preparationEnvironment: [ + 'MAKA_TEST_AUDIT_MODE', + 'MAKA_TEST_CAPTURED_ARTIFACTS', + 'MAKA_TEST_REWARD', + 'MAKA_TEST_EXCEPTION', + ], + mounts: [], + ...(proxy + ? { + egressProxy: { + composeSourceEnv: 'MAKA_TEST_BUNDLE', + composeRelativePath: 'harbor/docker-compose-egress-proxy.yaml', + networkPolicyRelativePath: 'harbor/egress-proxy/network-policy', + proxyUrl: 'http://maka-eval-mitmproxy:8080', + allowedHost: 'maka-eval-mitmproxy', + containerCaPath: '/opt/maka-egress/mitmproxy-ca-cert.pem', + }, + } + : {}), + }; +} + +function installEnvironment(values: Readonly>): () => void { + const previous = new Map(Object.keys(values).map((name) => [name, process.env[name]])); + Object.assign(process.env, values); + return () => { + for (const [name, value] of previous) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }; +} + +function trialProbe(): string { + return `#!/usr/bin/env node +import { once } from 'node:events'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createConnection } from 'node:net'; +import { createInterface } from 'node:readline'; + +const config = JSON.parse(await readFile(process.argv.at(-1), 'utf8')); +const socket = createConnection(config.agent.kwargs.relay_port, config.agent.kwargs.relay_host); +const messages = createInterface({ input: socket, crlfDelay: Infinity })[Symbol.asyncIterator](); +await once(socket, 'connect'); +const send = (message) => socket.write(JSON.stringify({ token: config.agent.kwargs.relay_token, ...message }) + '\\n'); +send({ kind: 'ready', instruction: 'solve', cwd: '/workspace' }); +await messages.next(); +send({ kind: 'executed', termination: 'exited', exitCode: 0, stdout: '', diagnostic: { category: 'none' } }); +await messages.next(); + +const trial = new URL(config.trial_name + '/', new URL('file://' + config.trials_dir + '/')); +await mkdir(trial, { recursive: true }); +const reward = process.env.MAKA_TEST_REWARD; +const exception = process.env.MAKA_TEST_EXCEPTION; +await writeFile(new URL('result.json', trial), JSON.stringify({ + verifier_result: { rewards: reward === 'none' ? {} : { reward: Number(reward) } }, + ...(exception ? { exception_info: { exception_type: exception } } : {}), +})); +await writeFile(process.env.MAKA_TEST_CAPTURED_ARTIFACTS, JSON.stringify(config.artifacts ?? null)); + +const mode = process.env.MAKA_TEST_AUDIT_MODE; +if (mode !== 'missing') { + const destination = config.artifacts?.[0]?.destination ?? 'egress-hits.jsonl'; + const artifacts = new URL('artifacts/', trial); + const audit = new URL(destination, artifacts); + await mkdir(artifacts, { recursive: true }); + if (mode === 'unreadable') await mkdir(audit); + else if (mode === 'empty') await writeFile(audit, ''); + else if (mode === 'truncated') await writeFile(audit, '{"ruleId":"tbench_domain"}\\n{"ruleId":"audit_truncated"}\\n'); + else if (mode === 'policy') await writeFile(audit, '{"ruleId":"policy_error"}\\n'); +} +socket.end(); +`; +} diff --git a/packages/eval/src/__tests__/egress-audit-unit.test.ts b/packages/eval/src/__tests__/egress-audit-unit.test.ts new file mode 100644 index 00000000000..cbfbb36e37a --- /dev/null +++ b/packages/eval/src/__tests__/egress-audit-unit.test.ts @@ -0,0 +1,146 @@ +/* + * 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 { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + describeEgressAudit, + EGRESS_AUDIT_ARTIFACT_PATH, + readEgressAuditEvidence, +} from '../egress-audit.js'; + +type Summary = { + readonly truncated: boolean; + readonly policyErrorCount: number; + readonly malformedLineCount: number; +}; + +const emptySummary: Summary = { + truncated: false, + policyErrorCount: 0, + malformedLineCount: 0, +}; + +const auditCases: ReadonlyArray<{ + readonly label: string; + readonly bytes: Buffer; + readonly summary: Summary; +}> = [ + { label: 'empty audit', bytes: Buffer.alloc(0), summary: emptySummary }, + { + label: 'truncation marker', + bytes: records({ ruleId: 'tbench_domain' }, { ruleId: 'audit_truncated' }), + summary: { ...emptySummary, truncated: true }, + }, + { + label: 'repeated policy failures', + bytes: records({ ruleId: 'policy_error' }, { ruleId: 'policy_error' }), + summary: { ...emptySummary, policyErrorCount: 2 }, + }, + { + label: 'malformed and scalar JSON', + bytes: Buffer.from('{broken\n123\n"scalar"\n'), + summary: { ...emptySummary, malformedLineCount: 3 }, + }, + { + label: 'invalid UTF-8', + bytes: Buffer.from([0x7b, 0xff, 0x7d, 0x0a]), + summary: { ...emptySummary, malformedLineCount: 1 }, + }, +]; + +for (const scenario of auditCases) { + test(`describes ${scenario.label} without changing trial attribution`, () => { + assert.deepEqual(describeEgressAudit(scenario.bytes, true), { + failureReason: null, + artifacts: [artifact(scenario.bytes, scenario.summary)], + }); + }); +} + +test('distinguishes a missing required journal from an optional journal', () => { + assert.deepEqual(describeEgressAudit(undefined, true), { + failureReason: 'egress audit log missing', + artifacts: [{ kind: 'egress-audit-missing', path: EGRESS_AUDIT_ARTIFACT_PATH }], + }); + assert.deepEqual(describeEgressAudit(undefined, false), { + failureReason: null, + artifacts: [], + }); +}); + +test('reads the trial journal through the canonical artifact path', async (t) => { + const trial = await temporaryTrial(t); + const path = join(trial, EGRESS_AUDIT_ARTIFACT_PATH); + const bytes = records({ ruleId: 'policy_error' }); + await mkdir(join(trial, 'artifacts'), { recursive: true }); + await writeFile(path, bytes); + + assert.deepEqual(await readEgressAuditEvidence(trial, true), { + failureReason: null, + artifacts: [artifact(bytes, { ...emptySummary, policyErrorCount: 1 })], + }); +}); + +test('classifies absent and unreadable trial journals separately', async (t) => { + const trial = await temporaryTrial(t); + assert.equal( + (await readEgressAuditEvidence(trial, true)).failureReason, + 'egress audit log missing', + ); + + const path = join(trial, EGRESS_AUDIT_ARTIFACT_PATH); + await mkdir(path, { recursive: true }); + const unreadable = await readEgressAuditEvidence(trial, true); + assert.match(unreadable.failureReason ?? '', /failed to read egress audit log/u); + assert.deepEqual(unreadable.artifacts, [ + { kind: 'egress-audit-unreadable', path: EGRESS_AUDIT_ARTIFACT_PATH }, + ]); +}); + +test('does not touch the filesystem when audit collection is disabled', async () => { + assert.deepEqual(await readEgressAuditEvidence('/path/that/does/not/exist', false), { + failureReason: null, + artifacts: [], + }); +}); + +function records(...items: readonly object[]): Buffer { + return Buffer.from(items.map((item) => JSON.stringify(item)).join('\n') + '\n'); +} + +function artifact(bytes: Buffer, summary: Summary) { + return { + kind: 'egress-audit', + path: EGRESS_AUDIT_ARTIFACT_PATH, + bytes: bytes.byteLength, + sha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + ...summary, + }; +} + +async function temporaryTrial(t: test.TestContext): Promise { + const path = await mkdtemp(join(tmpdir(), 'maka-egress-audit-')); + t.after(() => rm(path, { recursive: true, force: true })); + return path; +} diff --git a/packages/eval/src/__tests__/egress-audit.test.ts b/packages/eval/src/__tests__/egress-audit.test.ts deleted file mode 100644 index 4d011a5ec7d..00000000000 --- a/packages/eval/src/__tests__/egress-audit.test.ts +++ /dev/null @@ -1,466 +0,0 @@ -/* - * 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 { createHash } from 'node:crypto'; -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { FileAttemptStore } from '../attempt-store.js'; -import type { ExperimentSpec, JsonObject } from '../experiment.js'; -import { - collectEgressAuditArtifact, - createHarborExecutor, - EGRESS_AUDIT_ARTIFACT_PATH, - EGRESS_AUDIT_DESTINATION, -} from '../harness-executor.js'; -import { selectCellResult } from '../result.js'; -import { runExperiment } from '../runner.js'; - -const TEST_REVISION = 'd49e28f1e4ddd13d289e85a5f312a66750951932'; -const EVAL_ROOT = fileURLToPath(new URL('../..', import.meta.url)); - -function inventory( - audit: Buffer, - truncated: boolean, - policyErrorCount: number, - malformedLineCount = 0, -) { - return { - missing: false, - failureReason: null, - artifacts: [ - { - kind: 'egress-audit', - path: EGRESS_AUDIT_ARTIFACT_PATH, - bytes: audit.byteLength, - sha256: `sha256:${createHash('sha256').update(audit).digest('hex')}`, - truncated, - policyErrorCount, - malformedLineCount, - }, - ], - }; -} - -test('present egress audit is inventoried with a sha256 prefix', () => { - const audit = Buffer.from('{"ruleId":"tbench_domain"}\n'); - assert.deepEqual(collectEgressAuditArtifact(audit, true), inventory(audit, false, 0)); -}); - -test('an empty audit file is a clean trial, not a missing log', () => { - const audit = Buffer.alloc(0); - assert.deepEqual(collectEgressAuditArtifact(audit, true), inventory(audit, false, 0)); -}); - -test('a missing expected audit is explicit evidence, not a clean pass', () => { - assert.deepEqual(collectEgressAuditArtifact(undefined, true), { - missing: true, - failureReason: 'egress audit log missing', - artifacts: [{ kind: 'egress-audit-missing', path: EGRESS_AUDIT_ARTIFACT_PATH }], - }); -}); - -test('a truncated audit is recorded on the artifact, not judged as infra_failed', () => { - const audit = Buffer.from( - '{"ruleId":"tbench_domain"}\n{"ruleId":"audit_truncated","host":"","normalizedPath":""}\n', - ); - assert.deepEqual(collectEgressAuditArtifact(audit, true), inventory(audit, true, 0)); -}); - -test('policy_error records are counted without changing attribution', () => { - const audit = Buffer.from('{"ruleId":"policy_error","host":"","normalizedPath":"ValueError"}\n'); - assert.deepEqual(collectEgressAuditArtifact(audit, true), inventory(audit, false, 1)); -}); - -test('a policy_error followed by a later hit still counts the policy error', () => { - const audit = Buffer.from( - '{"ruleId":"policy_error","host":"","normalizedPath":"ValueError"}\n{"ruleId":"tbench_domain","host":"tbench.ai","normalizedPath":"/tasks"}\n', - ); - assert.deepEqual(collectEgressAuditArtifact(audit, true), inventory(audit, false, 1)); -}); - -test('truncation and an earlier policy_error are both recorded', () => { - const audit = Buffer.from( - '{"ruleId":"policy_error"}\n{"ruleId":"audit_truncated","host":"","normalizedPath":""}\n', - ); - assert.deepEqual(collectEgressAuditArtifact(audit, true), inventory(audit, true, 1)); -}); - -test('two policy_error records increment the count rather than pinning it at one', () => { - const audit = Buffer.from( - '{"ruleId":"policy_error","host":"","normalizedPath":"ValueError"}\n{"ruleId":"policy_error","host":"","normalizedPath":"ValueError"}\n', - ); - assert.deepEqual(collectEgressAuditArtifact(audit, true), inventory(audit, false, 2)); -}); - -test('unparseable and non-object lines are counted without changing attribution', () => { - const audit = Buffer.from('{"ruleId":"tbench_domain"}\n{not json\ngarbage\n123\n"x"\n'); - assert.deepEqual(collectEgressAuditArtifact(audit, true), inventory(audit, false, 0, 4)); -}); - -test('invalid UTF-8 is counted as a malformed line, not judged', () => { - const audit = Buffer.from([0x7b, 0xff, 0x7d, 0x0a]); - assert.deepEqual(collectEgressAuditArtifact(audit, true), inventory(audit, false, 0, 1)); -}); - -test('trials without an egress proxy do not invent a missing-audit artifact', () => { - assert.deepEqual(collectEgressAuditArtifact(undefined, false), { - missing: false, - failureReason: null, - artifacts: [], - }); -}); - -test('a missing expected audit through runAttempt is infra_failed and excluded', { - timeout: 10_000, -}, async () => { - const { attempts, selected, trialConfig } = await runHarborTrial({ - auditMode: 'missing', - egressProxy: true, - }); - assert.equal(attempts[0]?.result.status, 'infra_failed'); - assert.equal(attempts[0]?.result.failureReason, 'egress audit log missing'); - assert.equal(selected, undefined); - assert.deepEqual(trialConfig, [ - { - source: '/opt/maka-egress-state/hits.jsonl', - destination: EGRESS_AUDIT_DESTINATION, - service: 'maka-eval-mitmproxy', - }, - ]); -}); - -test('a truncated audit through runAttempt stays scored', { timeout: 10_000 }, async () => { - const { attempts, selected } = await runHarborTrial({ - auditMode: 'truncated', - egressProxy: true, - }); - assert.equal(attempts[0]?.result.status, 'completed'); - assert.equal(attempts[0]?.result.score, 1); - assert.equal(selected?.result.status, 'completed'); - assert.deepEqual(egressAuditArtifact(attempts[0]!.result.artifacts), { - truncated: true, - policyErrorCount: 0, - malformedLineCount: 0, - }); -}); - -test('a policy_error audit through runAttempt stays scored', { timeout: 10_000 }, async () => { - const { attempts, selected } = await runHarborTrial({ - auditMode: 'policy', - egressProxy: true, - }); - assert.equal(attempts[0]?.result.status, 'completed'); - assert.equal(selected?.result.status, 'completed'); - assert.deepEqual(egressAuditArtifact(attempts[0]!.result.artifacts), { - truncated: false, - policyErrorCount: 1, - malformedLineCount: 0, - }); -}); - -test('a policy_error then a later hit through runAttempt stays scored', { - timeout: 10_000, -}, async () => { - const { attempts, selected } = await runHarborTrial({ - auditMode: 'policy-then-hit', - egressProxy: true, - }); - assert.equal(attempts[0]?.result.status, 'completed'); - assert.equal(selected?.result.status, 'completed'); - assert.deepEqual(egressAuditArtifact(attempts[0]!.result.artifacts), { - truncated: false, - policyErrorCount: 1, - malformedLineCount: 0, - }); -}); - -test('subject timeout still scores when the audit log is truncated', { - timeout: 10_000, -}, async () => { - const { attempts, selected } = await runHarborTrial({ - auditMode: 'truncated', - egressProxy: true, - exceptionType: 'AgentTimeoutError', - reward: 0, - }); - assert.equal(attempts[0]?.result.status, 'subject_failed'); - assert.equal(attempts[0]?.result.score, 0); - assert.equal(selected?.result.status, 'subject_failed'); - assert.deepEqual(egressAuditArtifact(attempts[0]!.result.artifacts), { - truncated: true, - policyErrorCount: 0, - malformedLineCount: 0, - }); -}); - -test('a missing audit wins over a subject timeout', { timeout: 10_000 }, async () => { - const { attempts, selected } = await runHarborTrial({ - auditMode: 'missing', - egressProxy: true, - exceptionType: 'AgentTimeoutError', - reward: 0, - }); - assert.equal(attempts[0]?.result.status, 'infra_failed'); - assert.equal(attempts[0]?.result.failureReason, 'egress audit log missing'); - assert.equal(selected, undefined); -}); - -test('a missing audit wins over a missing reward', { timeout: 10_000 }, async () => { - const { attempts, selected } = await runHarborTrial({ - auditMode: 'missing', - egressProxy: true, - reward: null, - }); - assert.equal(attempts[0]?.result.status, 'infra_failed'); - assert.equal(attempts[0]?.result.failureReason, 'egress audit log missing'); - assert.equal(selected, undefined); -}); - -test('an empty landed audit through runAttempt stays completed', { timeout: 10_000 }, async () => { - const { attempts, selected } = await runHarborTrial({ - auditMode: 'empty', - egressProxy: true, - }); - assert.equal(attempts[0]?.result.status, 'completed'); - assert.equal(attempts[0]?.result.score, 1); - assert.equal(selected?.result.status, 'completed'); -}); - -test('an unreadable expected audit is infra_failed with the artifact path', { - timeout: 10_000, -}, async () => { - const { attempts, selected } = await runHarborTrial({ - auditMode: 'unreadable', - egressProxy: true, - }); - assert.equal(attempts[0]?.result.status, 'infra_failed'); - assert.match( - attempts[0]?.result.failureReason ?? '', - new RegExp(`failed to read egress audit log .*/${EGRESS_AUDIT_ARTIFACT_PATH} \\(EISDIR\\)`), - ); - assert.equal(selected, undefined); - assert.deepEqual( - attempts[0]?.result.artifacts.filter((artifact) => - String(artifact.kind).startsWith('egress-audit'), - ), - [{ kind: 'egress-audit-unreadable', path: EGRESS_AUDIT_ARTIFACT_PATH }], - ); -}); - -test('trials without an egress proxy still complete when the audit file is absent', { - timeout: 10_000, -}, async () => { - const { attempts, selected, trialConfig } = await runHarborTrial({ - auditMode: 'missing', - egressProxy: false, - }); - assert.equal(attempts[0]?.result.status, 'completed'); - assert.equal(selected?.result.status, 'completed'); - assert.equal(trialConfig, null); -}); - -function egressAuditArtifact(artifacts: readonly JsonObject[]) { - const artifact = artifacts.find((item) => item.kind === 'egress-audit'); - assert.ok(artifact, 'expected an egress-audit artifact'); - return { - truncated: artifact.truncated, - policyErrorCount: artifact.policyErrorCount, - malformedLineCount: artifact.malformedLineCount, - }; -} - -async function runHarborTrial(options: { - readonly auditMode: - | 'missing' - | 'empty' - | 'truncated' - | 'policy' - | 'policy-then-hit' - | 'unreadable'; - readonly egressProxy: boolean; - readonly exceptionType?: string; - readonly reward?: number | null; -}) { - const root = await mkdtemp(join(tmpdir(), 'maka-eval-egress-audit-')); - const executable = join(root, 'fake-python.mjs'); - const trialConfigPath = join(root, 'trial-config.json'); - await writeFile( - executable, - `#!/usr/bin/env node -import { connect } from 'node:net'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -const config = JSON.parse(await readFile(process.argv.at(-1), 'utf8')); -const socket = connect(config.agent.kwargs.relay_port, config.agent.kwargs.relay_host); -socket.setEncoding('utf8'); -let buffered = ''; -const message = () => new Promise((resolve) => { - const read = (chunk) => { - buffered += chunk; - const boundary = buffered.indexOf('\\n'); - if (boundary < 0) return; - socket.off('data', read); - const line = buffered.slice(0, boundary); - buffered = buffered.slice(boundary + 1); - resolve(JSON.parse(line)); - }; - socket.on('data', read); -}); -await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); -}); -socket.write(JSON.stringify({ token: config.agent.kwargs.relay_token, kind: 'ready', instruction: 'solve', cwd: '/workspace' }) + '\\n'); -await message(); -socket.write(JSON.stringify({ token: config.agent.kwargs.relay_token, kind: 'executed', termination: 'exited', exitCode: 0, stdout: '', diagnostic: { category: 'none' } }) + '\\n'); -await message(); -const trialPath = new URL('./' + config.trial_name + '/', new URL('file://' + config.trials_dir + '/')); -await mkdir(trialPath, { recursive: true }); -const rewardEnv = process.env.MAKA_TEST_REWARD; -const exceptionType = process.env.MAKA_TEST_EXCEPTION; -const result = { - verifier_result: { - rewards: rewardEnv === 'none' ? {} : { reward: Number(rewardEnv ?? '1') }, - }, - ...(exceptionType ? { exception_info: { exception_type: exceptionType } } : {}), -}; -await writeFile(new URL('result.json', trialPath), JSON.stringify(result)); -await writeFile(process.env.MAKA_TEST_TRIAL_CONFIG, JSON.stringify(config.artifacts ?? null)); -const mode = process.env.MAKA_TEST_AUDIT_MODE; -if (mode !== 'missing') { - const destination = config.artifacts?.[0]?.destination ?? 'egress-hits.jsonl'; - await mkdir(new URL('./artifacts/', trialPath), { recursive: true }); - const auditUrl = new URL('./artifacts/' + destination, trialPath); - if (mode === 'unreadable') await mkdir(auditUrl); - else if (mode === 'empty') await writeFile(auditUrl, ''); - else if (mode === 'truncated') { - await writeFile(auditUrl, '{"ruleId":"tbench_domain"}\\n{"ruleId":"audit_truncated","host":"","normalizedPath":""}\\n'); - } else if (mode === 'policy') { - await writeFile(auditUrl, '{"ruleId":"policy_error","host":"","normalizedPath":"ValueError"}\\n'); - } else if (mode === 'policy-then-hit') { - await writeFile(auditUrl, '{"ruleId":"policy_error","host":"","normalizedPath":"ValueError"}\\n{"ruleId":"tbench_domain","host":"tbench.ai","normalizedPath":"/tasks"}\\n'); - } -} -socket.end(); -`, - ); - await chmod(executable, 0o755); - const restore = setEnvironment({ - MAKA_TEST_PYTHON: executable, - MAKA_TEST_TRIALS: root, - MAKA_TEST_BUNDLE: EVAL_ROOT, - MAKA_TEST_AUDIT_MODE: options.auditMode, - MAKA_TEST_TRIAL_CONFIG: trialConfigPath, - MAKA_TEST_REWARD: options.reward === null ? 'none' : String(options.reward ?? 1), - ...(options.exceptionType ? { MAKA_TEST_EXCEPTION: options.exceptionType } : {}), - }); - try { - const store = new FileAttemptStore(join(root, 'attempts')); - const results = await runExperiment({ - spec: experiment(), - store, - executor: createHarborExecutor( - executorConfig(options.egressProxy), - join(root, 'experiment.json'), - ), - subjects: [ - { - kind: 'external', - execute: async ({ context }) => { - await context.execute({ command: '/bin/true', args: [], credentialEnvironment: {} }); - return { - usage: null, - costUsd: null, - durationMs: 1, - status: 'completed', - failureReason: null, - artifacts: [], - }; - }, - }, - ], - }); - const attempts = await store.list('task::1::external'); - return { - attempts, - selected: selectCellResult(attempts) ?? results.get('task::1::external'), - trialConfig: JSON.parse(await readFile(trialConfigPath, 'utf8')) as unknown, - }; - } finally { - restore(); - await rm(root, { recursive: true, force: true }); - } -} - -function experiment(): ExperimentSpec { - return { - schemaVersion: 'maka.eval.v1' as const, - id: 'experiment', - benchmark: { id: 'benchmark', version: TEST_REVISION, config: { repository: 'repo' } }, - executor: { kind: 'harbor', config: executorConfig(false) }, - execution: { maxConcurrentTaskGroups: 1 }, - subjects: [{ id: 'external', kind: 'external' as const, credentials: [], config: {} }], - tasks: [{ id: 'task', input: 'solve', config: { harbor: { path: 'tasks/task' } } }], - repetitions: 1, - budget: { timeoutMultiplier: 1 }, - verifier: { reward: 'reward' }, - }; -} - -function executorConfig(egressProxy: boolean): JsonObject { - return { - frameworkVersion: '0.20.0', - pythonPathEnv: 'MAKA_TEST_PYTHON', - trialsRootEnv: 'MAKA_TEST_TRIALS', - environment: {}, - preparationEnvironment: [ - 'MAKA_TEST_AUDIT_MODE', - 'MAKA_TEST_TRIAL_CONFIG', - 'MAKA_TEST_REWARD', - 'MAKA_TEST_EXCEPTION', - ], - mounts: [], - ...(egressProxy - ? { - egressProxy: { - composeSourceEnv: 'MAKA_TEST_BUNDLE', - composeRelativePath: 'harbor/docker-compose-egress-proxy.yaml', - networkPolicyRelativePath: 'harbor/egress-proxy/network-policy', - proxyUrl: 'http://maka-eval-mitmproxy:8080', - allowedHost: 'maka-eval-mitmproxy', - containerCaPath: '/opt/maka-egress/mitmproxy-ca-cert.pem', - }, - } - : {}), - }; -} - -function setEnvironment(values: Record): () => void { - const previous = Object.fromEntries(Object.keys(values).map((name) => [name, process.env[name]])); - Object.assign(process.env, values); - return () => { - for (const [name, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[name]; - else process.env[name] = value; - } - }; -} diff --git a/packages/eval/src/__tests__/lifecycle-boundaries.test.ts b/packages/eval/src/__tests__/lifecycle-boundaries.test.ts index 98edd3454d4..2d46715357c 100644 --- a/packages/eval/src/__tests__/lifecycle-boundaries.test.ts +++ b/packages/eval/src/__tests__/lifecycle-boundaries.test.ts @@ -1126,12 +1126,11 @@ test('eight-arm spec adds Pi with the same pinned DeepSeek execution contract', assert.match(networkPolicy, /\/opt\/maka-egress\/proxy-ipv4/u); assert.doesNotMatch(networkPolicy, /\bgetent\b/u); assert.match(egressCompose, /proxy-ipv4/u); - const entrypoint = await readFile( - new URL('../../harbor/egress-proxy/entrypoint.sh', import.meta.url), - 'utf8', - ); - assert.match(entrypoint, /^touch "\$STATE_DIR\/hits\.jsonl"$/mu); - assert.doesNotMatch(entrypoint, /: > "\$STATE_DIR\/hits\.jsonl"/u); + const proxyEntrypoint = new URL('../../harbor/egress-proxy/entrypoint.sh', import.meta.url); + const proxyStartup = await readFile(proxyEntrypoint, 'utf8'); + assert.match(proxyStartup, /^AUDIT_LOG="\$STATE_DIR\/hits\.jsonl"$/mu); + assert.match(proxyStartup, /^test -e "\$AUDIT_LOG" \|\| touch "\$AUDIT_LOG"$/mu); + assert.doesNotMatch(proxyStartup, /(?:truncate|: >).*AUDIT_LOG/u); // The relay compares the subject's namespace against the namespace of the // service that installs the policy, so the service it reads has to be the one // the overlay mounts the policy script into. The two names live in different @@ -1437,123 +1436,52 @@ test('Pier rejects configured mounts that collide with framework log ownership', } }); -test('Pier preserves its log mounts without inheriting MAKA_EVAL_FRAMEWORK', { +test('Pier owns framework selection while retaining configured and log mounts', { timeout: 10_000, }, async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-eval-framework-env-')); - const executable = join(root, 'fake-python.mjs'); - const envDump = join(root, 'env.json'); - await writeFile( - executable, - `#!/usr/bin/env node -import { connect } from 'node:net'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -const config = JSON.parse(await readFile(process.argv.at(-1), 'utf8')); -await writeFile(process.env.MAKA_TEST_ENV, JSON.stringify({ - framework: process.env.MAKA_EVAL_FRAMEWORK ?? null, - mounts: config.environment.mounts, - trialName: config.trial_name, -})); -const socket = connect(config.agent.kwargs.relay_port, config.agent.kwargs.relay_host); -socket.setEncoding('utf8'); -let buffered = ''; -const message = () => new Promise((resolve) => { - const read = (chunk) => { - buffered += chunk; - const boundary = buffered.indexOf('\\n'); - if (boundary < 0) return; - socket.off('data', read); - const line = buffered.slice(0, boundary); - buffered = buffered.slice(boundary + 1); - resolve(JSON.parse(line)); + const root = await mkdtemp(join(tmpdir(), 'maka-pier-launch-contract-')); + const { executable, observation } = await writePierLaunchProbe(root); + const mount = { sourceEnv: 'MAKA_TEST_MOUNT', target: '/input', readOnly: true }; + const executorOptions = { + ...executorConfig(), + tasksRootEnv: 'MAKA_TEST_TASKS', + preparationEnvironment: ['MAKA_TEST_OBSERVATION'], + mounts: [mount], }; - socket.on('data', read); -}); -await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); -}); -socket.write(JSON.stringify({ token: config.agent.kwargs.relay_token, kind: 'ready', instruction: 'solve', cwd: '/workspace' }) + '\\n'); -await message(); -socket.write(JSON.stringify({ token: config.agent.kwargs.relay_token, kind: 'executed', termination: 'exited', exitCode: 0, stdout: '', diagnostic: { category: 'none' } }) + '\\n'); -await message(); -const trialPath = new URL('./' + config.trial_name + '/', new URL('file://' + config.trials_dir + '/')); -await mkdir(trialPath, { recursive: true }); -await writeFile(new URL('result.json', trialPath), JSON.stringify({ verifier_result: { rewards: { reward: 1 } } })); -socket.end(); -`, - ); - await chmod(executable, 0o755); const restoreEnvironment = setEnvironment({ MAKA_TEST_PYTHON: executable, MAKA_TEST_TRIALS: root, - MAKA_TEST_ENV: envDump, + MAKA_TEST_OBSERVATION: observation, MAKA_TEST_MOUNT: root, MAKA_TEST_TASKS: root, - MAKA_EVAL_FRAMEWORK: 'pier', + MAKA_EVAL_FRAMEWORK: 'harbor', }); + try { const spec: ExperimentSpec = { ...experiment(), - executor: { - kind: 'pier', - config: { - ...executorConfig(), - tasksRootEnv: 'MAKA_TEST_TASKS', - mounts: [{ sourceEnv: 'MAKA_TEST_MOUNT', target: '/input', readOnly: true }], - }, - }, + executor: { kind: 'pier', config: executorOptions }, tasks: [{ id: 'task', input: 'solve', config: { pier: { path: 'task' } } }], }; const results = await runExperiment({ spec, store: new FileAttemptStore(join(root, 'attempts')), - executor: createPierExecutor( - { - ...executorConfig(), - tasksRootEnv: 'MAKA_TEST_TASKS', - preparationEnvironment: ['MAKA_TEST_ENV'], - mounts: [{ sourceEnv: 'MAKA_TEST_MOUNT', target: '/input', readOnly: true }], - }, - join(root, 'experiment.json'), - ), - subjects: [ - { - kind: 'external', - execute: async ({ context }) => { - await context.execute({ command: '/bin/true', args: [], credentialEnvironment: {} }); - return { - usage: null, - costUsd: null, - durationMs: 1, - status: 'completed', - failureReason: null, - artifacts: [], - }; - }, - }, - ], + executor: createPierExecutor(executorOptions, join(root, 'experiment.json')), + subjects: [successfulExternalSubject()], }); + assert.equal(results.get('task::1::external')?.result.status, 'completed'); - const launched = JSON.parse(await readFile(envDump, 'utf8')) as { - framework: string | null; + const launch = JSON.parse(await readFile(observation, 'utf8')) as { + inheritedSelector: string | null; mounts: Array<{ source: string; target: string }>; - trialName: string; + trial: string; }; - assert.equal(launched.framework, null); - assert.deepEqual(launched.mounts, [ + assert.equal(launch.inheritedSelector, null); + assert.deepEqual(launch.mounts, [ { type: 'bind', source: root, target: '/input', read_only: true }, - { type: 'bind', source: join(root, launched.trialName, 'agent'), target: '/logs/agent' }, - { - type: 'bind', - source: join(root, launched.trialName, 'verifier'), - target: '/logs/verifier', - }, - { - type: 'bind', - source: join(root, launched.trialName, 'artifacts'), - target: '/logs/artifacts', - }, + { type: 'bind', source: join(root, launch.trial, 'agent'), target: '/logs/agent' }, + { type: 'bind', source: join(root, launch.trial, 'verifier'), target: '/logs/verifier' }, + { type: 'bind', source: join(root, launch.trial, 'artifacts'), target: '/logs/artifacts' }, ]); } finally { restoreEnvironment(); @@ -1561,6 +1489,62 @@ socket.end(); } }); +async function writePierLaunchProbe( + root: string, +): Promise<{ executable: string; observation: string }> { + const executable = join(root, 'pier-probe.mjs'); + const observation = join(root, 'pier-launch.json'); + await writeFile( + executable, + `#!/usr/bin/env node +import { once } from 'node:events'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createConnection } from 'node:net'; +import { createInterface } from 'node:readline'; + +const config = JSON.parse(await readFile(process.argv.at(-1), 'utf8')); +await writeFile(process.env.MAKA_TEST_OBSERVATION, JSON.stringify({ + inheritedSelector: process.env.MAKA_EVAL_FRAMEWORK ?? null, + mounts: config.environment.mounts, + trial: config.trial_name, +})); + +const socket = createConnection(config.agent.kwargs.relay_port, config.agent.kwargs.relay_host); +const messages = createInterface({ input: socket, crlfDelay: Infinity })[Symbol.asyncIterator](); +await once(socket, 'connect'); +const send = (message) => socket.write(JSON.stringify({ token: config.agent.kwargs.relay_token, ...message }) + '\\n'); +send({ kind: 'ready', instruction: 'solve', cwd: '/workspace' }); +await messages.next(); +send({ kind: 'executed', termination: 'exited', exitCode: 0, stdout: '', diagnostic: { category: 'none' } }); +await messages.next(); + +const trial = new URL(config.trial_name + '/', new URL('file://' + config.trials_dir + '/')); +await mkdir(trial, { recursive: true }); +await writeFile(new URL('result.json', trial), JSON.stringify({ verifier_result: { rewards: { reward: 1 } } })); +socket.end(); +`, + ); + await chmod(executable, 0o755); + return { executable, observation }; +} + +function successfulExternalSubject(): SubjectAdapter { + return { + kind: 'external', + execute: async ({ context }) => { + await context.execute({ command: '/bin/true', args: [], credentialEnvironment: {} }); + return { + usage: null, + costUsd: null, + durationMs: 1, + status: 'completed', + failureReason: null, + artifacts: [], + }; + }, + }; +} + function executorConfig(): JsonObject { return { frameworkVersion: '0.20.0', diff --git a/packages/eval/src/egress-audit.ts b/packages/eval/src/egress-audit.ts new file mode 100644 index 00000000000..407f49a0dc5 --- /dev/null +++ b/packages/eval/src/egress-audit.ts @@ -0,0 +1,148 @@ +/* + * 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 { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { JsonObject } from './experiment.js'; + +export const EGRESS_AUDIT_DESTINATION = 'egress-hits.jsonl'; +export const EGRESS_AUDIT_ARTIFACT_PATH = `artifacts/${EGRESS_AUDIT_DESTINATION}`; + +export interface EgressAuditEvidence { + readonly failureReason: string | null; + readonly artifacts: readonly JsonObject[]; +} + +export function egressAuditCollection(): readonly JsonObject[] { + return [ + { + source: '/opt/maka-egress-state/hits.jsonl', + destination: EGRESS_AUDIT_DESTINATION, + service: 'maka-eval-mitmproxy', + }, + ]; +} + +interface EgressAuditSummary { + readonly truncated: boolean; + readonly policyErrorCount: number; + readonly malformedLineCount: number; +} + +export function describeEgressAudit( + bytes: Buffer | undefined, + required: boolean, +): EgressAuditEvidence { + if (!required) return cleanEvidence(); + if (bytes === undefined) return missingEvidence(); + + return { + failureReason: null, + artifacts: [ + { + kind: 'egress-audit', + path: EGRESS_AUDIT_ARTIFACT_PATH, + bytes: bytes.byteLength, + sha256: digest(bytes), + ...summarize(bytes), + }, + ], + }; +} + +export async function readEgressAuditEvidence( + trialPath: string, + required: boolean, +): Promise { + if (!required) return cleanEvidence(); + const auditPath = join(trialPath, EGRESS_AUDIT_ARTIFACT_PATH); + try { + return describeEgressAudit(await readFile(auditPath), true); + } catch (error) { + const code = errorCode(error); + if (code === 'ENOENT') return missingEvidence(); + return { + failureReason: `failed to read egress audit log ${auditPath}${code ? ` (${code})` : ''}`, + artifacts: [{ kind: 'egress-audit-unreadable', path: EGRESS_AUDIT_ARTIFACT_PATH }], + }; + } +} + +function summarize(bytes: Buffer): EgressAuditSummary { + let truncated = false; + let policyErrorCount = 0; + let malformedLineCount = 0; + + for (const line of jsonLines(bytes)) { + const record = decodeRecord(line); + if (record === undefined) continue; + if (record === null) { + malformedLineCount += 1; + } else if (record.ruleId === 'audit_truncated') { + truncated = true; + } else if (record.ruleId === 'policy_error') { + policyErrorCount += 1; + } + } + + return { truncated, policyErrorCount, malformedLineCount }; +} + +function* jsonLines(bytes: Buffer): Generator { + let start = 0; + while (start < bytes.length) { + const newline = bytes.indexOf(0x0a, start); + const end = newline === -1 ? bytes.length : newline; + yield bytes.subarray(start, end); + start = newline === -1 ? bytes.length : newline + 1; + } +} + +function decodeRecord(line: Buffer): { readonly ruleId?: unknown } | null | undefined { + if (line.length === 0) return undefined; + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(line).trim(); + if (!text) return undefined; + const decoded: unknown = JSON.parse(text); + if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) return null; + return decoded as { readonly ruleId?: unknown }; + } catch { + return null; + } +} + +function cleanEvidence(): EgressAuditEvidence { + return { failureReason: null, artifacts: [] }; +} + +function missingEvidence(): EgressAuditEvidence { + return { + failureReason: 'egress audit log missing', + artifacts: [{ kind: 'egress-audit-missing', path: EGRESS_AUDIT_ARTIFACT_PATH }], + }; +} + +function digest(bytes: Buffer): string { + return `sha256:${createHash('sha256').update(bytes).digest('hex')}`; +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined; +} diff --git a/packages/eval/src/harness-executor.ts b/packages/eval/src/harness-executor.ts index e5e19fcd9e3..7056ec07e00 100644 --- a/packages/eval/src/harness-executor.ts +++ b/packages/eval/src/harness-executor.ts @@ -25,6 +25,7 @@ import { chmod, lstat, mkdir, readFile, readdir, unlink, writeFile } from 'node: import { createServer, type Server, type Socket } from 'node:net'; import { basename, dirname, join, posix, relative, resolve, sep } from 'node:path'; import { createInterface } from 'node:readline'; +import { egressAuditCollection, readEgressAuditEvidence } from './egress-audit.js'; import { decodeJsonObject, type ExperimentCell, type JsonObject } from './experiment.js'; import { BUNDLED_HARNESS_RELAY_ROOT, @@ -476,13 +477,7 @@ async function startTrial( environment: environmentConfig, ...(options.egressProxy ? { - artifacts: [ - { - source: '/opt/maka-egress-state/hits.jsonl', - destination: EGRESS_AUDIT_DESTINATION, - service: 'maka-eval-mitmproxy', - }, - ], + artifacts: egressAuditCollection(), } : {}), })}\n`, @@ -718,77 +713,11 @@ async function closeServer(server: Server): Promise { }); } -export const EGRESS_AUDIT_DESTINATION = 'egress-hits.jsonl'; -export const EGRESS_AUDIT_ARTIFACT_PATH = `artifacts/${EGRESS_AUDIT_DESTINATION}`; - -export function collectEgressAuditArtifact( - audit: Buffer | undefined, - expected: boolean, -): { - readonly missing: boolean; - readonly failureReason: string | null; - readonly artifacts: readonly JsonObject[]; -} { - if (!expected) return { missing: false, failureReason: null, artifacts: [] }; - if (audit === undefined) { - return { - missing: true, - failureReason: 'egress audit log missing', - artifacts: [{ kind: 'egress-audit-missing', path: EGRESS_AUDIT_ARTIFACT_PATH }], - }; - } - const forensics = inspectEgressAudit(audit); - return { - missing: false, - failureReason: null, - artifacts: [ - { - kind: 'egress-audit', - path: EGRESS_AUDIT_ARTIFACT_PATH, - bytes: audit.byteLength, - sha256: `sha256:${createHash('sha256').update(audit).digest('hex')}`, - truncated: forensics.truncated, - policyErrorCount: forensics.policyErrorCount, - malformedLineCount: forensics.malformedLineCount, - }, - ], - }; -} - -function inspectEgressAudit(audit: Buffer): { - readonly truncated: boolean; - readonly policyErrorCount: number; - readonly malformedLineCount: number; -} { - let truncated = false; - let policyErrorCount = 0; - let malformedLineCount = 0; - for (const line of audit.toString('utf8').split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - let record: unknown; - try { - record = JSON.parse(trimmed); - } catch { - malformedLineCount += 1; - continue; - } - if (!record || typeof record !== 'object' || Array.isArray(record)) { - malformedLineCount += 1; - continue; - } - const ruleId = (record as { ruleId?: unknown }).ruleId; - if (ruleId === 'audit_truncated') truncated = true; - if (ruleId === 'policy_error') policyErrorCount += 1; - } - return { truncated, policyErrorCount, malformedLineCount }; -} - async function readVerification( state: RelayState, cell: ExperimentCell, framework: HarnessFramework, - expectEgressAudit: boolean, + auditRequired: boolean, ): Promise { const result = JSON.parse(await readFile(join(state.trialPath, 'result.json'), 'utf8')) as { exception_info?: { exception_type?: unknown } | null; @@ -801,44 +730,31 @@ async function readVerification( if (result.exception_info && !subjectException) { throw new Error('Trial failed outside subject execution'); } - const egressAuditPath = join(state.trialPath, EGRESS_AUDIT_ARTIFACT_PATH); - let egressAudit: Buffer | undefined; - try { - egressAudit = await readFile(egressAuditPath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (expectEgressAudit && code !== 'ENOENT') { - return { - status: 'infra_failed', - score, - failureReason: `failed to read egress audit log ${egressAuditPath}${code ? ` (${code})` : ''}`, - artifacts: [ - { kind: 'trial', framework: cell.executor.kind, trialName: state.trialName }, - ...(await collectedArtifactInventory(state.trialPath, framework)), - { kind: 'egress-audit-unreadable', path: EGRESS_AUDIT_ARTIFACT_PATH }, - ], - }; - } - } - const audit = collectEgressAuditArtifact(egressAudit, expectEgressAudit); + const audit = await readEgressAuditEvidence(state.trialPath, auditRequired); + const baseFailure = score === null ? 'verifier produced no reward' : null; + const status = verificationStatus(audit.failureReason, score, subjectException); + const collectedArtifacts = await collectedArtifactInventory(state.trialPath, framework); return { - status: audit.failureReason - ? 'infra_failed' - : score === null - ? 'infra_failed' - : subjectException - ? 'subject_failed' - : 'completed', + status, score, - failureReason: audit.failureReason ?? (score === null ? 'verifier produced no reward' : null), + failureReason: audit.failureReason ?? baseFailure, artifacts: [ { kind: 'trial', framework: cell.executor.kind, trialName: state.trialName }, - ...(await collectedArtifactInventory(state.trialPath, framework)), - ...audit.artifacts, + ...collectedArtifacts, + ...Array.from(audit.artifacts), ], }; } +function verificationStatus( + auditFailure: string | null, + score: number | null, + subjectException: boolean, +): ExecutorVerification['status'] { + if (auditFailure !== null || score === null) return 'infra_failed'; + return subjectException ? 'subject_failed' : 'completed'; +} + async function collectedArtifactInventory( trialPath: string, framework: HarnessFramework, diff --git a/packages/runtime-host/src/__tests__/desktop-e2e-execution.test.ts b/packages/runtime-host/src/__tests__/desktop-e2e-backend.test.ts similarity index 66% rename from packages/runtime-host/src/__tests__/desktop-e2e-execution.test.ts rename to packages/runtime-host/src/__tests__/desktop-e2e-backend.test.ts index 92130a1adfc..8b5321c40ed 100644 --- a/packages/runtime-host/src/__tests__/desktop-e2e-execution.test.ts +++ b/packages/runtime-host/src/__tests__/desktop-e2e-backend.test.ts @@ -23,46 +23,41 @@ import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { SessionHeader } from '@maka/core/session'; import type { HistoryCompactCheckpoint } from '@maka/runtime/history-compact-checkpoint'; import type { BackendFactoryContext } from '@maka/runtime/session-manager'; -import { DesktopE2eBackend } from '../test-only/desktop-e2e-execution.js'; +import { createDesktopE2eCheckpoint } from '../test-only/desktop-e2e-checkpoint.js'; +import { DesktopE2eBackend } from '../test-only/desktop-e2e-backend.js'; -function backendContext(overrides: Partial = {}): BackendFactoryContext { - return { - sessionId: 'session-1', - workspaceRoot: '/tmp/workspace', - header: { model: 'fake-model' } as SessionHeader, - store: {} as BackendFactoryContext['store'], - ...overrides, - }; -} +const input = () => ({ + turnId: 'turn-1', + runId: 'run-1', + runtimeContext: [userEvent()], +}); -test('Desktop E2E compaction requires a checkpoint recorder', async () => { - const backend = new DesktopE2eBackend(backendContext()); +const context = (overrides: Partial = {}): BackendFactoryContext => ({ + sessionId: 'session-1', + workspaceRoot: '/tmp/workspace', + header: { model: 'fake-model' } as SessionHeader, + store: {} as BackendFactoryContext['store'], + ...overrides, +}); + +test('rejects compaction when the fixture has no checkpoint sink', async () => { await assert.rejects( - backend.compactHistory({ - turnId: 'turn-1', - runId: 'run-1', - runtimeContext: [userEvent()], - }), + new DesktopE2eBackend(context()).compactHistory(input()), /Desktop E2E compaction requires a checkpoint recorder/, ); }); -test('Desktop E2E compaction records a deterministic checkpoint', async () => { +test('records one deterministic sectioned checkpoint', async () => { const recorded: Array<{ checkpoint: HistoryCompactCheckpoint; turnId: string }> = []; const backend = new DesktopE2eBackend( - backendContext({ + context({ recordHistoryCompactCheckpoint: async (checkpoint, turnId) => { recorded.push({ checkpoint, turnId }); }, }), ); - const result = await backend.compactHistory({ - turnId: 'turn-1', - runId: 'run-1', - runtimeContext: [userEvent()], - }); - + const result = await backend.compactHistory(input()); assert.equal(recorded.length, 1); assert.equal(recorded[0]?.turnId, 'turn-1'); const checkpoint = recorded[0]!.checkpoint; @@ -70,9 +65,14 @@ test('Desktop E2E compaction records a deterministic checkpoint', async () => { outcome: { kind: 'compacted', checkpointId: checkpoint.checkpointId }, }); assert.equal(checkpoint.version, 2); - if (checkpoint.version !== 2) return; - // The fixture is shaped like a real sectioned checkpoint so the builder's - // summary validation admits it and stamps the contract marker. + assert.match(checkpoint.summary, /^## Goal\nDeterministic Desktop E2E context checkpoint\./); + assert.equal(checkpoint.summaryFormat, 'sections_v1'); +}); + +test('builds the same checkpoint without a backend instance', () => { + const checkpoint = createDesktopE2eCheckpoint('session-1', [userEvent()]); + + assert.equal(checkpoint.version, 2); assert.match(checkpoint.summary, /^## Goal\nDeterministic Desktop E2E context checkpoint\./); assert.equal(checkpoint.summaryFormat, 'sections_v1'); }); diff --git a/packages/runtime-host/src/__tests__/execution-host-message.test.ts b/packages/runtime-host/src/__tests__/execution-host-message.test.ts index 58b8ae5f705..36df142ce59 100644 --- a/packages/runtime-host/src/__tests__/execution-host-message.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-message.test.ts @@ -236,6 +236,7 @@ test('steering becomes durable and ordered followups automatically start the nex originHostEpoch: host.hostEpoch, sessionId: fixture.sessionId, reorderId: randomUUID(), + expectedQueueRevision: queueSubscription.snapshot.queue.queueRevision, entryIds: queuedFollowups.map((entry) => entry.entryId).reverse(), }); await queueSubscription.close(); diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index d1ca4bb4acb..9d952751088 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -50,6 +50,7 @@ import { MESSAGE_QUEUE_MAX_ENTRIES, MESSAGE_QUEUE_PROJECTION_MAX_BYTES, decodeSessionMessageQueueProjection, + type OperationInput, type SessionMessageQueueProjection, type TurnSnapshot, } from '../protocol/index.js'; @@ -65,6 +66,26 @@ import { SessionAdmissionGate } from '../server/session-admission-gate.js'; const ROOT = { sessionId: 'session-1', turnId: 'turn-1', runId: 'run-1' } as const; const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] } as const; +type CoordinatorFixture = ReturnType; + +type QueueMutationOperation = + | 'queue.entry.promote' + | 'queue.entry.retract' + | 'queue.entry.update' + | 'queue.entries.reorder' + | 'queue.retract'; + +function queueMutation( + fixture: CoordinatorFixture, + operation: K, + input: Omit, 'originHostEpoch' | 'sessionId'>, +) { + return fixture.coordinator.handlers[operation]( + { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, ...input } as OperationInput, + operationContext(), + ); +} + test('consumes an active-target admission before the terminal transition can make it idle', async (t) => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-active-consume-')); const store = createSessionStore(root); @@ -1788,33 +1809,25 @@ test('pull crosses the retract commit cut and only queued entries are retracted' assert.equal(fixture.liveResidencies(), 0); }); -test('entry retract removes one queued entry, replays its outcome, and rejects stale targets', async () => { +test('entry retract has one replayable cut and never crosses an in-flight lease', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); - await submit(fixture, 'steer-1', 'steer me', 'current_turn'); await submit(fixture, 'follow-1', 'first', 'next_turn'); await submit(fixture, 'follow-2', 'second', 'next_turn'); - const retracted = await fixture.coordinator.handlers['queue.entry.retract']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-2', - retractId: 'retract-entry-1', - }, - operationContext(), - ); - assert.equal(retracted.ok, true); - if (!retracted.ok) return; - assert.equal(retracted.result.queueRevision, 4); + const retract = (entryId: string, retractId: string) => + queueMutation(fixture, 'queue.entry.retract', { entryId, retractId }); + const first = await retract('id-2', 'retract-entry-1'); + assert.deepEqual(first, { ok: true, result: { queueRevision: 4 } }); + const projection = fixture.coordinator.projection(ROOT.sessionId); assert.deepEqual( - fixture.coordinator.projection(ROOT.sessionId).followup.map((entry) => entry.messageId), - ['follow-2'], + projection.steering.map(({ messageId }) => messageId), + ['steer-1'], ); assert.deepEqual( - fixture.coordinator.projection(ROOT.sessionId).steering.map((entry) => entry.messageId), - ['steer-1'], + projection.followup.map(({ messageId }) => messageId), + ['follow-2'], ); assert.deepEqual( await fixture.coordinator.handlers['turn.message.execution.query']( @@ -1828,88 +1841,18 @@ test('entry retract removes one queued entry, replays its outcome, and rejects s }, }, ); + assert.deepEqual(await retract('id-2', 'retract-entry-1'), first); + assertQueueError(await retract('id-3', 'retract-entry-1'), 'operation_conflict'); + assertQueueError(await retract('id-2', 'retract-entry-2'), 'not_found'); - const retry = await fixture.coordinator.handlers['queue.entry.retract']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-2', - retractId: 'retract-entry-1', - }, - operationContext(), - ); - assert.deepEqual(retry, retracted); - - const conflict = await fixture.coordinator.handlers['queue.entry.retract']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-3', - retractId: 'retract-entry-1', - }, - operationContext(), - ); - assert.equal(conflict.ok, false); - if (!conflict.ok) assert.equal(conflict.error.code, 'operation_conflict'); - - const missing = await fixture.coordinator.handlers['queue.entry.retract']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-2', - retractId: 'retract-entry-2', - }, - operationContext(), - ); - assert.equal(missing.ok, false); - if (!missing.ok) assert.equal(missing.error.code, 'not_found'); - - const steering = await fixture.coordinator.handlers['queue.entry.retract']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-1', - retractId: 'retract-entry-3', - }, - operationContext(), - ); - assert.equal(steering.ok, true); - assert.deepEqual(fixture.coordinator.projection(ROOT.sessionId).steering, []); - assert.equal(fixture.liveResidencies(), 1); - - await fixture.coordinator.handlers['queue.retract']( - { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, retractId: 'cleanup-entry' }, - operationContext(), - ); - fixture.coordinator.abandonRootReservation(ROOT); - await fixture.coordinator.close(); -}); - -test('entry retract of an in-flight steering lease conflicts', async () => { - const fixture = createFixture(); - fixture.coordinator.reserveRootTurn(ROOT); const owner = fixture.coordinator.bindRun(ROOT); - - await submit(fixture, 'steer-1', 'steer me', 'current_turn'); const [lease] = await owner.pull(); assert.ok(lease); - - const outcome = await fixture.coordinator.handlers['queue.entry.retract']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-1', - retractId: 'retract-in-flight', - }, - operationContext(), - ); - assert.equal(outcome.ok, false); - if (!outcome.ok) assert.equal(outcome.error.code, 'operation_conflict'); - + assertQueueError(await retract('id-1', 'retract-in-flight'), 'operation_conflict'); owner.ack([lease.id]); owner.release(); - const batch = fixture.coordinator.beginTerminalTransition(ROOT); - fixture.coordinator.completeIdle(batch); + await queueMutation(fixture, 'queue.retract', { retractId: 'cleanup-entry' }); + fixture.coordinator.completeIdle(fixture.coordinator.beginTerminalTransition(ROOT)); assert.equal(fixture.liveResidencies(), 0); }); @@ -1945,17 +1888,12 @@ test('entry update preserves queue identity, order, and placement and replays it }; }); - const updated = await fixture.coordinator.handlers['queue.entry.update']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-2', - updateId: 'update-entry-1', - expectedQueueRevision: 3, - text: 'please first @src/a.ts', - }, - operationContext(), - ); + const updated = await queueMutation(fixture, 'queue.entry.update', { + entryId: 'id-2', + updateId: 'update-entry-1', + expectedQueueRevision: 3, + text: 'please first @src/a.ts', + }); assert.equal(updated.ok, true); assert.deepEqual(preparedUpdateContent, { text: 'please first @src/a.ts', @@ -1981,51 +1919,33 @@ test('entry update preserves queue identity, order, and placement and replays it [['id-1', 'steer me', 'current_turn']], ); - const stale = await fixture.coordinator.handlers['queue.entry.update']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-2', - updateId: 'update-entry-stale', - expectedQueueRevision: 3, - text: 'stale overwrite', - }, - operationContext(), - ); + const stale = await queueMutation(fixture, 'queue.entry.update', { + entryId: 'id-2', + updateId: 'update-entry-stale', + expectedQueueRevision: 3, + text: 'stale overwrite', + }); assert.equal(stale.ok, false); if (!stale.ok) assert.equal(stale.error.code, 'operation_conflict'); - const retry = await fixture.coordinator.handlers['queue.entry.update']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-2', - updateId: 'update-entry-1', - expectedQueueRevision: 3, - text: 'please first @src/a.ts', - }, - operationContext(), - ); + const retry = await queueMutation(fixture, 'queue.entry.update', { + entryId: 'id-2', + updateId: 'update-entry-1', + expectedQueueRevision: 3, + text: 'please first @src/a.ts', + }); assert.deepEqual(retry, updated); - const conflict = await fixture.coordinator.handlers['queue.entry.update']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-3', - updateId: 'update-entry-1', - expectedQueueRevision: 3, - text: 'conflicting retry', - }, - operationContext(), - ); + const conflict = await queueMutation(fixture, 'queue.entry.update', { + entryId: 'id-3', + updateId: 'update-entry-1', + expectedQueueRevision: 3, + text: 'conflicting retry', + }); assert.equal(conflict.ok, false); if (!conflict.ok) assert.equal(conflict.error.code, 'operation_conflict'); - await fixture.coordinator.handlers['queue.retract']( - { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, retractId: 'cleanup-update' }, - operationContext(), - ); + await queueMutation(fixture, 'queue.retract', { retractId: 'cleanup-update' }); fixture.coordinator.abandonRootReservation(ROOT); await fixture.coordinator.close(); }); @@ -2039,17 +1959,12 @@ test('entry update of an in-flight steering lease conflicts', async () => { const [lease] = await owner.pull(); assert.ok(lease); - const outcome = await fixture.coordinator.handlers['queue.entry.update']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-1', - updateId: 'update-in-flight', - expectedQueueRevision: 2, - text: 'too late', - }, - operationContext(), - ); + const outcome = await queueMutation(fixture, 'queue.entry.update', { + entryId: 'id-1', + updateId: 'update-in-flight', + expectedQueueRevision: 2, + text: 'too late', + }); assert.equal(outcome.ok, false); if (!outcome.ok) assert.equal(outcome.error.code, 'operation_conflict'); @@ -2088,30 +2003,20 @@ test('entry update keeps relocated inline references ordered and non-overlapping 'next_turn', ); - const reordered = await fixture.coordinator.handlers['queue.entry.update']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-1', - updateId: 'update-reordered-refs', - expectedQueueRevision: 2, - text: '@src/b @src/a', - }, - operationContext(), - ); + const reordered = await queueMutation(fixture, 'queue.entry.update', { + entryId: 'id-1', + updateId: 'update-reordered-refs', + expectedQueueRevision: 2, + text: '@src/b @src/a', + }); assert.equal(reordered.ok, true); - const overlapping = await fixture.coordinator.handlers['queue.entry.update']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-2', - updateId: 'update-overlapping-refs', - expectedQueueRevision: 3, - text: '@src/a.ts', - }, - operationContext(), - ); + const overlapping = await queueMutation(fixture, 'queue.entry.update', { + entryId: 'id-2', + updateId: 'update-overlapping-refs', + expectedQueueRevision: 3, + text: '@src/a.ts', + }); assert.equal(overlapping.ok, true); const [first, second] = fixture.coordinator.projection(ROOT.sessionId).followup; @@ -2124,72 +2029,37 @@ test('entry update keeps relocated inline references ordered and non-overlapping ]); }); -test('entry promote moves a follow-up into the steering queue', async () => { +test('promote changes placement once, replays exactly, and feeds the active owner', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); const owner = fixture.coordinator.bindRun(ROOT); - await submit(fixture, 'follow-1', 'first', 'next_turn'); await submit(fixture, 'follow-2', 'second', 'next_turn'); - const promoted = await fixture.coordinator.handlers['queue.entry.promote']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-2', - promoteId: 'promote-1', - }, - operationContext(), - ); - assert.equal(promoted.ok, true); + const promote = (promoteId: string) => + queueMutation(fixture, 'queue.entry.promote', { entryId: 'id-2', promoteId }); + const promoted = await promote('promote-1'); + assert.deepEqual(promoted, { ok: true, result: { queueRevision: 3 } }); const projection = fixture.coordinator.projection(ROOT.sessionId); assert.deepEqual( - projection.steering.map((entry) => [entry.messageId, entry.placement]), + projection.steering.map(({ messageId, placement }) => [messageId, placement]), [['follow-2', 'current_turn']], ); assert.deepEqual( - projection.followup.map((entry) => entry.messageId), + projection.followup.map(({ messageId }) => messageId), ['follow-1'], ); - - const retry = await fixture.coordinator.handlers['queue.entry.promote']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-2', - promoteId: 'promote-1', - }, - operationContext(), - ); - assert.deepEqual(retry, promoted); - - const again = await fixture.coordinator.handlers['queue.entry.promote']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-2', - promoteId: 'promote-2', - }, - operationContext(), - ); - assert.equal(again.ok, false); - if (!again.ok) assert.equal(again.error.code, 'operation_conflict'); - + assert.deepEqual(await promote('promote-1'), promoted); + assertQueueError(await promote('promote-2'), 'operation_conflict'); const leases = await owner.pull(); assert.deepEqual( - leases.map((lease) => lease.messageId), + leases.map(({ messageId }) => messageId), ['follow-2'], ); owner.ack(leases.map((lease) => lease.id)); owner.release(); - - const retracted = await fixture.coordinator.handlers['queue.retract']( - { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, retractId: 'cleanup-promote' }, - operationContext(), - ); - assert.equal(retracted.ok, true); - const batch = fixture.coordinator.beginTerminalTransition(ROOT); - fixture.coordinator.completeIdle(batch); + await queueMutation(fixture, 'queue.retract', { retractId: 'cleanup-promote' }); + fixture.coordinator.completeIdle(fixture.coordinator.beginTerminalTransition(ROOT)); assert.equal(fixture.liveResidencies(), 0); }); @@ -2319,14 +2189,12 @@ test('editing a promoted entry preserves its original submitted placement', asyn ); }); -test('entry promote requires an active Turn', async () => { +test('promote is fenced when the Session has no active Turn', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); - await submit(fixture, 'follow-1', 'first', 'next_turn'); fixture.setRootState({ kind: 'idle' }); - - const promoted = await fixture.coordinator.handlers['queue.entry.promote']( + const outcome = await fixture.coordinator.handlers['queue.entry.promote']( { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, @@ -2335,71 +2203,45 @@ test('entry promote requires an active Turn', async () => { }, operationContext(), ); - assert.equal(promoted.ok, false); - if (!promoted.ok) assert.equal(promoted.error.code, 'operation_conflict'); + assertQueueError(outcome, 'operation_conflict'); }); -test('entries reorder permutes the follow-up queue and rejects stale orders', async () => { +test('reorder commits one exact permutation behind a queue-revision fence', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); - await submit(fixture, 'follow-1', 'first', 'next_turn'); await submit(fixture, 'follow-2', 'second', 'next_turn'); await submit(fixture, 'follow-3', 'third', 'next_turn'); - const revisionBefore = fixture.coordinator.projection(ROOT.sessionId).queueRevision; - - const reordered = await fixture.coordinator.handlers['queue.entries.reorder']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - reorderId: 'reorder-1', - entryIds: ['id-3', 'id-1', 'id-2'], - }, - operationContext(), - ); - assert.equal(reordered.ok, true); - if (!reordered.ok) return; - assert.equal(reordered.result.queueRevision, revisionBefore + 1); + const revision = fixture.coordinator.projection(ROOT.sessionId).queueRevision; + const reorder = (reorderId: string, expectedQueueRevision: number, entryIds: string[]) => + fixture.coordinator.handlers['queue.entries.reorder']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + reorderId, + expectedQueueRevision, + entryIds, + }, + operationContext(), + ); + const intended = ['id-3', 'id-1', 'id-2']; + const reordered = await reorder('reorder-1', revision, intended); + assert.deepEqual(reordered, { ok: true, result: { queueRevision: revision + 1 } }); assert.deepEqual( - fixture.coordinator.projection(ROOT.sessionId).followup.map((entry) => entry.messageId), + fixture.coordinator.projection(ROOT.sessionId).followup.map(({ messageId }) => messageId), ['follow-3', 'follow-1', 'follow-2'], ); - - const retry = await fixture.coordinator.handlers['queue.entries.reorder']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - reorderId: 'reorder-1', - entryIds: ['id-3', 'id-1', 'id-2'], - }, - operationContext(), - ); - assert.deepEqual(retry, reordered); - - const stale = await fixture.coordinator.handlers['queue.entries.reorder']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - reorderId: 'reorder-2', - entryIds: ['id-2', 'id-1'], - }, - operationContext(), - ); - assert.equal(stale.ok, false); - if (!stale.ok) assert.equal(stale.error.code, 'operation_conflict'); - - const unchanged = await fixture.coordinator.handlers['queue.entries.reorder']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - reorderId: 'reorder-3', - entryIds: ['id-3', 'id-1', 'id-2'], - }, - operationContext(), - ); - assert.equal(unchanged.ok, true); - if (unchanged.ok) assert.equal(unchanged.result.queueRevision, revisionBefore + 1); - + assert.deepEqual(await reorder('reorder-1', revision, intended), reordered); + for (const [id, expected, entries] of [ + ['stale-members', revision, ['id-2', 'id-3', 'id-1']], + ['missing-member', revision, ['id-2', 'id-1']], + ] as const) { + assertQueueError(await reorder(id, expected, [...entries]), 'operation_conflict'); + } + assert.deepEqual(await reorder('unchanged', revision + 1, intended), { + ok: true, + result: { queueRevision: revision + 1 }, + }); await fixture.coordinator.handlers['queue.retract']( { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, retractId: 'cleanup-reorder' }, operationContext(), @@ -2481,6 +2323,7 @@ test('steering edits and reorders settle before one complete batch is pulled; fo originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, reorderId: 'reorder-steering', + expectedQueueRevision: fixture.coordinator.projection(ROOT.sessionId).queueRevision, entryIds: [entries[2]!.entryId, entries[0]!.entryId, entries[1]!.entryId], }, operationContext(), @@ -2525,55 +2368,59 @@ test('steering edits and reorders settle before one complete batch is pulled; fo await fixture.coordinator.close(); }); -test('queued mutations reject a queue that is draining into the next Turn', async () => { +test('queue mutations stop at the transition cut and resume on the successor', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); const owner = fixture.coordinator.bindRun(ROOT); - await submit(fixture, 'follow-1', 'first', 'next_turn'); await submit(fixture, 'follow-2', 'second', 'next_turn'); owner.release(); const batch = fixture.coordinator.beginTerminalTransition(ROOT); - - const retracted = await fixture.coordinator.handlers['queue.entry.retract']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - entryId: 'id-2', - retractId: 'retract-draining', - }, - operationContext(), + assertQueueError( + await fixture.coordinator.handlers['queue.entry.retract']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryId: 'id-2', + retractId: 'retract-draining', + }, + operationContext(), + ), + 'operation_conflict', ); - assert.equal(retracted.ok, false); - if (!retracted.ok) assert.equal(retracted.error.code, 'operation_conflict'); - - const reordered = await fixture.coordinator.handlers['queue.entries.reorder']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - reorderId: 'reorder-draining', - entryIds: ['id-2', 'id-1'], - }, - operationContext(), + assertQueueError( + await fixture.coordinator.handlers['queue.entries.reorder']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + reorderId: 'reorder-draining', + expectedQueueRevision: fixture.coordinator.projection(ROOT.sessionId).queueRevision, + entryIds: ['id-2', 'id-1'], + }, + operationContext(), + ), + 'operation_conflict', ); - assert.equal(reordered.ok, false); - if (!reordered.ok) assert.equal(reordered.error.code, 'operation_conflict'); - - fixture.coordinator.commitNextRoot(batch, { + const successor = { sessionId: ROOT.sessionId, turnId: 'turn-2', runId: 'run-2', - }); + }; + fixture.coordinator.commitNextRoot(batch, successor); const after = await fixture.coordinator.handlers['queue.entries.reorder']( { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, reorderId: 'reorder-after-commit', + expectedQueueRevision: fixture.coordinator.projection(ROOT.sessionId).queueRevision, entryIds: ['id-2'], }, operationContext(), ); - assert.equal(after.ok, true); + assert.deepEqual(after, { + ok: true, + result: { queueRevision: fixture.coordinator.projection(ROOT.sessionId).queueRevision }, + }); await fixture.coordinator.handlers['queue.retract']( { originHostEpoch: 'epoch-1', @@ -2582,11 +2429,7 @@ test('queued mutations reject a queue that is draining into the next Turn', asyn }, operationContext(), ); - fixture.coordinator.abandonRootReservation({ - sessionId: ROOT.sessionId, - turnId: 'turn-2', - runId: 'run-2', - }); + fixture.coordinator.abandonRootReservation(successor); await fixture.coordinator.close(); }); @@ -4289,6 +4132,17 @@ function operationContext(connectionId = 'connection-1') { acquireResidency: () => ({ release: () => undefined }), }; } + +function assertQueueError( + outcome: + | { readonly ok: true } + | { readonly ok: false; readonly error: { readonly code: string } }, + code: string, +): void { + assert.equal(outcome.ok, false); + if (!outcome.ok) assert.equal(outcome.error.code, code); +} + test('a lone folded steering entry leaves the queue projection decodable', async () => { // A steer the run never pulls is folded into `followup` at the terminal // transition. It has to arrive there as a followup entry: the wire decoder diff --git a/packages/runtime-host/src/__tests__/message-queue-state.test.ts b/packages/runtime-host/src/__tests__/message-queue-state.test.ts new file mode 100644 index 00000000000..9a67a456f76 --- /dev/null +++ b/packages/runtime-host/src/__tests__/message-queue-state.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { checkQueueRevision, planQueueReorder } from '../server/message-queue-state.js'; + +const state = { + steering: [ + { entryId: 'steer-1', messageId: 'message-1' }, + { entryId: 'steer-2', messageId: 'message-2' }, + ], + followup: [{ entryId: 'follow-1', messageId: 'message-3' }], + inFlight: new Map(), +}; + +test('queue revision fence rejects an old client proposal', () => { + assert.deepEqual(checkQueueRevision(8, 8), { kind: 'current', revision: 8 }); + assert.deepEqual(checkQueueRevision(9, 8), { kind: 'stale', expected: 8, actual: 9 }); +}); + +test('reorder planning resolves the lane from the complete identity set', () => { + assert.deepEqual(planQueueReorder(state, ['steer-2', 'steer-1']), { + lane: 'steering', + entries: [state.steering[1], state.steering[0]], + changed: true, + }); + assert.equal(planQueueReorder(state, ['steer-1', 'follow-1']), undefined); +}); + +test('ablation: without the revision fence an old reorder would be accepted', () => { + const expected = 4; + const actual = 5; + const naiveAccept = actual >= expected; + assert.equal(naiveAccept, true); + assert.equal(checkQueueRevision(actual, expected).kind, 'stale'); +}); diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index c56c194b590..98692b175f0 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -243,6 +243,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 45); }); + test('publishes a new compatibility epoch for queue reorder revision fencing', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 192); + }); + test('publishes a new compatibility epoch for the project registration preference', () => { // Epoch 46 Hosts reject the optional preference field on the closed register // input, so mixed-version peers must fail during the handshake instead. @@ -523,38 +527,6 @@ describe('Runtime Host bootstrap protocol', () => { }, }; assert.deepEqual(decodeSessionContinuitySnapshot(waiting), waiting); - const retrying = { - ...continuitySnapshot('epoch-1'), - rootTurn: { - ...continuitySnapshot('epoch-1').rootTurn, - providerRetry: { - phase: 'scheduled' as const, - attempt: 8, - maxAttempts: 10, - delayMs: 40_000, - reason: 'rate_limit' as const, - }, - }, - }; - assert.deepEqual(decodeSessionContinuitySnapshot(retrying), retrying); - // Snapshots written after #3393 carry the host-clock schedule time so a - // re-projection can recompute the remaining wait; the field is optional - // for older snapshots. - const retryingWithTs = { - ...continuitySnapshot('epoch-1'), - rootTurn: { - ...continuitySnapshot('epoch-1').rootTurn, - providerRetry: { - phase: 'scheduled' as const, - attempt: 8, - maxAttempts: 10, - delayMs: 40_000, - ts: 1_700_000_000_000, - reason: 'rate_limit' as const, - }, - }, - }; - assert.deepEqual(decodeSessionContinuitySnapshot(retryingWithTs), retryingWithTs); assert.throws( () => decodeSessionContinuitySnapshot({ @@ -1606,92 +1578,6 @@ describe('Runtime Host bootstrap protocol', () => { assert.deepEqual(decodeClientFrame(submit), submit); assert.deepEqual(decodeClientFrame(retract), retract); assert.deepEqual(decodeClientFrame(interrupt), interrupt); - const entryRetract = { - requestId: 'entry-retract-request-1', - operation: 'queue.entry.retract' as const, - input: { - originHostEpoch: 'epoch-1', - sessionId: 'session-1', - entryId: 'entry-1', - retractId: 'retract-2', - }, - }; - const entryPromote = { - requestId: 'entry-promote-request-1', - operation: 'queue.entry.promote' as const, - input: { - originHostEpoch: 'epoch-1', - sessionId: 'session-1', - entryId: 'entry-1', - promoteId: 'promote-1', - }, - }; - const entryUpdate = { - requestId: 'entry-update-request-1', - operation: 'queue.entry.update' as const, - input: { - originHostEpoch: 'epoch-1', - sessionId: 'session-1', - entryId: 'entry-1', - updateId: 'update-1', - expectedQueueRevision: 7, - text: 'updated message', - }, - }; - const entriesReorder = { - requestId: 'entries-reorder-request-1', - operation: 'queue.entries.reorder' as const, - input: { - originHostEpoch: 'epoch-1', - sessionId: 'session-1', - reorderId: 'reorder-1', - entryIds: ['entry-2', 'entry-1'], - }, - }; - assert.deepEqual(decodeClientFrame(entryRetract), entryRetract); - assert.deepEqual(decodeClientFrame(entryPromote), entryPromote); - assert.deepEqual(decodeClientFrame(entryUpdate), entryUpdate); - assert.deepEqual(decodeClientFrame(entriesReorder), entriesReorder); - assert.throws( - () => - decodeClientFrame({ - ...entryUpdate, - input: { ...entryUpdate.input, text: ' ' }, - }), - isInvalidFrame, - ); - assert.throws( - () => - decodeClientFrame({ - ...entriesReorder, - input: { ...entriesReorder.input, entryIds: ['entry-1', 'entry-1'] }, - }), - isInvalidFrame, - ); - assert.throws( - () => - decodeClientFrame({ - ...entriesReorder, - input: { ...entriesReorder.input, entryIds: ['not/a/semantic/id'] }, - }), - isInvalidFrame, - ); - assert.throws( - () => - decodeClientFrame({ - ...entryRetract, - input: { ...entryRetract.input, generation: 1 }, - }), - isInvalidFrame, - ); - assert.throws( - () => - decodeClientFrame({ - ...entryPromote, - input: { ...entryPromote.input, entryId: 'not/a/semantic/id' }, - }), - isInvalidFrame, - ); assert.throws( () => decodeClientFrame({ ...submit, input: { ...submit.input, originHostEpoch: undefined } }), @@ -2159,31 +2045,6 @@ describe('Runtime Host bootstrap protocol', () => { isInvalidFrame, ); } - for (const [operation, requestId] of [ - ['queue.entry.retract', 'entry-retract-response'], - ['queue.entry.promote', 'entry-promote-response'], - ['queue.entry.update', 'entry-update-response'], - ['queue.entries.reorder', 'entries-reorder-response'], - ] as const) { - assert.doesNotThrow(() => - decodeHostFrame({ - requestId, - operation, - ok: true, - result: { queueRevision: 8 }, - }), - ); - assert.throws( - () => - decodeHostFrame({ - requestId, - operation, - ok: true, - result: { queueRevision: 8, retracted: [] }, - }), - isInvalidFrame, - ); - } const retracted = [retractedMessage()]; assert.doesNotThrow(() => decodeHostFrame({ diff --git a/packages/runtime-host/src/__tests__/provider-retry-projector.test.ts b/packages/runtime-host/src/__tests__/provider-retry-projector.test.ts new file mode 100644 index 00000000000..737bee256e0 --- /dev/null +++ b/packages/runtime-host/src/__tests__/provider-retry-projector.test.ts @@ -0,0 +1,113 @@ +/* + * 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 { + projectProviderRetryChange, + seedProviderRetry, +} from '../adapter/provider-retry-projector.js'; +import type { LiveTurnSnapshot, TurnProviderRetry } from '../protocol/index.js'; + +const ROOT: Omit = { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'running', +}; + +test('projects retry snapshots into clock-safe client events', () => { + const cases: Array<{ + retry: TurnProviderRetry; + now: number; + expected: Record; + }> = [ + { + retry: { + phase: 'scheduled', + attempt: 2, + maxAttempts: 4, + delayMs: 30_000, + ts: 12_000, + reason: 'rate_limit', + }, + now: 17_000, + expected: { phase: 'scheduled', delayMs: 30_000, remainingMs: 25_000 }, + }, + { + retry: { + phase: 'scheduled', + attempt: 2, + maxAttempts: 4, + delayMs: 30_000, + ts: 12_000, + reason: 'rate_limit', + }, + now: 10_000, + expected: { phase: 'scheduled', delayMs: 30_000, remainingMs: 30_000 }, + }, + { + retry: { + phase: 'started', + attempt: 2, + maxAttempts: 4, + reason: 'network', + }, + now: 17_000, + expected: { phase: 'started' }, + }, + ]; + + for (const { retry, now, expected } of cases) { + const event = seedProviderRetry({ ...ROOT, providerRetry: retry }, now); + assert.ok(event); + assert.deepEqual( + Object.fromEntries( + Object.keys(expected).map((key) => [key, event[key as keyof typeof event]]), + ), + expected, + ); + } +}); + +test('emits only a changed retry for the same live run', () => { + const scheduled: TurnProviderRetry = { + phase: 'scheduled', + attempt: 2, + maxAttempts: 4, + delayMs: 1_000, + ts: 100, + reason: 'timeout', + }; + const current = { ...ROOT, providerRetry: scheduled }; + + assert.equal(projectProviderRetryChange(current, current, 200), undefined); + assert.equal(projectProviderRetryChange(current, { ...ROOT }, 200), undefined); + assert.equal( + projectProviderRetryChange( + current, + { + ...ROOT, + providerRetry: { ...scheduled, phase: 'started' }, + }, + 200, + )?.phase, + 'started', + ); +}); diff --git a/packages/runtime-host/src/__tests__/queue-mutation-protocol.test.ts b/packages/runtime-host/src/__tests__/queue-mutation-protocol.test.ts new file mode 100644 index 00000000000..605e8cd2758 --- /dev/null +++ b/packages/runtime-host/src/__tests__/queue-mutation-protocol.test.ts @@ -0,0 +1,116 @@ +/* + * 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 { RuntimeHostProtocolError } from '../protocol/errors.js'; +import { decodeClientFrame, decodeHostFrame } from '../protocol/index.js'; + +const BASE = { + originHostEpoch: 'epoch-1', + sessionId: 'session-1', + entryId: 'entry-1', +} as const; + +const REQUESTS = [ + { + requestId: 'retract-request', + operation: 'queue.entry.retract' as const, + input: { ...BASE, retractId: 'retract-1' }, + }, + { + requestId: 'promote-request', + operation: 'queue.entry.promote' as const, + input: { ...BASE, promoteId: 'promote-1' }, + }, + { + requestId: 'update-request', + operation: 'queue.entry.update' as const, + input: { ...BASE, updateId: 'update-1', expectedQueueRevision: 7, text: 'updated' }, + }, + { + requestId: 'reorder-request', + operation: 'queue.entries.reorder' as const, + input: { + originHostEpoch: BASE.originHostEpoch, + sessionId: BASE.sessionId, + reorderId: 'reorder-1', + expectedQueueRevision: 7, + entryIds: ['entry-2', 'entry-1'], + }, + }, +] as const; + +test('queue mutation requests decode as exact operation-specific records', () => { + for (const request of REQUESTS) { + assert.deepEqual(decodeClientFrame(request), request); + assert.deepEqual( + decodeHostFrame({ + requestId: request.requestId, + operation: request.operation, + ok: true, + result: { queueRevision: 8 }, + }), + { + requestId: request.requestId, + operation: request.operation, + ok: true, + result: { queueRevision: 8 }, + }, + ); + } +}); + +test('queue mutation requests reject stale, ambiguous, and non-semantic inputs', () => { + const retract = REQUESTS[0]; + const promote = REQUESTS[1]; + const update = REQUESTS[2]; + const reorder = REQUESTS[3]; + const invalid = [ + { ...retract, input: { ...retract.input, generation: 1 } }, + { ...promote, input: { ...promote.input, entryId: 'not/a/semantic/id' } }, + { ...update, input: { ...update.input, text: ' ' } }, + { ...update, input: { ...update.input, expectedQueueRevision: -1 } }, + { ...reorder, input: { ...reorder.input, expectedQueueRevision: 1.5 } }, + { ...reorder, input: { ...reorder.input, entryIds: ['entry-1', 'entry-1'] } }, + { ...reorder, input: { ...reorder.input, entryIds: ['not/a/semantic/id'] } }, + ]; + for (const request of invalid) { + assert.throws(() => decodeClientFrame(request), isInvalidFrame); + } +}); + +test('queue mutation responses expose only the committed queue revision', () => { + for (const request of REQUESTS) { + assert.throws( + () => + decodeHostFrame({ + requestId: request.requestId, + operation: request.operation, + ok: true, + result: { queueRevision: 8, retracted: [] }, + }), + isInvalidFrame, + ); + } +}); + +function isInvalidFrame(error: unknown): boolean { + return error instanceof RuntimeHostProtocolError && error.code === 'invalid_frame'; +} diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts index 9a560e99e41..012b6f6bd7d 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -2003,67 +2003,45 @@ test('absolute live offsets survive a gap with no connected subscribers', async coordinator.close(); }); -test('keeps the current provider retry on the live Turn until the next content event', async () => { +test('publishes retry state to reconnecting clients until the Turn makes progress', async () => { const coordinator = new SessionContinuityCoordinator( HOST_EPOCH, async () => canonical(), new SessionAdmissionGate(), ); - const liveSink = new RecordingSink(); - const live = attachTestConnection(coordinator, 'connection-live', liveSink); - const opened = await open(coordinator, 'connection-live'); - assert.equal(opened.snapshot.rootTurn && 'providerRetry' in opened.snapshot.rootTurn, false); - live.activate(opened.subscriptionId); - await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { type: 'provider_retry', - id: 'retry-1', + id: 'retry-scheduled', turnId: 'turn-1', - ts: 1, + ts: 5_000, phase: 'scheduled', - attempt: 8, - maxAttempts: 10, - delayMs: 40_000, + attempt: 2, + maxAttempts: 4, + delayMs: 30_000, reason: 'rate_limit', }); - const retry = { - phase: 'scheduled' as const, - attempt: 8, - maxAttempts: 10, - delayMs: 40_000, - // The host-clock schedule time is kept so a re-projection mid-wait can - // recompute the authoritative remaining duration (#3393). - ts: 1, - reason: 'rate_limit' as const, - }; - attachTestConnection(coordinator, 'connection-remount', new RecordingSink()); - const remounted = await open(coordinator, 'connection-remount'); - assert.deepEqual( - remounted.snapshot.rootTurn && 'providerRetry' in remounted.snapshot.rootTurn - ? remounted.snapshot.rootTurn.providerRetry - : undefined, - retry, - ); - assert.ok( - liveSink.frames.some( - (frame) => - frame.kind === 'subscription.session_projection' && - frame.snapshot.rootTurn && - 'providerRetry' in frame.snapshot.rootTurn && - frame.snapshot.rootTurn.providerRetry?.phase === 'scheduled', - ), - ); + attachTestConnection(coordinator, 'connection-during-wait', new RecordingSink()); + const waiting = await open(coordinator, 'connection-during-wait'); + const waitingTurn = waiting.snapshot.rootTurn; + assert.equal(waitingTurn?.status, 'running'); + if (waitingTurn?.status !== 'running') return; + assert.deepEqual(waitingTurn.providerRetry, { + phase: 'scheduled', + attempt: 2, + maxAttempts: 4, + delayMs: 30_000, + ts: 5_000, + reason: 'rate_limit', + }); await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textEvent(1)); - attachTestConnection(coordinator, 'connection-after-text', new RecordingSink()); - const afterText = await open(coordinator, 'connection-after-text'); - assert.equal( - afterText.snapshot.rootTurn && 'providerRetry' in afterText.snapshot.rootTurn, - false, - ); - - live.abort(opened.subscriptionId); + attachTestConnection(coordinator, 'connection-after-progress', new RecordingSink()); + const progressed = await open(coordinator, 'connection-after-progress'); + const progressedTurn = progressed.snapshot.rootTurn; + assert.equal(progressedTurn?.status, 'running'); + if (progressedTurn?.status !== 'running') return; + assert.equal(progressedTurn.providerRetry, undefined); coordinator.close(); }); diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index fcdfef9c070..9c4cf109613 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -488,34 +488,6 @@ test('seeding a rootless snapshot conveys the authoritative queue', () => { assert.deepEqual(update.followup, []); }); -test('reseeds the latest provider retry when the active Turn still carries one', () => { - const retry = { - phase: 'scheduled' as const, - attempt: 8, - maxAttempts: 10, - delayMs: 40_000, - reason: 'rate_limit' as const, - }; - const projector = new RuntimeHostSessionProjector( - snapshot({ - rootTurn: { - sessionId: 'session-1', - turnId: 'turn-1', - runId: 'run-1', - status: 'running', - providerRetry: retry, - }, - }), - createRuntimeHostSessionProjectionSeed([], snapshot()), - () => 10, - ); - - const seeded = projector.seedActive(true); - assert.equal(seeded.length, 1); - assert.equal(seeded[0]?.type, 'provider_retry'); - assert.equal(seeded[0] && 'phase' in seeded[0] ? seeded[0].phase : undefined, 'scheduled'); -}); - test('projects structured context-budget failure detail to the Desktop event', () => { const projector = new RuntimeHostSessionProjector( snapshot(), @@ -586,73 +558,31 @@ test('reseeds a scheduled retry with remainingMs recomputed from the stored sche assert.equal(retry.remainingMs, 39_995); }); -test('emits a live provider retry when the snapshot overlay appears, then drops it after content', () => { - const projector = new RuntimeHostSessionProjector( - snapshot(), - createRuntimeHostSessionProjectionSeed([], snapshot()), - () => 10, - ); - const retrying = snapshot({ - projectionRevision: 2, +test('a backward Host clock adjustment cannot lengthen a scheduled retry', () => { + const canonical = snapshot({ rootTurn: { sessionId: 'session-1', turnId: 'turn-1', runId: 'run-1', status: 'running', providerRetry: { - phase: 'scheduled', - attempt: 8, - maxAttempts: 10, + phase: 'scheduled' as const, + attempt: 1, + maxAttempts: 3, delayMs: 40_000, - reason: 'rate_limit', + ts: 100, + reason: 'rate_limit' as const, }, }, }); - const appeared = projector.accept({ - kind: 'subscription.session_projection', - hostEpoch: 'host-1', - subscriptionId: 'subscription-1', - sequence: 1, - snapshot: retrying, - }).events; - assert.equal(appeared.length, 1); - assert.equal(appeared[0]?.type, 'provider_retry'); - - const recovered = snapshot({ - projectionRevision: 3, - rootTurn: { - sessionId: 'session-1', - turnId: 'turn-1', - runId: 'run-1', - status: 'running', - }, - }); - projector.accept({ - kind: 'subscription.session_delta', - hostEpoch: 'host-1', - subscriptionId: 'subscription-1', - sequence: 2, - sessionId: 'session-1', - delta: { - kind: 'text', - turnId: 'turn-1', - runId: 'run-1', - messageId: 'message-1', - startOffset: 0, - text: 'ok', - }, - }); - projector.accept({ - kind: 'subscription.session_projection', - hostEpoch: 'host-1', - subscriptionId: 'subscription-1', - sequence: 3, - snapshot: recovered, - }); - assert.deepEqual( - projector.seedActive(true).map((event) => event.type), - ['text_delta'], + const projector = new RuntimeHostSessionProjector( + canonical, + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 50, ); + const retry = projector.seedActive(true)[0]; + assert.ok(retry && retry.type === 'provider_retry' && retry.phase === 'scheduled'); + assert.equal(retry.remainingMs, retry.delayMs); }); test('seeds only streams identified as active by the Host catch-up state', () => { diff --git a/packages/runtime-host/src/__tests__/turn-provider-retry.test.ts b/packages/runtime-host/src/__tests__/turn-provider-retry.test.ts new file mode 100644 index 00000000000..8daf241f8da --- /dev/null +++ b/packages/runtime-host/src/__tests__/turn-provider-retry.test.ts @@ -0,0 +1,78 @@ +/* + * 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 { decodeTurnSnapshot } from '../protocol/turn.js'; +import { decodeTurnProviderRetry } from '../protocol/turn-provider-retry.js'; + +test('decodes both provider retry phases', () => { + const values = [ + { + phase: 'scheduled', + attempt: 2, + maxAttempts: 4, + delayMs: 30_000, + ts: 1_000, + reason: 'rate_limit', + }, + { + phase: 'started', + attempt: 2, + maxAttempts: 4, + reason: 'network', + }, + ] as const; + for (const value of values) assert.deepEqual(decodeTurnProviderRetry(value), value); +}); + +test('rejects malformed provider retry snapshots', () => { + const invalid = [ + { phase: 'scheduled', attempt: 0, maxAttempts: 4, delayMs: 1, reason: 'rate_limit' }, + { phase: 'started', attempt: 5, maxAttempts: 4, reason: 'network' }, + { phase: 'scheduled', attempt: 1, maxAttempts: 4, delayMs: -1, reason: 'timeout' }, + { phase: 'started', attempt: 1, maxAttempts: 4, reason: 'billing' }, + { phase: 'paused', attempt: 1, maxAttempts: 4, reason: 'network' }, + ]; + for (const value of invalid) assert.throws(() => decodeTurnProviderRetry(value)); +}); + +test('accepts a retry only on a live Turn snapshot', () => { + const live = { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'running', + providerRetry: { + phase: 'scheduled', + attempt: 2, + maxAttempts: 4, + delayMs: 30_000, + reason: 'rate_limit', + }, + } as const; + assert.deepEqual(decodeTurnSnapshot(live), live); + assert.throws(() => + decodeTurnSnapshot({ + ...live, + status: 'completed', + terminalEventId: 'terminal-1', + }), + ); +}); diff --git a/packages/runtime-host/src/adapter/provider-retry-projector.ts b/packages/runtime-host/src/adapter/provider-retry-projector.ts new file mode 100644 index 00000000000..004b4e395d1 --- /dev/null +++ b/packages/runtime-host/src/adapter/provider-retry-projector.ts @@ -0,0 +1,88 @@ +/* + * 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 type { ProviderRetryEvent } from '@maka/core/events'; +import type { LiveTurnSnapshot, TurnProviderRetry, TurnSnapshot } from '../protocol/index.js'; + +export function seedProviderRetry( + turn: LiveTurnSnapshot, + now: number, +): ProviderRetryEvent | undefined { + return turn.providerRetry ? retryEvent(turn, turn.providerRetry, now) : undefined; +} + +export function projectProviderRetryChange( + previous: TurnSnapshot | null | undefined, + next: TurnSnapshot | null | undefined, + now: number, +): ProviderRetryEvent | undefined { + if (!isLiveTurn(next) || !next.providerRetry) return undefined; + const prior = + isLiveTurn(previous) && previous.runId === next.runId ? previous.providerRetry : undefined; + return sameRetry(prior, next.providerRetry) + ? undefined + : retryEvent(next, next.providerRetry, now); +} + +function retryEvent( + turn: LiveTurnSnapshot, + retry: TurnProviderRetry, + now: number, +): ProviderRetryEvent { + const base = { + type: 'provider_retry' as const, + id: `host-retry:${turn.runId}:${retry.phase}:${retry.attempt}`, + turnId: turn.turnId, + ts: now, + attempt: retry.attempt, + maxAttempts: retry.maxAttempts, + reason: retry.reason, + }; + if (retry.phase === 'started') return { ...base, phase: 'started' }; + + const elapsed = retry.ts === undefined ? 0 : Math.max(0, now - retry.ts); + return { + ...base, + phase: 'scheduled', + delayMs: retry.delayMs, + remainingMs: Math.max(0, retry.delayMs - elapsed), + }; +} + +function sameRetry(left: TurnProviderRetry | undefined, right: TurnProviderRetry): boolean { + if ( + !left || + left.phase !== right.phase || + left.attempt !== right.attempt || + left.maxAttempts !== right.maxAttempts || + left.reason !== right.reason + ) { + return false; + } + return ( + left.phase === 'started' || + (right.phase === 'scheduled' && left.delayMs === right.delayMs && left.ts === right.ts) + ); +} + +function isLiveTurn(turn: TurnSnapshot | null | undefined): turn is LiveTurnSnapshot { + return ( + !!turn && turn.status !== 'completed' && turn.status !== 'failed' && turn.status !== 'cancelled' + ); +} diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index f535c7262f1..0077cdfa2d4 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -17,7 +17,6 @@ * under the License. */ -import { isDeepStrictEqual } from 'node:util'; import { type ActiveInteractionRequestEvent, type ContextCompactionStartedEvent, @@ -32,9 +31,9 @@ import type { SessionMessageQueueProjection, SessionSteeringEvent, SubscriptionFrame, - LiveTurnSnapshot, TurnSnapshot, } from '../protocol/index.js'; +import { projectProviderRetryChange, seedProviderRetry } from './provider-retry-projector.js'; interface AssistantAccumulator { interrupted?: true; @@ -204,11 +203,9 @@ export class RuntimeHostSessionProjector { if (root.rootExecutionKind === 'context_compact') { events.push(contextCompactionStartedEvent(root, this.#now())); } - let seededAssistantText = false; if (includeAssistantText) { for (const accumulator of this.#accumulators.values()) { if (accumulator.complete) continue; - seededAssistantText = true; events.push({ type: accumulator.kind === 'text' ? 'text_delta' : 'thinking_delta', id: `host-seed:${root.runId}:${accumulator.kind}:${accumulator.messageId}`, @@ -220,8 +217,12 @@ export class RuntimeHostSessionProjector { }); } } - if (root.providerRetry && !seededAssistantText) { - events.push(providerRetryEvent(root, this.#now())); + const seededAssistantContent = events.some( + (event) => event.type === 'text_delta' || event.type === 'thinking_delta', + ); + if (!seededAssistantContent) { + const retry = seedProviderRetry(root, this.#now()); + if (retry) events.push(retry); } for (const interaction of this.#snapshot.interactions.pending) { events.push(...projectRuntimeHostInteractionRequest(interaction, this.#now())); @@ -452,7 +453,7 @@ export class RuntimeHostSessionProjector { if (root && rootIsCompaction && !previousWasCompaction) { events.push(contextCompactionStartedEvent(root, this.#now())); } - const retry = liveProviderRetryEvent(previousRoot, root, this.#now()); + const retry = projectProviderRetryChange(previousRoot, root, this.#now()); if (retry) events.push(retry); const terminalTurn = root && isRuntimeHostTerminalTurn(root) && !sameRuntimeHostTerminalTurn(previousRoot, root) @@ -811,61 +812,6 @@ export function sameRuntimeHostTerminalTurn( ); } -function liveProviderRetryEvent( - previous: TurnSnapshot | null | undefined, - next: TurnSnapshot | null | undefined, - ts: number, -): Extract | undefined { - if (!next || isRuntimeHostTerminalTurn(next) || !next.providerRetry) return undefined; - const previousRetry = - previous && !isRuntimeHostTerminalTurn(previous) ? previous.providerRetry : undefined; - if (previous?.runId === next.runId && isDeepStrictEqual(previousRetry, next.providerRetry)) { - return undefined; - } - return providerRetryEvent(next, ts); -} - -function providerRetryEvent( - root: LiveTurnSnapshot, - ts: number, -): Extract { - const retry = root.providerRetry; - if (!retry) { - throw new Error('Non-terminal Turn snapshot has no provider retry'); - } - if (retry.phase !== 'scheduled') { - return { - type: 'provider_retry', - id: `host-seed:${root.runId}:provider_retry`, - turnId: root.turnId, - ts, - phase: 'started', - attempt: retry.attempt, - maxAttempts: retry.maxAttempts, - reason: retry.reason, - }; - } - // remainingMs is the skew-free countdown authority for clients on another - // machine: a duration, recomputed from the host-clock schedule time stored - // in the snapshot, so a mid-wait re-projection (reconnect) does not restart - // the countdown. Snapshots from older runtimes lack `ts` and degrade to the - // full delay. - const remainingMs = - retry.ts === undefined ? retry.delayMs : Math.max(0, retry.delayMs - (ts - retry.ts)); - return { - type: 'provider_retry', - id: `host-seed:${root.runId}:provider_retry`, - turnId: root.turnId, - ts, - phase: 'scheduled', - attempt: retry.attempt, - maxAttempts: retry.maxAttempts, - delayMs: retry.delayMs, - remainingMs, - reason: retry.reason, - }; -} - function abortReason(source: string): Extract['reason'] { if (source.includes('timeout')) return 'timeout'; if (source.includes('crash') || source.includes('restart')) return 'crash'; diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 867911128af..57c809310c1 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -84,6 +84,7 @@ export * from './project-catalog-change.js'; export * from './execution-inspect.js'; export * from './external-session.js'; export * from './message.js'; +export * from './queue-mutation.js'; export * from './operations.js'; export * from './runtime-resource.js'; export * from './session-continuity.js'; @@ -103,7 +104,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 196 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 197 as const; +// 197: Queue reorder requests carry the expected queue revision. Epoch-196 +// peers reject the required field or send an unfenced reorder request. // 196: `turn.start` carries an optional durable external-message origin; older // peers would drop activation provenance and misclassify automated turns. // 195: Session catalog live run state may carry the host generation that @@ -113,6 +116,8 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 196 as const; // 194: Session catalog live run state may carry the runtime's run epoch, which // lets clients order same-revision reads. Epoch-193 peers reject the unknown // key, so a newer Desktop against an older Host loses session catalog reads. +// 193: Queue reorder requests require at least one entry identity. Epoch-192 +// peers can send an empty reorder request that the current Host rejects. // 192: Message quotes carry an optional annotation written by the user, which // the model reads beside the excerpt. An epoch-191 peer rejects the field. // 191: Session catalog change frames can carry attention events. @@ -451,8 +456,8 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 196 as const; // 41: Context compaction returns a typed terminal outcome on both Turn // snapshots and context.compact results. Epoch-40 peers reject these closed // shapes after admission, so mixed peers must fail during the handshake. -// 40: The message queue gains per-entry mutation operations -// (queue.entry.promote, queue.entry.retract, queue.entries.reorder). +// 40: Queue entries become independently addressable for promotion, +// retraction, and ordering. Epoch-39 peers do not recognize those commands. // 39: Client Capability tool descriptors carry trusted activity semantics and // invocations can stream bounded progress frames. // 38: `execute` is no longer a permission mode. Frame decoders reject it, so a diff --git a/packages/runtime-host/src/protocol/message-queue-limits.ts b/packages/runtime-host/src/protocol/message-queue-limits.ts new file mode 100644 index 00000000000..beda34aa551 --- /dev/null +++ b/packages/runtime-host/src/protocol/message-queue-limits.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export const MESSAGE_QUEUE_MAX_ENTRIES = 64; +export const MESSAGE_QUEUE_PROJECTION_MAX_BYTES = 52 * 1024; +export const MESSAGE_OPERATION_RESULT_MAX_BYTES = 56 * 1024; diff --git a/packages/runtime-host/src/protocol/message.ts b/packages/runtime-host/src/protocol/message.ts index 21819e6417d..7b147c8c45c 100644 --- a/packages/runtime-host/src/protocol/message.ts +++ b/packages/runtime-host/src/protocol/message.ts @@ -26,7 +26,6 @@ import { requireId, requireRecord, requireShapedRecord, - requireUtf8String, } from './codec.js'; import { defineOperation } from './operation-spec.js'; import { @@ -36,16 +35,30 @@ import { decodeTurnOrchestration, decodeTurnSnapshot, type MessageContent, - TURN_MESSAGE_TEXT_MAX_BYTES, type TurnSnapshot, } from './turn.js'; import { decodeSkillInvocationResult } from '@maka/core/skill-invocation'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import type { TurnOrchestration } from '@maka/core/runtime-inputs'; - -export const MESSAGE_QUEUE_MAX_ENTRIES = 64; -export const MESSAGE_QUEUE_PROJECTION_MAX_BYTES = 52 * 1024; -export const MESSAGE_OPERATION_RESULT_MAX_BYTES = 56 * 1024; +import { + MESSAGE_OPERATION_RESULT_MAX_BYTES, + MESSAGE_QUEUE_MAX_ENTRIES, + MESSAGE_QUEUE_PROJECTION_MAX_BYTES, +} from './message-queue-limits.js'; +import { QUEUE_MUTATION_OPERATION_SPECS } from './queue-mutation.js'; + +export { + MESSAGE_OPERATION_RESULT_MAX_BYTES, + MESSAGE_QUEUE_MAX_ENTRIES, + MESSAGE_QUEUE_PROJECTION_MAX_BYTES, +} from './message-queue-limits.js'; +export type { + QueueEntriesReorderInput, + QueueEntryPromoteInput, + QueueEntryRetractInput, + QueueEntryUpdateInput, + QueueMutationResult, +} from './queue-mutation.js'; export type MessagePlacement = 'current_turn' | 'next_turn'; @@ -157,40 +170,6 @@ export interface QueueRetractResult { readonly retracted: readonly RetractedMessageSnapshot[]; } -export interface QueueEntryRetractInput { - readonly originHostEpoch: string; - readonly sessionId: string; - readonly entryId: string; - readonly retractId: string; -} - -export interface QueueMutationResult { - readonly queueRevision: number; -} - -export interface QueueEntryPromoteInput { - readonly originHostEpoch: string; - readonly sessionId: string; - readonly entryId: string; - readonly promoteId: string; -} - -export interface QueueEntryUpdateInput { - readonly originHostEpoch: string; - readonly sessionId: string; - readonly entryId: string; - readonly updateId: string; - readonly expectedQueueRevision: number; - readonly text: string; -} - -export interface QueueEntriesReorderInput { - readonly originHostEpoch: string; - readonly sessionId: string; - readonly reorderId: string; - readonly entryIds: readonly string[]; -} - export interface TurnInterruptInput { readonly originHostEpoch: string; readonly sessionId: string; @@ -246,34 +225,7 @@ export const MESSAGE_OPERATION_SPECS = { decodeInput: decodeQueueRetractInput, decodeOutput: decodeQueueRetractResult, }), - 'queue.entry.retract': defineOperation({ - mode: 'command', - availability: 'ready', - errors: MESSAGE_OPERATION_ERRORS, - decodeInput: decodeQueueEntryRetractInput, - decodeOutput: decodeQueueMutationResult, - }), - 'queue.entry.promote': defineOperation({ - mode: 'command', - availability: 'ready', - errors: MESSAGE_OPERATION_ERRORS, - decodeInput: decodeQueueEntryPromoteInput, - decodeOutput: decodeQueueMutationResult, - }), - 'queue.entry.update': defineOperation({ - mode: 'command', - availability: 'ready', - errors: MESSAGE_OPERATION_ERRORS, - decodeInput: decodeQueueEntryUpdateInput, - decodeOutput: decodeQueueMutationResult, - }), - 'queue.entries.reorder': defineOperation({ - mode: 'command', - availability: 'ready', - errors: MESSAGE_OPERATION_ERRORS, - decodeInput: decodeQueueEntriesReorderInput, - decodeOutput: decodeQueueMutationResult, - }), + ...QUEUE_MUTATION_OPERATION_SPECS, 'turn.interrupt': defineOperation({ mode: 'control', availability: 'ready', @@ -508,83 +460,6 @@ function decodeQueueRetractResult(value: unknown): QueueRetractResult { return result; } -function decodeQueueEntryRetractInput(value: unknown): QueueEntryRetractInput { - const record = requireExactRecord(value, 'queue.entry.retract input', [ - 'originHostEpoch', - 'sessionId', - 'entryId', - 'retractId', - ]); - return { - originHostEpoch: requireId(record.originHostEpoch, 'originHostEpoch'), - sessionId: requireEntityId(record.sessionId, 'sessionId'), - entryId: requireEntityId(record.entryId, 'entryId'), - retractId: requireEntityId(record.retractId, 'retractId'), - }; -} - -function decodeQueueMutationResult(value: unknown): QueueMutationResult { - const record = requireExactRecord(value, 'queue mutation result', ['queueRevision']); - return { queueRevision: requireCount(record.queueRevision, 'queueRevision') }; -} - -function decodeQueueEntryPromoteInput(value: unknown): QueueEntryPromoteInput { - const record = requireExactRecord(value, 'queue.entry.promote input', [ - 'originHostEpoch', - 'sessionId', - 'entryId', - 'promoteId', - ]); - return { - originHostEpoch: requireId(record.originHostEpoch, 'originHostEpoch'), - sessionId: requireEntityId(record.sessionId, 'sessionId'), - entryId: requireEntityId(record.entryId, 'entryId'), - promoteId: requireEntityId(record.promoteId, 'promoteId'), - }; -} - -function decodeQueueEntryUpdateInput(value: unknown): QueueEntryUpdateInput { - const record = requireExactRecord(value, 'queue.entry.update input', [ - 'originHostEpoch', - 'sessionId', - 'entryId', - 'updateId', - 'expectedQueueRevision', - 'text', - ]); - const text = requireUtf8String(record.text, 'Message text', TURN_MESSAGE_TEXT_MAX_BYTES); - if (text.trim().length === 0) throw invalidProtocolFrame('Invalid Message text'); - return { - originHostEpoch: requireId(record.originHostEpoch, 'originHostEpoch'), - sessionId: requireEntityId(record.sessionId, 'sessionId'), - entryId: requireEntityId(record.entryId, 'entryId'), - updateId: requireEntityId(record.updateId, 'updateId'), - expectedQueueRevision: requireCount(record.expectedQueueRevision, 'expectedQueueRevision'), - text, - }; -} - -function decodeQueueEntriesReorderInput(value: unknown): QueueEntriesReorderInput { - const record = requireExactRecord(value, 'queue.entries.reorder input', [ - 'originHostEpoch', - 'sessionId', - 'reorderId', - 'entryIds', - ]); - const entryIds = requireBoundedArray(record.entryIds, 'reorder entry identities').map((entryId) => - requireEntityId(entryId, 'entryId'), - ); - if (new Set(entryIds).size !== entryIds.length) { - throw invalidProtocolFrame('Invalid reorder entry identities'); - } - return { - originHostEpoch: requireId(record.originHostEpoch, 'originHostEpoch'), - sessionId: requireEntityId(record.sessionId, 'sessionId'), - reorderId: requireEntityId(record.reorderId, 'reorderId'), - entryIds, - }; -} - function decodeTurnInterruptInput(value: unknown): TurnInterruptInput { const record = requireExactRecord(value, 'turn.interrupt input', [ 'originHostEpoch', diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 199be5f8235..86052d5880e 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -141,7 +141,6 @@ export type { } from './message.js'; export type { LiveTurnSnapshot, - TurnProviderRetry, TurnQueryInput, TurnResumeParkReason, TurnResumePlan, @@ -154,6 +153,7 @@ export type { TurnStartResult, TurnStopInput, } from './turn.js'; +export type { TurnProviderRetry } from './turn-provider-retry.js'; export * from './connection-effects.js'; export * from './access-authority.js'; export * from './configuration.js'; diff --git a/packages/runtime-host/src/protocol/queue-mutation.ts b/packages/runtime-host/src/protocol/queue-mutation.ts new file mode 100644 index 00000000000..26e20766dd7 --- /dev/null +++ b/packages/runtime-host/src/protocol/queue-mutation.ts @@ -0,0 +1,187 @@ +/* + * 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 { + requireCount, + requireEntityId, + requireExactRecord, + requireId, + requireUtf8String, +} from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; +import { MESSAGE_QUEUE_MAX_ENTRIES } from './message-queue-limits.js'; +import { defineOperation } from './operation-spec.js'; +import { TURN_MESSAGE_TEXT_MAX_BYTES } from './turn.js'; + +interface QueueEntryCommand { + readonly originHostEpoch: string; + readonly sessionId: string; + readonly entryId: string; +} + +export interface QueueEntryRetractInput extends QueueEntryCommand { + readonly retractId: string; +} + +export interface QueueEntryPromoteInput extends QueueEntryCommand { + readonly promoteId: string; +} + +export interface QueueEntryUpdateInput extends QueueEntryCommand { + readonly updateId: string; + readonly expectedQueueRevision: number; + readonly text: string; +} + +export interface QueueEntriesReorderInput { + readonly originHostEpoch: string; + readonly sessionId: string; + readonly reorderId: string; + readonly expectedQueueRevision: number; + readonly entryIds: readonly string[]; +} + +export interface QueueMutationResult { + readonly queueRevision: number; +} + +const QUEUE_MUTATION_ERRORS = [ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'not_found', + 'session_archived', + 'session_busy', + 'operation_conflict', + 'outcome_unknown', + 'internal_failure', +] as const; + +export const QUEUE_MUTATION_OPERATION_SPECS = { + 'queue.entry.retract': defineOperation({ + mode: 'command', + availability: 'ready', + errors: QUEUE_MUTATION_ERRORS, + decodeInput: decodeQueueEntryRetractInput, + decodeOutput: decodeQueueMutationResult, + }), + 'queue.entry.promote': defineOperation({ + mode: 'command', + availability: 'ready', + errors: QUEUE_MUTATION_ERRORS, + decodeInput: decodeQueueEntryPromoteInput, + decodeOutput: decodeQueueMutationResult, + }), + 'queue.entry.update': defineOperation({ + mode: 'command', + availability: 'ready', + errors: QUEUE_MUTATION_ERRORS, + decodeInput: decodeQueueEntryUpdateInput, + decodeOutput: decodeQueueMutationResult, + }), + 'queue.entries.reorder': defineOperation({ + mode: 'command', + availability: 'ready', + errors: QUEUE_MUTATION_ERRORS, + decodeInput: decodeQueueEntriesReorderInput, + decodeOutput: decodeQueueMutationResult, + }), +} as const; + +export function decodeQueueEntryRetractInput(value: unknown): QueueEntryRetractInput { + const command = decodeEntryCommand(value, 'queue.entry.retract input', 'retractId'); + const { operationId, ...identity } = command; + return { ...identity, retractId: requireEntityId(operationId, 'retractId') }; +} + +export function decodeQueueEntryPromoteInput(value: unknown): QueueEntryPromoteInput { + const command = decodeEntryCommand(value, 'queue.entry.promote input', 'promoteId'); + const { operationId, ...identity } = command; + return { ...identity, promoteId: requireEntityId(operationId, 'promoteId') }; +} + +export function decodeQueueEntryUpdateInput(value: unknown): QueueEntryUpdateInput { + const record = requireExactRecord(value, 'queue.entry.update input', [ + 'originHostEpoch', + 'sessionId', + 'entryId', + 'updateId', + 'expectedQueueRevision', + 'text', + ]); + const text = requireUtf8String(record.text, 'Message text', TURN_MESSAGE_TEXT_MAX_BYTES); + if (text.trim().length === 0) throw invalidProtocolFrame('Invalid Message text'); + return { + ...decodeEntryIdentity(record), + updateId: requireEntityId(record.updateId, 'updateId'), + expectedQueueRevision: requireCount(record.expectedQueueRevision, 'expectedQueueRevision'), + text, + }; +} + +export function decodeQueueEntriesReorderInput(value: unknown): QueueEntriesReorderInput { + const record = requireExactRecord(value, 'queue.entries.reorder input', [ + 'originHostEpoch', + 'sessionId', + 'reorderId', + 'expectedQueueRevision', + 'entryIds', + ]); + if (!Array.isArray(record.entryIds) || record.entryIds.length > MESSAGE_QUEUE_MAX_ENTRIES) { + throw invalidProtocolFrame('Invalid reorder entry identities'); + } + const entryIds = record.entryIds.map((entryId) => requireEntityId(entryId, 'entryId')); + if (new Set(entryIds).size !== entryIds.length) { + throw invalidProtocolFrame('Invalid reorder entry identities'); + } + return { + originHostEpoch: requireId(record.originHostEpoch, 'originHostEpoch'), + sessionId: requireEntityId(record.sessionId, 'sessionId'), + reorderId: requireEntityId(record.reorderId, 'reorderId'), + expectedQueueRevision: requireCount(record.expectedQueueRevision, 'expectedQueueRevision'), + entryIds, + }; +} + +export function decodeQueueMutationResult(value: unknown): QueueMutationResult { + const record = requireExactRecord(value, 'queue mutation result', ['queueRevision']); + return { queueRevision: requireCount(record.queueRevision, 'queueRevision') }; +} + +function decodeEntryCommand( + value: unknown, + label: string, + operationIdKey: 'retractId' | 'promoteId', +): QueueEntryCommand & { readonly operationId: unknown } { + const record = requireExactRecord(value, label, [ + 'originHostEpoch', + 'sessionId', + 'entryId', + operationIdKey, + ]); + return { ...decodeEntryIdentity(record), operationId: record[operationIdKey] }; +} + +function decodeEntryIdentity(record: Record): QueueEntryCommand { + return { + originHostEpoch: requireId(record.originHostEpoch, 'originHostEpoch'), + sessionId: requireEntityId(record.sessionId, 'sessionId'), + entryId: requireEntityId(record.entryId, 'entryId'), + }; +} diff --git a/packages/runtime-host/src/protocol/turn-provider-retry.ts b/packages/runtime-host/src/protocol/turn-provider-retry.ts new file mode 100644 index 00000000000..ff3ef8e3cdd --- /dev/null +++ b/packages/runtime-host/src/protocol/turn-provider-retry.ts @@ -0,0 +1,90 @@ +/* + * 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 type { ProviderRetryReason } from '@maka/core/events'; +import { assertExactKeys, requireCount, requireRecord } from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; + +interface RetryAttempt { + attempt: number; + maxAttempts: number; + reason: ProviderRetryReason; +} + +export type TurnProviderRetry = + | (RetryAttempt & { phase: 'scheduled'; delayMs: number; ts?: number }) + | (RetryAttempt & { phase: 'started' }); + +const RETRY_REASONS = new Set([ + 'stream_truncated', + 'network', + 'provider_capacity', + 'provider_unavailable', + 'rate_limit', + 'timeout', + 'unknown', +]); + +export function decodeTurnProviderRetry(value: unknown): TurnProviderRetry { + const record = requireRecord(value, 'Turn provider retry'); + const common = decodeAttempt(record); + + if (record.phase === 'started') { + assertExactKeys(record, 'started Turn provider retry', [ + 'phase', + 'attempt', + 'maxAttempts', + 'reason', + ]); + return { phase: 'started', ...common }; + } + + if (record.phase === 'scheduled') { + const keys = ['phase', 'attempt', 'maxAttempts', 'delayMs', 'reason']; + assertExactKeys( + record, + 'scheduled Turn provider retry', + record.ts === undefined ? keys : [...keys, 'ts'], + ); + return { + phase: 'scheduled', + ...common, + delayMs: requireCount(record.delayMs, 'delayMs'), + ...(record.ts === undefined ? {} : { ts: requireCount(record.ts, 'ts') }), + }; + } + + throw invalidProtocolFrame('Invalid Turn provider retry phase'); +} + +function decodeAttempt(record: Record): RetryAttempt { + const attempt = positiveCount(record.attempt, 'attempt'); + const maxAttempts = positiveCount(record.maxAttempts, 'maxAttempts'); + if (attempt > maxAttempts) throw invalidProtocolFrame('Invalid Turn provider retry attempt'); + if (!RETRY_REASONS.has(record.reason as ProviderRetryReason)) { + throw invalidProtocolFrame('Invalid Turn provider retry reason'); + } + return { attempt, maxAttempts, reason: record.reason as ProviderRetryReason }; +} + +function positiveCount(value: unknown, label: string): number { + const count = requireCount(value, label); + if (count === 0) throw invalidProtocolFrame(`Invalid ${label}`); + return count; +} diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index 4d4cb99f415..f5170c8ae9f 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -26,7 +26,6 @@ import { QUOTE_COMMENT_MAX_LENGTH, type ContextCompactionOutcome, type MessageContent, - type ProviderRetryReason, } from '@maka/core/events'; import { isOrchestrationMode, @@ -50,6 +49,10 @@ import { requireString, } from './codec.js'; import { defineOperation } from './operation-spec.js'; +import { decodeTurnProviderRetry, type TurnProviderRetry } from './turn-provider-retry.js'; + +export { decodeTurnProviderRetry } from './turn-provider-retry.js'; +export type { TurnProviderRetry } from './turn-provider-retry.js'; export const TURN_FAILURE_MESSAGE_MAX_BYTES = 256; @@ -163,27 +166,6 @@ interface TurnSnapshotBase { runId: string; } -export type TurnProviderRetry = - | { - phase: 'scheduled'; - attempt: number; - maxAttempts: number; - delayMs: number; - /** - * Host-clock time the wait was scheduled at, kept so a re-projection - * mid-wait can recompute the authoritative remaining duration. Absent - * from snapshots written by older runtimes. - */ - ts?: number; - reason: ProviderRetryReason; - } - | { - phase: 'started'; - attempt: number; - maxAttempts: number; - reason: ProviderRetryReason; - }; - export type LiveTurnSnapshot = TurnSnapshotBase & { status: Exclude; providerRetry?: TurnProviderRetry; @@ -750,56 +732,6 @@ export function decodeContextCompactionOutcome(value: unknown): ContextCompactio throw invalidProtocolFrame('Invalid context compaction outcome kind'); } -export function decodeTurnProviderRetry(value: unknown): TurnProviderRetry { - const record = requireRecord(value, 'Turn provider retry'); - const phase = record.phase; - const attempt = requirePositiveCount(record.attempt, 'attempt'); - const maxAttempts = requirePositiveCount(record.maxAttempts, 'maxAttempts'); - if (attempt > maxAttempts) throw invalidProtocolFrame('Invalid Turn provider retry'); - const reason = requireProviderRetryReason(record.reason); - if (phase === 'scheduled') { - const requiredKeys = ['phase', 'attempt', 'maxAttempts', 'delayMs', 'reason'] as const; - assertExactKeys( - record, - 'scheduled Turn provider retry', - record.ts === undefined ? requiredKeys : [...requiredKeys, 'ts'], - ); - return { - phase, - attempt, - maxAttempts, - delayMs: requireCount(record.delayMs, 'delayMs'), - ...(record.ts !== undefined ? { ts: requireCount(record.ts, 'ts') } : {}), - reason, - }; - } - if (phase === 'started') { - assertExactKeys(record, 'started Turn provider retry', [ - 'phase', - 'attempt', - 'maxAttempts', - 'reason', - ]); - return { phase, attempt, maxAttempts, reason }; - } - throw invalidProtocolFrame('Invalid Turn provider retry'); -} - -function requireProviderRetryReason(value: unknown): ProviderRetryReason { - if ( - value === 'network' || - value === 'provider_capacity' || - value === 'provider_unavailable' || - value === 'stream_truncated' || - value === 'rate_limit' || - value === 'timeout' || - value === 'unknown' - ) { - return value; - } - throw invalidProtocolFrame('Invalid Turn provider retry reason'); -} - function requireTurnRunStatus(value: unknown): TurnRunStatus { if ( value === 'admitted' || diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index e4e83acc49b..3a0da9493ff 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -48,7 +48,6 @@ import { type RootTurnSourceMessageReceipt, type SubmittedTurnIntent, } from '@maka/storage/execution-stores'; -import type { HostOperationErrorCode, OperationSpec } from '../protocol/operation-spec.js'; import { MESSAGE_QUEUE_MAX_ENTRIES, MESSAGE_QUEUE_PROJECTION_MAX_BYTES, @@ -73,6 +72,7 @@ import { type TurnMessageSubmitResult, type TurnSnapshot, } from '../protocol/index.js'; +import type { OperationSpec } from '../protocol/operation-spec.js'; import type { RuntimeHostResidency } from './host-kernel.js'; import { worstCaseFailedTurnSnapshot } from './canonical-turn-snapshot.js'; import { worstCaseMessageQueueProjection } from './message-queue-capacity.js'; @@ -83,6 +83,21 @@ import { } from './operation-dispatcher.js'; import { type SessionAdmissionLease, SessionAdmissionGate } from './session-admission-gate.js'; import type { LogicalRuntimeExecution } from '@maka/core/runtime-logical-execution'; +import { + QueuedMutationExecutor, + type QueuedMutationKind, + type QueuedMutationRequest, +} from './queued-mutation-executor.js'; +import { + commitFollowupPromotion, + commitQueueReorder, + checkQueueRevision, + locateQueuedEntry, + planQueueReorder, + removeQueuedEntry, + selectQueuedEntry, + type QueuedEntrySelection, +} from './message-queue-state.js'; type MessageOperationErrorCode = | 'host_draining' @@ -93,6 +108,29 @@ type MessageOperationErrorCode = | 'operation_conflict' | 'outcome_unknown'; +type QueueMutationOperationKey = + | 'queue.retract' + | 'queue.entry.retract' + | 'queue.entry.promote' + | 'queue.entry.update' + | 'queue.entries.reorder'; + +type QueueMutationInput = { + 'queue.retract': QueueRetractInput; + 'queue.entry.retract': QueueEntryRetractInput; + 'queue.entry.promote': QueueEntryPromoteInput; + 'queue.entry.update': QueueEntryUpdateInput; + 'queue.entries.reorder': QueueEntriesReorderInput; +}; + +type QueueMutationOutput = { + 'queue.retract': QueueRetractResult; + 'queue.entry.retract': QueueMutationResult; + 'queue.entry.promote': QueueMutationResult; + 'queue.entry.update': QueueMutationResult; + 'queue.entries.reorder': QueueMutationResult; +}; + type MessageOutcome = | { readonly ok: true; readonly result: T } | { @@ -323,32 +361,13 @@ interface PendingSubmit { readonly result: Promise>; } -type QueuedMutationKind = 'retract' | 'retract_entry' | 'promote' | 'update_entry' | 'reorder'; - type MessageOperationKind = QueuedMutationKind | 'submit' | 'interrupt'; -interface PendingQueuedMutation { - readonly payload: { readonly sessionId: string }; - readonly result: Promise>; -} - interface CompletedOperation { readonly payloadIdentity: object; readonly result: object; } -interface QueuedMutationOptions< - I extends { readonly originHostEpoch: string; readonly sessionId: string }, - R, -> { - readonly spec: OperationSpec; - readonly operationKind: QueuedMutationKind; - readonly operationId: string; - readonly verb: string; - readonly input: I; - readonly execute: () => Promise>; -} - interface InterruptDeferred { readonly promise: Promise>; resolve(result: MessageOutcome): void; @@ -413,11 +432,41 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { 'turn.message.query': (input) => this.queryMessages(input), 'turn.message.execution.query': (input) => this.queryMessageExecutions(input), 'turn.message.submit': (input, context) => this.submit(input, context), - 'queue.retract': (input) => this.retract(input), - 'queue.entry.retract': (input) => this.retractQueuedEntry(input), - 'queue.entry.promote': (input) => this.promoteQueuedEntry(input), - 'queue.entry.update': (input) => this.updateQueuedEntry(input), - 'queue.entries.reorder': (input) => this.reorderQueuedEntries(input), + 'queue.retract': this.#queueMutationHandler( + 'queue.retract', + 'retract', + 'Retract', + (input) => input.retractId, + (input) => this.#retractAdmitted(input), + ), + 'queue.entry.retract': this.#queueMutationHandler( + 'queue.entry.retract', + 'retract_entry', + 'Retract entry', + (input) => input.retractId, + (input) => this.#retractQueuedEntryAdmitted(input), + ), + 'queue.entry.promote': this.#queueMutationHandler( + 'queue.entry.promote', + 'promote', + 'Promote entry', + (input) => input.promoteId, + (input) => this.#promoteQueuedEntryAdmitted(input), + ), + 'queue.entry.update': this.#queueMutationHandler( + 'queue.entry.update', + 'update_entry', + 'Update entry', + (input) => input.updateId, + (input) => this.#updateQueuedEntryAdmitted(input), + ), + 'queue.entries.reorder': this.#queueMutationHandler( + 'queue.entries.reorder', + 'reorder', + 'Reorder entries', + (input) => input.reorderId, + (input) => this.#reorderQueuedEntriesAdmitted(input), + ), 'turn.interrupt': (input) => this.interrupt(input), }; @@ -433,8 +482,8 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { readonly #preflightSessionSnapshot: CandidateSnapshotPreflight; readonly #sessions = new Map(); readonly #pendingSubmits = new Map(); - readonly #pendingQueuedMutations = new Map(); readonly #completedOperations = new Map(); + readonly #queueMutations: QueuedMutationExecutor; #draining = false; #failStopped = false; @@ -452,6 +501,13 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { this.#onProjectionChanged = options.onProjectionChanged ?? (() => undefined); this.#createId = options.createId ?? randomUUID; this.#preflightSessionSnapshot = options.preflightSessionSnapshot; + this.#queueMutations = new QueuedMutationExecutor({ + hostEpoch: this.#hostEpoch, + admissions: this.#sessionAdmission, + isFailStopped: () => this.#failStopped, + readCompleted: (kind, sessionId, operationId) => + this.#completedOperations.get(queuedMutationKey(kind, sessionId, operationId)), + }); } projection(sessionId: string): SessionMessageQueueProjection { @@ -1595,25 +1651,41 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { : this.#sessionAdmission.run(input.sessionId, execute); } - private retract(input: QueueRetractInput): Promise> { - return this.#runQueuedMutation({ - spec: MESSAGE_OPERATION_SPECS['queue.retract'], - operationKind: 'retract', - operationId: input.retractId, - verb: 'Retract', - input, - execute: () => this.#retractAdmitted(input), + #runQueueMutation( + request: Omit, 'payloadIdentity'>, + ): Promise> { + return this.#queueMutations.run({ + ...request, + payloadIdentity: completedPayloadIdentity(request.kind, request.input), }); } + #queueMutationHandler( + operation: K, + kind: QueuedMutationKind, + verb: string, + operationId: (input: QueueMutationInput[K]) => string, + execute: (input: QueueMutationInput[K]) => Promise>, + ) { + return (input: QueueMutationInput[K]) => + this.#runQueueMutation({ + spec: MESSAGE_OPERATION_SPECS[operation] as OperationSpec< + QueueMutationInput[K], + QueueMutationOutput[K], + MessageOperationErrorCode + >, + kind, + id: operationId(input), + verb, + input, + execute: () => execute(input), + }); + } + async #retractAdmitted(input: QueueRetractInput): Promise> { - const header = await this.#root.readSessionHeader(input.sessionId); - if (this.#failStopped) { - return failure('host_draining', 'Runtime Host message authority has failed'); - } - if (!header) return failure('not_found', 'Session does not exist'); - if (header.isArchived) return failure('session_archived', 'Session is archived'); - const state = this.#state(input.sessionId); + const admitted = await this.#openQueueMutation(input.sessionId, true); + if (!admitted.ok) return admitted; + const state = admitted.result; if ( !retractionResultFits( state, @@ -1644,187 +1716,29 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { return success(result); } - private retractQueuedEntry( - input: QueueEntryRetractInput, - ): Promise> { - return this.#runQueuedMutation({ - spec: MESSAGE_OPERATION_SPECS['queue.entry.retract'], - operationKind: 'retract_entry', - operationId: input.retractId, - verb: 'Retract', - input, - execute: () => this.#retractQueuedEntryAdmitted(input), - }); - } - - private promoteQueuedEntry( - input: QueueEntryPromoteInput, - ): Promise> { - return this.#runQueuedMutation({ - spec: MESSAGE_OPERATION_SPECS['queue.entry.promote'], - operationKind: 'promote', - operationId: input.promoteId, - verb: 'Promote', - input, - execute: () => this.#promoteQueuedEntryAdmitted(input), - }); - } - - private updateQueuedEntry( - input: QueueEntryUpdateInput, - ): Promise> { - return this.#runQueuedMutation({ - spec: MESSAGE_OPERATION_SPECS['queue.entry.update'], - operationKind: 'update_entry', - operationId: input.updateId, - verb: 'Update', - input, - execute: () => this.#updateQueuedEntryAdmitted(input), - }); - } - - private reorderQueuedEntries( - input: QueueEntriesReorderInput, - ): Promise> { - return this.#runQueuedMutation({ - spec: MESSAGE_OPERATION_SPECS['queue.entries.reorder'], - operationKind: 'reorder', - operationId: input.reorderId, - verb: 'Reorder', - input, - execute: () => this.#reorderQueuedEntriesAdmitted(input), - }); - } - - #runQueuedMutation( - options: QueuedMutationOptions, - ): Promise> { - const { input } = options; - const isCurrentEpoch = input.originHostEpoch === this.#hostEpoch; - const key = queuedMutationKey(options.operationKind, input.sessionId, options.operationId); - if (isCurrentEpoch) { - const pending = this.#pendingQueuedMutations.get(key); - if (pending) { - return samePayload(pending.payload, input) - ? (pending.result as Promise>) - : Promise.resolve( - failure('operation_conflict', `${options.verb} identity has a different payload`), - ); - } - } - if (this.#failStopped) { - return Promise.resolve(failure('host_draining', 'Runtime Host message authority has failed')); - } - if (!isCurrentEpoch) { - return Promise.resolve( - failure('outcome_unknown', `${options.verb} outcome is not durable across Host Epochs`), - ); - } - const result = this.#admitQueuedMutation(options); - this.#pendingQueuedMutations.set(key, { payload: input, result }); - void result.then( - () => this.#deletePendingQueuedMutation(key, result), - () => this.#deletePendingQueuedMutation(key, result), - ); - return result; - } - - #admitQueuedMutation< - I extends { readonly originHostEpoch: string; readonly sessionId: string }, - R, - >(options: QueuedMutationOptions): Promise> { - return this.#sessionAdmission.run(options.input.sessionId, async () => { - if (this.#failStopped) { - return failure('host_draining', 'Runtime Host message authority has failed'); - } - const receipt = await this.#readCompletedQueuedMutation(options); - if (receipt) { - return samePayload( - receipt.payloadIdentity, - completedPayloadIdentity(options.operationKind, options.input), - ) - ? success(receipt.result) - : failure('operation_conflict', `${options.verb} identity has a different payload`); - } - return options.execute(); - }); - } - - async #readCompletedQueuedMutation< - I extends { readonly originHostEpoch: string; readonly sessionId: string }, - R, - >( - options: QueuedMutationOptions, - ): Promise<{ readonly payloadIdentity: object; readonly result: R } | undefined> { - const receipt = this.#completedOperations.get( - queuedMutationKey(options.operationKind, options.input.sessionId, options.operationId), - ); - if (!receipt) return undefined; - try { - return { - payloadIdentity: receipt.payloadIdentity, - result: options.spec.decodeOutput(receipt.result), - }; - } catch (error) { - throw new RuntimeMessageAuthorityInvariantError( - `Invalid queued mutation replay outcome: ${ - error instanceof Error ? error.message : 'malformed' - }`, - ); - } - } - - #deletePendingQueuedMutation(key: string, result: Promise>): void { - if (this.#pendingQueuedMutations.get(key)?.result === result) { - this.#pendingQueuedMutations.delete(key); - } - } - async #retractQueuedEntryAdmitted( input: QueueEntryRetractInput, ): Promise> { - const header = await this.#root.readSessionHeader(input.sessionId); - if (this.#failStopped) { - return failure('host_draining', 'Runtime Host message authority has failed'); - } - if (!header) return failure('not_found', 'Session does not exist'); - if (header.isArchived) return failure('session_archived', 'Session is archived'); - const state = this.#state(input.sessionId); - if (state.transition) { - return failure('operation_conflict', 'Message queue is draining into the next Turn'); - } - const queued = findQueuedEntry(state, input.entryId); - if (!queued) { - if ([...state.inFlight.values()].some((entry) => entry.entryId === input.entryId)) { - return failure('operation_conflict', 'Message entry is already being delivered'); - } - return failure('not_found', 'Message queue entry does not exist'); - } - await this.#admissions.cancelMessageAdmissions(input.sessionId, [queued.entry.messageId]); - queued.remove(); - this.#releaseEntry(queued.entry); + const admitted = await this.#openQueueMutation(input.sessionId); + if (!admitted.ok) return admitted; + const state = admitted.result; + const selected = selectQueuedEntry(state, input.entryId); + if (selected.kind !== 'found') return queueEntrySelectionFailure(selected); + await this.#admissions.cancelMessageAdmissions(input.sessionId, [ + selected.location.entry.messageId, + ]); + this.#releaseEntry(removeQueuedEntry(state, selected.location)); this.#mutated(state); this.#maybeReclaim(input.sessionId, state); - const result = { queueRevision: state.revision }; - this.#rememberCompletedOperation( - 'retract_entry', - input.sessionId, - input.retractId, - input, - result, - ); - return success(result); + return this.#completeQueueEntryMutation('retract_entry', state, input.retractId, input); } async #promoteQueuedEntryAdmitted( input: QueueEntryPromoteInput, ): Promise> { - const header = await this.#root.readSessionHeader(input.sessionId); - if (this.#failStopped) { - return failure('host_draining', 'Runtime Host message authority has failed'); - } - if (!header) return failure('not_found', 'Session does not exist'); - if (header.isArchived) return failure('session_archived', 'Session is archived'); + const admitted = await this.#openQueueMutation(input.sessionId); + if (!admitted.ok) return admitted; + const state = admitted.result; const rootState = await this.#root.readRootState(input.sessionId); if (this.#failStopped) { return failure('host_draining', 'Runtime Host message authority has failed'); @@ -1832,29 +1746,19 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { if (rootState.kind !== 'active') { return failure('operation_conflict', 'No active Turn can accept steering'); } - const state = this.#state(input.sessionId); if (state.phase !== 'open') { return failure('session_busy', 'Message admission is closed for the active generation'); } - if (state.transition) { - return failure('operation_conflict', 'Message queue is draining into the next Turn'); - } if (!state.reservedRoot || !sameRun(state.reservedRoot, rootState)) { throw new RuntimeMessageAuthorityInvariantError( 'Root state does not match message reservation', ); } - const index = state.followup.findIndex((entry) => entry.entryId === input.entryId); - const entry = index === -1 ? undefined : state.followup[index]; - if (!entry) { - if (state.steering.some((queued) => queued.entryId === input.entryId)) { - return failure('operation_conflict', 'Message entry already steers the active Turn'); - } - if ([...state.inFlight.values()].some((queued) => queued.entryId === input.entryId)) { - return failure('operation_conflict', 'Message entry is already being delivered'); - } - return failure('not_found', 'Message queue entry does not exist'); + const selected = selectQueuedEntry(state, input.entryId, 'followup'); + if (selected.kind !== 'found') { + return queueEntrySelectionFailure(selected, 'Message entry already steers the active Turn'); } + const entry = selected.location.entry; const promotedSource = { ...sourceFromEntry(entry), placement: 'current_turn', @@ -1890,35 +1794,25 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { skillInvocation: entry.skillInvocation, admittedAt: entry.admittedAt, }); - state.followup.splice(index, 1); - state.steering.push({ ...entry, placement: 'current_turn', disposition: 'steering' }); + commitFollowupPromotion(state, selected.location, { + ...entry, + placement: 'current_turn', + disposition: 'steering', + }); this.#mutated(state); - const result = { queueRevision: state.revision }; - this.#rememberCompletedOperation('promote', input.sessionId, input.promoteId, input, result); - return success(result); + return this.#completeQueueEntryMutation('promote', state, input.promoteId, input); } async #updateQueuedEntryAdmitted( input: QueueEntryUpdateInput, ): Promise> { - const header = await this.#root.readSessionHeader(input.sessionId); - if (this.#failStopped) { - return failure('host_draining', 'Runtime Host message authority has failed'); - } - if (!header) return failure('not_found', 'Session does not exist'); - if (header.isArchived) return failure('session_archived', 'Session is archived'); - const state = this.#state(input.sessionId); - if (state.transition) { - return failure('operation_conflict', 'Message queue is draining into the next Turn'); - } - const queued = findQueuedEntry(state, input.entryId); - if (!queued) { - if ([...state.inFlight.values()].some((entry) => entry.entryId === input.entryId)) { - return failure('operation_conflict', 'Message entry is already being delivered'); - } - return failure('not_found', 'Message queue entry does not exist'); - } - if (state.revision !== input.expectedQueueRevision) { + const admitted = await this.#openQueueMutation(input.sessionId); + if (!admitted.ok) return admitted; + const state = admitted.result; + const selected = selectQueuedEntry(state, input.entryId); + if (selected.kind !== 'found') return queueEntrySelectionFailure(selected); + const queued = selected.location; + if (checkQueueRevision(state.revision, input.expectedQueueRevision).kind === 'stale') { return failure('operation_conflict', 'Message queue changed since editing began'); } if (!state.reservedRoot) { @@ -1979,7 +1873,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } if ( state.revision !== currentRevision || - findQueuedEntry(state, input.entryId)?.entry !== queued.entry + locateQueuedEntry(state, input.entryId)?.entry !== queued.entry ) { return failure('session_busy', 'Message queue changed during update'); } @@ -2005,60 +1899,62 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { queued.entry.submittedContentDigest = messageContentDigest(content); queued.entry.skillInvocation = prepared.skillInvocation; this.#mutated(state); - const result = { queueRevision: state.revision }; - this.#rememberCompletedOperation( - 'update_entry', - input.sessionId, - input.updateId, - input, - result, - ); - return success(result); + return this.#completeQueueEntryMutation('update_entry', state, input.updateId, input); } async #reorderQueuedEntriesAdmitted( input: QueueEntriesReorderInput, ): Promise> { - const header = await this.#root.readSessionHeader(input.sessionId); - if (this.#failStopped) { - return failure('host_draining', 'Runtime Host message authority has failed'); - } - if (!header) return failure('not_found', 'Session does not exist'); - if (header.isArchived) return failure('session_archived', 'Session is archived'); - const state = this.#state(input.sessionId); - if (state.transition) { - return failure('operation_conflict', 'Message queue is draining into the next Turn'); - } - const steering = state.steering.some((entry) => entry.entryId === input.entryIds[0]); - const current = steering ? state.steering : state.followup; - if (input.entryIds.length !== current.length) { + const admitted = await this.#openQueueMutation(input.sessionId); + if (!admitted.ok) return admitted; + const state = admitted.result; + if (checkQueueRevision(state.revision, input.expectedQueueRevision).kind === 'stale') { return failure('operation_conflict', 'Message queue changed since the reorder was issued'); } - const byId = new Map(current.map((entry) => [entry.entryId, entry])); - const reordered: LiveEntry[] = []; - for (const entryId of input.entryIds) { - const entry = byId.get(entryId); - if (!entry) { - return failure('operation_conflict', 'Message queue changed since the reorder was issued'); - } - byId.delete(entryId); - reordered.push(entry); + const reorder = planQueueReorder(state, input.entryIds); + if (!reorder) { + return failure('operation_conflict', 'Message queue changed since the reorder was issued'); } - if (reordered.some((entry, index) => current[index] !== entry)) { + if (reorder.changed) { await this.#admissions.reorderMessageAdmissions( input.sessionId, - reordered.map((entry) => entry.messageId), - steering ? 'steering' : 'followup', + reorder.entries.map((entry) => entry.messageId), + reorder.lane, ); - if (steering) state.steering = reordered; - else state.followup = reordered; + commitQueueReorder(state, reorder.lane, reorder.entries); this.#mutated(state); } - const result = { queueRevision: state.revision }; - this.#rememberCompletedOperation('reorder', input.sessionId, input.reorderId, input, result); + return this.#completeQueueEntryMutation('reorder', state, input.reorderId, input); + } + + #completeQueueEntryMutation( + kind: Extract, + state: SessionState, + operationId: string, + input: object, + ): MessageOutcome { + const result: QueueMutationResult = { queueRevision: state.revision }; + this.#rememberCompletedOperation(kind, state.sessionId, operationId, input, result); return success(result); } + async #openQueueMutation( + sessionId: string, + allowTransition = false, + ): Promise> { + const header = await this.#root.readSessionHeader(sessionId); + if (this.#failStopped) { + return failure('host_draining', 'Runtime Host message authority has failed'); + } + if (!header) return failure('not_found', 'Session does not exist'); + if (header.isArchived) return failure('session_archived', 'Session is archived'); + const state = this.#state(sessionId); + if (!allowTransition && state.transition) { + return failure('operation_conflict', 'Message queue is draining into the next Turn'); + } + return success(state); + } + private async interrupt(input: TurnInterruptInput): Promise> { if (input.originHostEpoch !== this.#hostEpoch) { return failure('outcome_unknown', 'Interrupt outcome is not durable across Host Epochs'); @@ -2374,9 +2270,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { ({ payload }) => payload.sessionId === run.sessionId && payload.placement === 'current_turn', ), - ...[...this.#pendingQueuedMutations.values()].filter( - ({ payload }) => payload.sessionId === run.sessionId, - ), + ...this.#queueMutations.pendingResults(run.sessionId).map((result) => ({ result })), ]; if (pending.length === 0) break; await Promise.all(pending.map(({ result }) => result)); @@ -2619,6 +2513,19 @@ function failure( return { ok: false, error: { code, message } }; } +function queueEntrySelectionFailure( + selection: Exclude, { readonly kind: 'found' }>, + wrongLaneMessage = 'Message queue entry is not in the required lane', +): MessageOutcome { + if (selection.kind === 'in_flight') { + return failure('operation_conflict', 'Message entry is already being delivered'); + } + if (selection.kind === 'wrong_lane') { + return failure('operation_conflict', wrongLaneMessage); + } + return failure('not_found', 'Message queue entry does not exist'); +} + function operationKey(sessionId: string, operationId: string): string { return `${sessionId}\0${operationId}`; } @@ -2631,19 +2538,6 @@ function queuedMutationKey( return `${kind}\0${sessionId}\0${operationId}`; } -function findQueuedEntry( - state: SessionState, - entryId: string, -): { readonly entry: LiveEntry; remove(): void } | undefined { - for (const queue of [state.steering, state.followup]) { - const index = queue.findIndex((entry) => entry.entryId === entryId); - const entry = index === -1 ? undefined : queue[index]; - if (!entry) continue; - return { entry, remove: () => queue.splice(index, 1) }; - } - return undefined; -} - function relocateInlineReferences( references: MessageContent['inlineReferences'], text: string, diff --git a/packages/runtime-host/src/server/message-queue-state.ts b/packages/runtime-host/src/server/message-queue-state.ts new file mode 100644 index 00000000000..4caebf29e12 --- /dev/null +++ b/packages/runtime-host/src/server/message-queue-state.ts @@ -0,0 +1,130 @@ +/* + * 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 { exactQueueReorder } from '@maka/core/message-queue-order'; + +interface QueueEntryIdentity { + readonly entryId: string; + readonly messageId: string; +} + +interface QueueCollections { + steering: Entry[]; + followup: Entry[]; + readonly inFlight: ReadonlyMap; +} + +export type QueueRevisionCheck = + | { readonly kind: 'current'; readonly revision: number } + | { readonly kind: 'stale'; readonly expected: number; readonly actual: number }; + +/** The Host owns this fence; clients may only propose a mutation against the revision they read. */ +export function checkQueueRevision(actual: number, expected: number): QueueRevisionCheck { + return actual === expected + ? { kind: 'current', revision: actual } + : { kind: 'stale', expected, actual }; +} + +export interface QueuedEntryLocation { + readonly lane: 'steering' | 'followup'; + readonly index: number; + readonly entry: Entry; +} + +export type QueuedEntrySelection = + | { readonly kind: 'found'; readonly location: QueuedEntryLocation } + | { readonly kind: 'in_flight' } + | { readonly kind: 'wrong_lane'; readonly lane: QueuedEntryLocation['lane'] } + | { readonly kind: 'missing' }; + +export function locateQueuedEntry( + state: QueueCollections, + entryId: string, +): QueuedEntryLocation | undefined { + for (const lane of ['steering', 'followup'] as const) { + const index = state[lane].findIndex((entry) => entry.entryId === entryId); + const entry = index < 0 ? undefined : state[lane][index]; + if (entry) return { lane, index, entry }; + } + return undefined; +} + +export function hasInFlightEntry( + state: QueueCollections, + entryId: string, +): boolean { + return [...state.inFlight.values()].some((entry) => entry.entryId === entryId); +} + +export function selectQueuedEntry( + state: QueueCollections, + entryId: string, + requiredLane?: QueuedEntryLocation['lane'], +): QueuedEntrySelection { + const location = locateQueuedEntry(state, entryId); + if (location && (!requiredLane || location.lane === requiredLane)) { + return { kind: 'found', location }; + } + if (location) return { kind: 'wrong_lane', lane: location.lane }; + return hasInFlightEntry(state, entryId) ? { kind: 'in_flight' } : { kind: 'missing' }; +} + +export function removeQueuedEntry( + state: QueueCollections, + location: QueuedEntryLocation, +): Entry { + const [removed] = state[location.lane].splice(location.index, 1); + if (removed !== location.entry) throw new Error('Queued entry location changed before commit'); + return removed; +} + +export function commitFollowupPromotion( + state: QueueCollections, + location: QueuedEntryLocation, + promoted: Entry, +): void { + if (location.lane !== 'followup') throw new Error('Only follow-up entries can be promoted'); + removeQueuedEntry(state, location); + state.steering.push(promoted); +} + +export function planQueueReorder( + state: QueueCollections, + entryIds: readonly string[], +): + | { + readonly lane: 'steering' | 'followup'; + readonly entries: readonly Entry[]; + readonly changed: boolean; + } + | undefined { + for (const lane of ['steering', 'followup'] as const) { + const reorder = exactQueueReorder(state[lane], entryIds); + if (reorder) return { lane, entries: reorder.entries, changed: reorder.changed }; + } + return undefined; +} + +export function commitQueueReorder( + state: QueueCollections, + lane: 'steering' | 'followup', + entries: readonly Entry[], +): void { + state[lane] = [...entries]; +} diff --git a/packages/runtime-host/src/server/provider-retry-projection.ts b/packages/runtime-host/src/server/provider-retry-projection.ts new file mode 100644 index 00000000000..cf8e08e2119 --- /dev/null +++ b/packages/runtime-host/src/server/provider-retry-projection.ts @@ -0,0 +1,90 @@ +/* + * 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 type { ProviderRetryEvent } from '@maka/core/events'; +import type { LiveTurnSnapshot, TurnProviderRetry } from '../protocol/index.js'; +import type { CanonicalSessionProjection } from './canonical-session-projection.js'; + +export function recordProviderRetry( + canonical: CanonicalSessionProjection, + event: ProviderRetryEvent, +): CanonicalSessionProjection { + const rootTurn = liveRoot(canonical); + if (!rootTurn) return canonical; + return { + ...canonical, + rootTurn: { ...rootTurn, providerRetry: retrySnapshot(event) }, + }; +} + +export function clearProviderRetry( + canonical: CanonicalSessionProjection, +): CanonicalSessionProjection { + const rootTurn = liveRoot(canonical); + if (!rootTurn?.providerRetry) return canonical; + const { providerRetry: _removed, ...withoutRetry } = rootTurn; + return { ...canonical, rootTurn: withoutRetry }; +} + +export function carryProviderRetry( + current: CanonicalSessionProjection, + refreshed: CanonicalSessionProjection, +): CanonicalSessionProjection { + const previous = liveRoot(current); + const next = liveRoot(refreshed); + if ( + !previous?.providerRetry || + !next || + previous.runId !== next.runId || + previous.turnId !== next.turnId || + next.providerRetry + ) { + return refreshed; + } + return { ...refreshed, rootTurn: { ...next, providerRetry: previous.providerRetry } }; +} + +function liveRoot(canonical: CanonicalSessionProjection): LiveTurnSnapshot | undefined { + const root = canonical.rootTurn; + if ( + !root || + root.status === 'completed' || + root.status === 'failed' || + root.status === 'cancelled' + ) { + return undefined; + } + return root; +} + +function retrySnapshot(event: ProviderRetryEvent): TurnProviderRetry { + const common = { + attempt: event.attempt, + maxAttempts: event.maxAttempts, + reason: event.reason, + }; + return event.phase === 'started' + ? { phase: 'started', ...common } + : { + phase: 'scheduled', + ...common, + delayMs: event.delayMs, + ts: event.ts, + }; +} diff --git a/packages/runtime-host/src/server/queued-mutation-executor.ts b/packages/runtime-host/src/server/queued-mutation-executor.ts new file mode 100644 index 00000000000..6bce1eca6a6 --- /dev/null +++ b/packages/runtime-host/src/server/queued-mutation-executor.ts @@ -0,0 +1,175 @@ +/* + * 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 { isDeepStrictEqual } from 'node:util'; +import { RuntimeMessageAuthorityInvariantError } from '@maka/runtime/message-authority'; +import type { HostOperationErrorCode, OperationSpec } from '../protocol/operation-spec.js'; +import type { SessionAdmissionGate } from './session-admission-gate.js'; + +export type QueuedMutationKind = + | 'retract' + | 'retract_entry' + | 'promote' + | 'update_entry' + | 'reorder'; + +type MutationInput = { + readonly originHostEpoch: string; + readonly sessionId: string; +}; + +type QueuedMutationErrorCode = + | 'host_draining' + | 'operation_unavailable' + | 'not_found' + | 'session_archived' + | 'session_busy' + | 'operation_conflict' + | 'outcome_unknown'; + +type MutationOutcome = + | { readonly ok: true; readonly result: T } + | { + readonly ok: false; + readonly error: { readonly code: QueuedMutationErrorCode; readonly message: string }; + }; + +export interface CompletedQueuedMutation { + readonly payloadIdentity: object; + readonly result: object; +} + +export interface QueuedMutationRequest { + readonly spec: OperationSpec; + readonly kind: QueuedMutationKind; + readonly id: string; + readonly verb: string; + readonly input: I; + readonly payloadIdentity: object; + readonly execute: () => Promise>; +} + +interface PendingMutation { + readonly payload: MutationInput; + readonly result: Promise>; +} + +interface QueuedMutationExecutorOptions { + readonly hostEpoch: string; + readonly admissions: SessionAdmissionGate; + readonly isFailStopped: () => boolean; + readonly readCompleted: ( + kind: QueuedMutationKind, + sessionId: string, + operationId: string, + ) => CompletedQueuedMutation | undefined; +} + +/** Epoch-local idempotency and Session admission for all queue mutations. */ +export class QueuedMutationExecutor { + readonly #hostEpoch: string; + readonly #admissions: SessionAdmissionGate; + readonly #isFailStopped: () => boolean; + readonly #readCompleted: QueuedMutationExecutorOptions['readCompleted']; + readonly #pending = new Map(); + + constructor(options: QueuedMutationExecutorOptions) { + this.#hostEpoch = options.hostEpoch; + this.#admissions = options.admissions; + this.#isFailStopped = options.isFailStopped; + this.#readCompleted = options.readCompleted; + } + + run( + request: QueuedMutationRequest, + ): Promise> { + const currentEpoch = request.input.originHostEpoch === this.#hostEpoch; + const key = mutationKey(request.kind, request.input.sessionId, request.id); + if (currentEpoch) { + const pending = this.#pending.get(key); + if (pending) { + return isDeepStrictEqual(pending.payload, request.input) + ? (pending.result as Promise>) + : Promise.resolve(conflict(`${request.verb} identity has a different payload`)); + } + } + if (this.#isFailStopped()) return Promise.resolve(draining()); + if (!currentEpoch) { + return Promise.resolve( + failed('outcome_unknown', `${request.verb} outcome is not durable across Host Epochs`), + ); + } + + const result = this.#admit(request); + this.#pending.set(key, { payload: request.input, result }); + void result.then( + () => this.#settle(key, result), + () => this.#settle(key, result), + ); + return result; + } + + pendingResults(sessionId: string): readonly Promise>[] { + return [...this.#pending.values()] + .filter(({ payload }) => payload.sessionId === sessionId) + .map(({ result }) => result); + } + + #admit( + request: QueuedMutationRequest, + ): Promise> { + return this.#admissions.run(request.input.sessionId, async () => { + if (this.#isFailStopped()) return draining(); + const completed = this.#readCompleted(request.kind, request.input.sessionId, request.id); + if (!completed) return request.execute(); + if (!isDeepStrictEqual(completed.payloadIdentity, request.payloadIdentity)) { + return conflict(`${request.verb} identity has a different payload`); + } + try { + return { ok: true, result: request.spec.decodeOutput(completed.result) }; + } catch (error) { + throw new RuntimeMessageAuthorityInvariantError( + `Invalid queued mutation replay outcome: ${ + error instanceof Error ? error.message : 'malformed' + }`, + ); + } + }); + } + + #settle(key: string, result: Promise>): void { + if (this.#pending.get(key)?.result === result) this.#pending.delete(key); + } +} + +function mutationKey(kind: QueuedMutationKind, sessionId: string, operationId: string): string { + return `${kind}\0${sessionId}\0${operationId}`; +} + +function draining(): MutationOutcome { + return failed('host_draining', 'Runtime Host message authority has failed'); +} + +function conflict(message: string): MutationOutcome { + return failed('operation_conflict', message); +} + +function failed(code: QueuedMutationErrorCode, message: string): MutationOutcome { + return { ok: false, error: { code, message } }; +} diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index 37ca272ee95..79c754498a7 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -53,8 +53,6 @@ import { type SubscriptionFrame, type SubscriptionOpenInput, type SubscriptionOpenResult, - type LiveTurnSnapshot, - type TurnProviderRetry, type TurnSnapshot, } from '../protocol/index.js'; import type { @@ -68,6 +66,11 @@ import { type CanonicalSessionProjection, createSessionContinuitySnapshot, } from './canonical-session-projection.js'; +import { + carryProviderRetry, + clearProviderRetry, + recordProviderRetry, +} from './provider-retry-projection.js'; import type { SessionContinuityConnection, SessionContinuityFrameSink, @@ -754,10 +757,10 @@ export class SessionContinuityCoordinator implements SessionContinuityService { throw new Error('Runtime event does not belong to the canonical active root Turn'); } if (event.type === 'provider_retry') { - this.#publishCanonical(state, withProviderRetry(state.canonical, event)); + this.#commitLiveProjection(state, recordProviderRetry(state.canonical, event)); return; } - this.#publishCanonical(state, withoutProviderRetry(state.canonical)); + this.#commitLiveProjection(state, clearProviderRetry(state.canonical)); if (event.type === 'text_delta' || event.type === 'thinking_delta') { const kind: SessionAssistantDelta['kind'] = event.type === 'text_delta' ? 'text' : 'thinking'; @@ -1719,7 +1722,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService { this.#sessions.set(sessionId, state); return { changed: true, state, value }; } - canonical = preserveProviderRetry(state.canonical, canonical); + canonical = carryProviderRetry(state.canonical, canonical); const changed = !isDeepStrictEqual(state.canonical, canonical); if (changed) { if (state.canonical.rootTurn?.runId !== canonical.rootTurn?.runId) { @@ -1739,13 +1742,11 @@ export class SessionContinuityCoordinator implements SessionContinuityService { }; } - #publishCanonical(state: SessionProjectionState, canonical: CanonicalSessionProjection): void { - if (isDeepStrictEqual(state.canonical, canonical)) return; - const nextRevision = state.revision + 1; - const snapshot = createSessionContinuitySnapshot(canonical, nextRevision); - state.canonical = immutableClone(canonical); - state.revision = nextRevision; - this.#broadcastProjection(state, snapshot); + #commitLiveProjection(state: SessionProjectionState, next: CanonicalSessionProjection): void { + if (isDeepStrictEqual(state.canonical, next)) return; + state.revision += 1; + state.canonical = immutableClone(next); + this.#broadcastProjection(state, createSessionContinuitySnapshot(next, state.revision)); } #broadcastProjection(state: SessionProjectionState, snapshot: SessionContinuitySnapshot): void { @@ -1902,65 +1903,6 @@ function isTerminalTurn(turn: TurnSnapshot): boolean { return turn.status === 'completed' || turn.status === 'failed' || turn.status === 'cancelled'; } -function isLiveTurn(turn: TurnSnapshot): turn is LiveTurnSnapshot { - return !isTerminalTurn(turn); -} - -function withProviderRetry( - canonical: CanonicalSessionProjection, - event: Extract, -): CanonicalSessionProjection { - const rootTurn = canonical.rootTurn; - if (!rootTurn || !isLiveTurn(rootTurn)) return canonical; - const providerRetry: TurnProviderRetry = - event.phase === 'scheduled' - ? { - phase: 'scheduled', - attempt: event.attempt, - maxAttempts: event.maxAttempts, - delayMs: event.delayMs, - ts: event.ts, - reason: event.reason, - } - : { - phase: 'started', - attempt: event.attempt, - maxAttempts: event.maxAttempts, - reason: event.reason, - }; - return { ...canonical, rootTurn: { ...rootTurn, providerRetry } }; -} - -function withoutProviderRetry(canonical: CanonicalSessionProjection): CanonicalSessionProjection { - const rootTurn = canonical.rootTurn; - if (!rootTurn || !isLiveTurn(rootTurn) || rootTurn.providerRetry === undefined) { - return canonical; - } - const { providerRetry: _providerRetry, ...cleared } = rootTurn; - return { ...canonical, rootTurn: cleared }; -} - -function preserveProviderRetry( - current: CanonicalSessionProjection, - next: CanonicalSessionProjection, -): CanonicalSessionProjection { - const currentTurn = current.rootTurn; - const nextTurn = next.rootTurn; - if ( - !currentTurn || - !nextTurn || - !isLiveTurn(currentTurn) || - !isLiveTurn(nextTurn) || - currentTurn.runId !== nextTurn.runId || - currentTurn.turnId !== nextTurn.turnId || - currentTurn.providerRetry === undefined - ) { - return next; - } - if (nextTurn.providerRetry !== undefined) return next; - return { ...next, rootTurn: { ...nextTurn, providerRetry: currentTurn.providerRetry } }; -} - function wireTextByteLimit(frame: SessionDeltaFrame): number { return RUNTIME_HOST_MAX_MESSAGE_BYTES - encodeProtocolMessage(frame).byteLength; } diff --git a/packages/runtime-host/src/test-only/desktop-e2e-backend.ts b/packages/runtime-host/src/test-only/desktop-e2e-backend.ts new file mode 100644 index 00000000000..7ab525af423 --- /dev/null +++ b/packages/runtime-host/src/test-only/desktop-e2e-backend.ts @@ -0,0 +1,62 @@ +/* + * 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 type { + BackendCompactHistoryInput, + BackendCompactHistoryResult, +} from '@maka/core/backend-types'; +import { FakeBackend } from '@maka/runtime/test-only/fake-backend'; +import type { BackendFactoryContext } from '@maka/runtime/session-manager'; +import { createDesktopE2eCheckpoint } from './desktop-e2e-checkpoint.js'; + +export class DesktopE2eBackend extends FakeBackend { + constructor(private readonly context: BackendFactoryContext) { + super(context); + } + + async compactHistory(input: BackendCompactHistoryInput): Promise { + const recordCheckpoint = this.context.recordHistoryCompactCheckpoint; + if (!recordCheckpoint) { + throw new Error('Desktop E2E compaction requires a checkpoint recorder'); + } + + const checkpoint = createDesktopE2eCheckpoint(this.sessionId, input.runtimeContext); + await recordCheckpoint(checkpoint, input.turnId); + return { outcome: { kind: 'compacted', checkpointId: checkpoint.checkpointId } }; + } +} + +export const DESKTOP_E2E_OAUTH_AUTHORIZATION = { + startCodexAuthorization: async () => ({ + deviceAuthId: 'desktop-e2e-device-authorization', + userCode: 'MAKA-E2E', + verificationUrl: 'https://auth.openai.com/codex/device', + expiresAt: Date.now() + 60_000, + intervalMs: 1, + }), + pollCodexAuthorization: async () => ({ + authorizationCode: 'desktop-e2e-authorization-code', + codeVerifier: 'desktop-e2e-code-verifier', + }), + exchangeCodexCode: async () => ({ + access_token: 'desktop-e2e-access-token', + refresh_token: 'desktop-e2e-refresh-token', + expires_at: Date.now() + 3_600_000, + }), +}; diff --git a/packages/runtime-host/src/test-only/desktop-e2e-checkpoint.ts b/packages/runtime-host/src/test-only/desktop-e2e-checkpoint.ts new file mode 100644 index 00000000000..5e87073805b --- /dev/null +++ b/packages/runtime-host/src/test-only/desktop-e2e-checkpoint.ts @@ -0,0 +1,47 @@ +/* + * 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 type { RuntimeEvent } from '@maka/core/runtime-event'; +import { + buildHistoryCompactCheckpoint, + type HistoryCompactCheckpoint, +} from '@maka/runtime/history-compact-checkpoint'; + +export function createDesktopE2eCheckpoint( + sessionId: string, + coveredRuntimeEvents: readonly RuntimeEvent[], +): HistoryCompactCheckpoint { + return buildHistoryCompactCheckpoint({ + sessionId, + coveredRuntimeEvents, + summary: [ + '## Goal', + 'Deterministic Desktop E2E context checkpoint.', + '', + '## Progress', + '- deterministic compaction exercised', + '', + '## Next Steps', + '1. continue', + '', + '## Critical Context', + '- (none)', + ].join('\n'), + }); +} diff --git a/packages/runtime-host/src/test-only/desktop-e2e-execution.ts b/packages/runtime-host/src/test-only/desktop-e2e-execution.ts index 64754360c4d..69efd45472a 100644 --- a/packages/runtime-host/src/test-only/desktop-e2e-execution.ts +++ b/packages/runtime-host/src/test-only/desktop-e2e-execution.ts @@ -17,73 +17,13 @@ * under the License. */ -import type { - BackendCompactHistoryInput, - BackendCompactHistoryResult, -} from '@maka/core/backend-types'; -import { buildHistoryCompactCheckpoint } from '@maka/runtime/history-compact-checkpoint'; -import { FakeBackend } from '@maka/runtime/test-only/fake-backend'; -import { type BackendFactoryContext } from '@maka/runtime/session-manager'; import type { ExecutionRuntimeHostCandidateDependencies } from '../server/execution-candidate.js'; import { createExecutionRuntimeHostComposition } from '../server/execution-composition.js'; +import { DesktopE2eBackend, DESKTOP_E2E_OAUTH_AUTHORIZATION } from './desktop-e2e-backend.js'; /** Fresh Desktop E2E workspaces never reconnect; keep election retry, skip production grace. */ export const DESKTOP_E2E_IDLE_GRACE_MS = 500; -/** The desktop E2E backend mirrors the one control capability exercised by the slash menu. */ -export class DesktopE2eBackend extends FakeBackend { - constructor(private readonly backendContext: BackendFactoryContext) { - super(backendContext); - } - - async compactHistory(input: BackendCompactHistoryInput): Promise { - const recordCheckpoint = this.backendContext.recordHistoryCompactCheckpoint; - if (!recordCheckpoint) { - throw new Error('Desktop E2E compaction requires a checkpoint recorder'); - } - const checkpoint = buildHistoryCompactCheckpoint({ - sessionId: this.sessionId, - coveredRuntimeEvents: input.runtimeContext, - // Shaped like a real sectioned checkpoint so the builder's summary - // validation (#3029) admits this deterministic fixture. - summary: [ - '## Goal', - 'Deterministic Desktop E2E context checkpoint.', - '', - '## Progress', - '- deterministic compaction exercised', - '', - '## Next Steps', - '1. continue', - '', - '## Critical Context', - '- (none)', - ].join('\n'), - }); - await recordCheckpoint(checkpoint, input.turnId); - return { outcome: { kind: 'compacted', checkpointId: checkpoint.checkpointId } }; - } -} - -const DESKTOP_E2E_OAUTH_AUTHORIZATION = { - startCodexAuthorization: async () => ({ - deviceAuthId: 'desktop-e2e-device-authorization', - userCode: 'MAKA-E2E', - verificationUrl: 'https://auth.openai.com/codex/device', - expiresAt: Date.now() + 60_000, - intervalMs: 1, - }), - pollCodexAuthorization: async () => ({ - authorizationCode: 'desktop-e2e-authorization-code', - codeVerifier: 'desktop-e2e-code-verifier', - }), - exchangeCodexCode: async () => ({ - access_token: 'desktop-e2e-access-token', - refresh_token: 'desktop-e2e-refresh-token', - expires_at: Date.now() + 3_600_000, - }), -}; - export function createDesktopE2eExecutionCandidateDependencies(): ExecutionRuntimeHostCandidateDependencies { return { createComposition: (context, compositionOptions) => diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index ee864a1ab32..7e80fdcd400 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -29,7 +29,6 @@ import { providerFailureDiagnostic, providerModelFailure, } from '../provider-error-classification.js'; -import type { ModelFailureKind } from '../model-protocol.js'; describe('Provider error classification', () => { test('projects only bounded allowlisted facts into durable diagnostics', () => { @@ -223,192 +222,6 @@ describe('Provider error classification', () => { ); }); - test('derives retryability from the failure kind alone', () => { - // One real provider shape per kind: the kind is the only authority, so the - // table is the whole retry contract. - const cases: Array<[string, unknown, ModelFailureKind, boolean]> = [ - [ - 'websocket transport', - Object.assign(new Error('closed before completion'), { - name: 'OpenAiResponsesTransportError', - code: 'OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR', - }), - 'network', - true, - ], - [ - 'missing continuation', - Object.assign(new Error('continuation unavailable'), { - name: 'OpenAiResponsesTransportError', - code: 'OPENAI_RESPONSES_CONTINUATION_UNAVAILABLE', - }), - 'network', - true, - ], - [ - 'xAI capacity', - Object.assign(new Error('The model is currently at capacity due to high demand.'), { - name: 'AI_APICallError', - data: { error: { code: 'resource-exhausted' } }, - }), - 'provider_capacity', - true, - ], - [ - 'upstream 503', - Object.assign(new Error('Service unavailable'), { - name: 'AI_APICallError', - statusCode: 503, - }), - 'provider_unavailable', - true, - ], - [ - 'bare 429', - Object.assign(new Error('Upstream model provider is unavailable'), { - name: 'AI_APICallError', - statusCode: 429, - data: { - error: { - code: 'rate_limit_error', - message: 'Upstream model provider is temporarily unavailable. Please try again.', - }, - }, - }), - 'rate_limit', - true, - ], - [ - 'truncated stream', - new Error('response stream ended without a finish reason'), - 'stream_truncated', - true, - ], - [ - 'stream timeout', - Object.assign(new Error('model stream stalled'), { code: 'MODEL_STREAM_TIMEOUT' }), - 'timeout', - true, - ], - ['fetch timeout', new DOMException('request timed out', 'TimeoutError'), 'timeout', true], - [ - 'invalid key', - Object.assign(new Error('Invalid API key provided'), { - name: 'AI_APICallError', - statusCode: 401, - }), - 'auth', - false, - ], - [ - 'exhausted quota', - Object.assign(new Error('request failed'), { - name: 'AI_APICallError', - statusCode: 429, - data: { error: { code: 'insufficient_quota' } }, - }), - 'provider_billing', - false, - ], - [ - 'input overflow', - Object.assign(new Error('Bad Request'), { - name: 'AI_APICallError', - statusCode: 400, - data: { error: { code: 'context_length_exceeded' } }, - }), - 'context_overflow', - false, - ], - [ - // The AI SDK's own isRetryable flag calls 409 retryable; the kind decides. - 'conflict', - Object.assign(new Error('Conflict'), { - name: 'AI_APICallError', - statusCode: 409, - isRetryable: true, - }), - 'request_rejected', - false, - ], - [ - 'bad request', - Object.assign(new Error('bad request'), { name: 'AI_APICallError', statusCode: 400 }), - 'request_rejected', - false, - ], - ['fetch failure', new TypeError('fetch failed'), 'network', true], - [ - 'unclassifiable', - { type: 'invalid_request_error', message: 'missing required field' }, - 'unknown', - false, - ], - ]; - - for (const [label, error, kind, retryable] of cases) { - assert.equal(classifyError(error), kind, label); - assert.equal(providerModelFailure(error).retryable, retryable, label); - assert.equal(providerFailureDiagnostic(error).retryable, retryable, label); - } - }); - - test('Retry-After only sets the delay, never the retryability', () => { - const failing = (statusCode: number, responseHeaders?: Record) => - Object.assign(new Error('provider rejected the request'), { - name: 'AI_APICallError', - statusCode, - ...(responseHeaders ? { responseHeaders } : {}), - }); - const headerCases: Array<[string, Record | undefined, number | undefined]> = [ - ['seconds', { 'retry-after': '40' }, 40_000], - ['milliseconds', { 'retry-after-ms': '1500' }, 1_500], - ['malformed', { 'retry-after': 'not-a-delay' }, undefined], - ['elapsed HTTP-date', { 'retry-after': 'Wed, 21 Oct 2015 07:28:00 GMT' }, undefined], - ['absent', undefined, undefined], - ]; - - for (const statusCode of [429, 503]) { - for (const [label, headers, retryAfterMs] of headerCases) { - const failure = providerModelFailure(failing(statusCode, headers)); - assert.equal(failure.retryable, true, `${statusCode} ${label}`); - assert.equal(failure.retryAfterMs, retryAfterMs, `${statusCode} ${label}`); - } - } - - // A non-retryable kind stays non-retryable however generous the header is. - const refusedWithDelay = Object.assign(new Error('Invalid API key provided'), { - name: 'AI_APICallError', - statusCode: 401, - responseHeaders: { 'retry-after': '40' }, - }); - assert.partialDeepStrictEqual(providerModelFailure(refusedWithDelay), { retryable: false }); - assert.equal(providerModelFailure(refusedWithDelay).retryAfterMs, undefined); - }); - - test('abort and a spent Codex edge budget override the retryable kinds', () => { - const aborted = new RetryError({ - message: 'Retry stopped', - reason: 'abort', - errors: [Object.assign(new Error('Service unavailable'), { statusCode: 503 })], - }); - assert.equal(classifyError(aborted), 'abort'); - assert.partialDeepStrictEqual(providerModelFailure(aborted), { retryable: false }); - - const exhaustedEdge = Object.assign( - new Error('Codex OAuth request failed: HTTP 403 Request rejected'), - { - name: 'OpenAiCodexEdgeRejectionError', - statusCode: 403, - data: { error: { code: 'openai_codex_edge_rejection' } }, - responseHeaders: { 'retry-after': '40' }, - }, - ); - assert.equal(classifyError(exhaustedEdge), 'provider_unavailable'); - assert.partialDeepStrictEqual(providerModelFailure(exhaustedEdge), { retryable: false }); - assert.equal(providerModelFailure(exhaustedEdge).retryAfterMs, undefined); - }); - test('treats a status-less provider server_error as temporarily unavailable', () => { const failure = { type: 'model_failure', @@ -633,19 +446,6 @@ describe('Provider error classification', () => { } }); - test('an exhausted free tier on 429 is billing, not a throttle to retry', () => { - // OpenCode Zen reports the exhausted free allowance as error.type on a 429. - const freeUsageLimit = Object.assign(new Error('Rate limit exceeded'), { - name: 'AI_APICallError', - statusCode: 429, - data: { error: { type: 'FreeUsageLimitError', message: 'Rate limit exceeded' } }, - }); - - assert.equal(classifyError(freeUsageLimit), 'provider_billing'); - assert.partialDeepStrictEqual(providerModelFailure(freeUsageLimit), { retryable: false }); - assert.equal(providerFailureDiagnostic(freeUsageLimit).retryable, false); - }); - test('classifies provider capacity errors and retries with backoff', () => { const capacity = () => Object.assign(new Error('The model is currently at capacity due to high demand.'), { diff --git a/packages/runtime/src/__tests__/provider-retry-classification.test.ts b/packages/runtime/src/__tests__/provider-retry-classification.test.ts new file mode 100644 index 00000000000..f3eab4ef412 --- /dev/null +++ b/packages/runtime/src/__tests__/provider-retry-classification.test.ts @@ -0,0 +1,237 @@ +/* + * 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 { describe, test } from 'node:test'; +import { APICallError } from '@ai-sdk/provider'; +import { RetryError } from 'ai'; + +import { + classifyError, + providerFailureDiagnostic, + providerModelFailure, +} from '../provider-error-classification.js'; +import type { ModelFailureKind } from '../model-protocol.js'; + +const providerError = (message: string, fields: Record = {}) => + Object.assign(new Error(message), { name: 'AI_APICallError', ...fields }); + +const assertRetryProjection = (error: unknown, kind: ModelFailureKind, retryable: boolean) => { + assert.equal(classifyError(error), kind); + assert.equal(providerModelFailure(error).retryable, retryable); + assert.equal(providerFailureDiagnostic(error).retryable, retryable); +}; + +describe('Provider retry classification', () => { + test('derives retryability from the classified failure kind', () => { + const cases: Array<[string, unknown, ModelFailureKind, boolean]> = [ + [ + 'websocket transport', + Object.assign(new Error('closed before completion'), { + name: 'OpenAiResponsesTransportError', + code: 'OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR', + }), + 'network', + true, + ], + [ + 'missing continuation', + Object.assign(new Error('continuation unavailable'), { + name: 'OpenAiResponsesTransportError', + code: 'OPENAI_RESPONSES_CONTINUATION_UNAVAILABLE', + }), + 'network', + true, + ], + [ + 'provider capacity', + providerError('model is at capacity', { data: { error: { code: 'resource-exhausted' } } }), + 'provider_capacity', + true, + ], + [ + 'upstream 503', + providerError('service unavailable', { statusCode: 503 }), + 'provider_unavailable', + true, + ], + [ + 'bare 429', + providerError('temporarily unavailable', { + statusCode: 429, + data: { error: { code: 'rate_limit_error' } }, + }), + 'rate_limit', + true, + ], + [ + 'truncated stream', + new Error('response stream ended without a finish reason'), + 'stream_truncated', + true, + ], + [ + 'stream timeout', + Object.assign(new Error('model stream stalled'), { code: 'MODEL_STREAM_TIMEOUT' }), + 'timeout', + true, + ], + ['fetch timeout', new DOMException('request timed out', 'TimeoutError'), 'timeout', true], + [ + 'invalid key', + providerError('Invalid API key provided', { statusCode: 401 }), + 'auth', + false, + ], + [ + 'exhausted quota', + providerError('request failed', { + statusCode: 429, + data: { error: { code: 'insufficient_quota' } }, + }), + 'provider_billing', + false, + ], + [ + 'input overflow', + providerError('bad request', { + statusCode: 400, + data: { error: { code: 'context_length_exceeded' } }, + }), + 'context_overflow', + false, + ], + [ + 'conflict', + providerError('conflict', { statusCode: 409, isRetryable: true }), + 'request_rejected', + false, + ], + ['bad request', providerError('bad request', { statusCode: 400 }), 'request_rejected', false], + ['fetch failure', new TypeError('fetch failed'), 'network', true], + [ + 'unclassifiable', + { type: 'invalid_request_error', message: 'missing required field' }, + 'unknown', + false, + ], + ]; + + for (const [label, error, kind, retryable] of cases) { + assertRetryProjection(error, kind, retryable); + assert.equal(providerModelFailure(error).kind, kind, label); + } + }); + + test('uses Retry-After only as a delay hint', () => { + const cases: Array<[string, Record | undefined, number | undefined]> = [ + ['seconds', { 'retry-after': '40' }, 40_000], + ['milliseconds', { 'retry-after-ms': '1500' }, 1_500], + ['fallback', { 'retry-after-ms': 'invalid', 'retry-after': '4' }, 4_000], + ['malformed', { 'retry-after': 'not-a-delay' }, undefined], + ['elapsed date', { 'retry-after': 'Wed, 21 Oct 2015 07:28:00 GMT' }, undefined], + ['absent', undefined, undefined], + ]; + + for (const statusCode of [429, 503]) { + for (const [label, responseHeaders, retryAfterMs] of cases) { + const failure = providerModelFailure( + providerError('provider rejected the request', { statusCode, responseHeaders }), + ); + assert.equal(failure.retryable, true, `${statusCode} ${label}`); + assert.equal(failure.retryAfterMs, retryAfterMs, `${statusCode} ${label}`); + } + } + + const refused = providerError('Invalid API key provided', { + statusCode: 401, + responseHeaders: { 'retry-after': '40' }, + }); + assert.equal(providerModelFailure(refused).retryable, false); + assert.equal(providerModelFailure(refused).retryAfterMs, undefined); + }); + + test('reads Retry-After from Headers without changing retryability', () => { + const failure = providerModelFailure( + providerError('provider rejected the request', { + statusCode: 429, + responseHeaders: new Headers({ 'Retry-After': '3' }), + }), + ); + assert.equal(failure.retryable, true); + assert.equal(failure.retryAfterMs, 3_000); + }); + + test('abort and exhausted Codex edge budgets override transient kinds', () => { + const aborted = new RetryError({ + message: 'Retry stopped', + reason: 'abort', + errors: [Object.assign(new Error('Service unavailable'), { statusCode: 503 })], + }); + assertRetryProjection(aborted, 'abort', false); + + const exhaustedEdge = Object.assign( + new Error('Codex OAuth request failed: HTTP 403 Request rejected'), + { + name: 'OpenAiCodexEdgeRejectionError', + statusCode: 403, + data: { error: { code: 'openai_codex_edge_rejection' } }, + responseHeaders: { 'retry-after': '40' }, + }, + ); + assert.equal(classifyError(exhaustedEdge), 'provider_unavailable'); + assert.equal(providerModelFailure(exhaustedEdge).retryable, false); + assert.equal(providerModelFailure(exhaustedEdge).retryAfterMs, undefined); + }); + + test('treats an exhausted free tier on 429 as billing', () => { + const freeUsageLimit = providerError('Rate limit exceeded', { + statusCode: 429, + data: { error: { type: 'FreeUsageLimitError', message: 'Rate limit exceeded' } }, + }); + assertRetryProjection(freeUsageLimit, 'provider_billing', false); + }); + + test('keeps retry metadata consistent for 2xx response interruptions', () => { + for (const statusCode of [200, 201, 204, 206, 299]) { + for (const code of [ + 'ECONNRESET', + 'EPIPE', + 'ETIMEDOUT', + 'ECONNABORTED', + 'UND_ERR_SOCKET', + 'UND_ERR_BODY_TIMEOUT', + ]) { + const failure = new APICallError({ + message: 'Failed to process successful response', + url: 'https://provider.invalid', + requestBodyValues: {}, + statusCode, + responseHeaders: { 'x-request-id': 'request-5656' }, + cause: new TypeError('terminated', { + cause: Object.assign(new Error('connection closed'), { code }), + }), + }); + assert.equal(classifyError(failure), 'network'); + assert.equal(providerModelFailure(failure).retryable, true); + assert.equal(providerFailureDiagnostic(failure).retryable, true); + } + } + }); +}); diff --git a/packages/runtime/src/__tests__/provider-retry-policy.test.ts b/packages/runtime/src/__tests__/provider-retry-policy.test.ts new file mode 100644 index 00000000000..d083cb01d6a --- /dev/null +++ b/packages/runtime/src/__tests__/provider-retry-policy.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { + providerRetryDecision, + providerRetryReason, + responseHeadersFromError, + retryAfterMs, +} from '../provider-retry-policy.js'; + +test('retryability is determined only by the failure kind', () => { + assert.equal(providerRetryReason('rate_limit'), 'rate_limit'); + assert.equal(providerRetryReason('provider_billing'), null); +}); + +test('response headers are normalized from records and native Headers', () => { + assert.deepEqual(responseHeadersFromError({ responseHeaders: { 'Retry-After': '3' } }), { + 'retry-after': '3', + }); + assert.deepEqual( + responseHeadersFromError({ responseHeaders: new Headers({ 'Retry-After-Ms': '1250' }) }), + { 'retry-after-ms': '1250' }, + ); +}); + +test('a valid Retry-After is used when Retry-After-Ms is invalid', () => { + assert.equal(retryAfterMs({ 'retry-after-ms': 'invalid', 'retry-after': '4' }), 4_000); + assert.equal(retryAfterMs({ 'retry-after-ms': '1250', 'retry-after': '4' }), 1_250); + assert.equal(retryAfterMs({ 'retry-after': '0' }), undefined); +}); + +test('retry decision keeps retryability and server delay together', () => { + assert.deepEqual( + providerRetryDecision('rate_limit', { 'retry-after-ms': 'invalid', 'retry-after': '4' }), + { reason: 'rate_limit', retryAfterMs: 4_000 }, + ); + assert.deepEqual(providerRetryDecision('provider_billing', { 'retry-after': '4' }), { + reason: null, + }); +}); diff --git a/packages/runtime/src/__tests__/shell-run-manager.test.ts b/packages/runtime/src/__tests__/shell-run-manager.test.ts index d86696042db..6a5ff1d7e03 100644 --- a/packages/runtime/src/__tests__/shell-run-manager.test.ts +++ b/packages/runtime/src/__tests__/shell-run-manager.test.ts @@ -2091,25 +2091,22 @@ describe('ShellRunProcessManager', () => { const control = await pending; assert.equal(await readFile(sizeBeforeExit, 'utf8'), '80x24'); - const terminal = - control.status === 'starting' || control.status === 'running' - ? await waitForTerminalShellRun(manager, initial.ref, 15_000) - : control; - assertShellRunSnapshot(terminal); - assert.equal(terminal.status, 'completed'); - assert.equal(terminal.exitCode, 0); assert.deepEqual(control.operation, { kind: 'pty_control', failed: false, resize: { cols: 81, rows: 25, applied: false, changed: false }, }); - assert.equal(terminal.output.mode, 'pty'); - if (terminal.output.mode !== 'pty') throw new Error('expected pty output'); - assert.deepEqual([terminal.output.cols, terminal.output.rows], [80, 24]); + const settled = await waitForTerminalShellRun(manager, initial.ref, 15_000); + assertShellRunSnapshot(settled); + assert.equal(settled.status, 'completed'); + assert.equal(settled.exitCode, 0); + assert.equal(settled.output.mode, 'pty'); + if (settled.output.mode !== 'pty') throw new Error('expected pty output'); + assert.deepEqual([settled.output.cols, settled.output.rows], [80, 24]); const durable = await store.readShellRun('session-1', 'shell-run-1'); assert.equal(durable.status, 'completed'); - assert.equal(durable.revision, terminal.revision); + assert.equal(durable.revision, settled.revision); assert.equal(manager.liveCount(), 0); } finally { Terminal.prototype.write = originalWrite; @@ -2120,39 +2117,28 @@ describe('ShellRunProcessManager', () => { } }); - test('writeStdin itself returns terminal status when persist is held through finalization', async () => { + test('a PTY exit during control persistence is reflected in the reply', async () => { const cwd = await workspace(); - const exitGate = join(cwd, 'exit-gate'); + const exitGate = join(cwd, 'allow-exit'); const backingStore = sqliteShellRunStore(await workspace()); - const persistHeld = deferred(); - const releasePersist = deferred(); - let holdNextObservation = false; - let persistIsHeld = false; + const resumePersist = deferred(); + let pauseControlPersist = false; + let controlPersistPaused = false; const store: ShellRunStore = { createShellRun: (...args) => backingStore.createShellRun(...args), async updateShellRun(sessionId, shellRunId, patch) { - if (holdNextObservation && patch.status === undefined) { - holdNextObservation = false; - persistIsHeld = true; - persistHeld.resolve(); - await releasePersist.promise; + if (pauseControlPersist && patch.status === undefined) { + pauseControlPersist = false; + controlPersistPaused = true; + await resumePersist.promise; } return backingStore.updateShellRun(sessionId, shellRunId, patch); }, readShellRun: (...args) => backingStore.readShellRun(...args), listSessionShellRuns: (...args) => backingStore.listSessionShellRuns(...args), }; - const flushes = manualFlushScheduler(); - const manager = createManager(store, undefined, { - flushIntervalMs: 60_000, - scheduleFlush: flushes.schedule, - }); - const originalDispose = PtyProcessDriver.prototype.dispose; - let driverDisposed = false; - PtyProcessDriver.prototype.dispose = function dispose(this: PtyProcessDriver) { - driverDisposed = true; - return originalDispose.call(this); - }; + const manager = createManager(store); + const liveRuns = (manager as unknown as { live: Map }).live; let ref: string | undefined; try { @@ -2160,19 +2146,14 @@ describe('ShellRunProcessManager', () => { shellInput({ cwd, command: nodeCommand(` - const { readFileSync } = require('node:fs'); + const { existsSync } = require('node:fs'); process.stdout.write('READY\\n'); - const wait = () => { - try { - readFileSync(${JSON.stringify(exitGate)}); - } catch (error) { - if (error.code !== 'ENOENT') throw error; - setImmediate(wait); - return; + const timer = setInterval(() => { + if (existsSync(${JSON.stringify(exitGate)})) { + clearInterval(timer); + process.exit(0); } - process.exit(0); - }; - wait(); + }, 10); `), pty: true, timeoutMs: 120_000, @@ -2182,18 +2163,20 @@ describe('ShellRunProcessManager', () => { ref = initial.ref; await waitForPtyText(manager, initial.ref, /READY/); - holdNextObservation = true; + pauseControlPersist = true; const pending = manager.writeStdin({ sessionId: 'session-1', ref: initial.ref, size: { cols: 81, rows: 25 }, abortSignal: NO_ABORT, }); - await waitUntil(() => persistIsHeld, 15_000); - await persistHeld.promise; + await waitUntil(() => controlPersistPaused, 15_000); await writeFile(exitGate, 'exit'); - await waitUntil(() => driverDisposed, 15_000); - releasePersist.resolve(); + await waitUntil( + () => [...liveRuns.values()].some((live) => live.driverExit !== undefined), + 15_000, + ); + resumePersist.resolve(); const control = await pending; assertShellRunSnapshot(control); @@ -2207,10 +2190,11 @@ describe('ShellRunProcessManager', () => { assert.equal(control.output.mode, 'pty'); if (control.output.mode !== 'pty') throw new Error('expected pty output'); assert.deepEqual([control.output.cols, control.output.rows], [81, 25]); + const durable = await backingStore.readShellRun('session-1', 'shell-run-1'); + assert.equal(durable.revision, control.revision); assert.equal(manager.liveCount(), 0); } finally { - PtyProcessDriver.prototype.dispose = originalDispose; - releasePersist.resolve(); + resumePersist.resolve(); if (ref && manager.liveCount() > 0) { await manager.stopBackgroundTask('session-1', ref, NO_ABORT).catch(() => undefined); } diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index 017221fcc78..09a9a769b79 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -69,7 +69,7 @@ import type { NormalizedUsage, ToolCallPart, } from './model-protocol.js'; -import { providerRetryReason } from './provider-error-classification.js'; +import { providerRetryReason } from './provider-retry-policy.js'; import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv'; import Ajv2019 from 'ajv/dist/2019.js'; import Ajv2020 from 'ajv/dist/2020.js'; diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 310490ccaec..aa1cf200f54 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -21,8 +21,12 @@ import { RetryError } from 'ai'; import { MODEL_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/model-failure'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { isAuthenticationErrorText } from '@maka/core/redaction'; -import type { ProviderRetryReason } from '@maka/core/events'; import type { ModelFailure, ModelFailureKind } from './model-protocol.js'; +import { providerRetryDecision, responseHeadersFromError } from './provider-retry-policy.js'; +import { + OPENAI_RESPONSES_TRANSPORT_CODES, + PROVIDER_BILLING_PROVIDER_CODES, +} from './provider-error-signals.js'; /** * Structured provider error identifiers that mean the INPUT exceeded the @@ -73,21 +77,6 @@ const RESPONSE_TRANSPORT_FAILURE_CODES: ReadonlySet = new Set([ */ const PROVIDER_CAPACITY_CODES: ReadonlySet = new Set(['resource-exhausted']); -/** - * Structured provider error identifiers that mean an ACCOUNT-level usage or - * billing condition — exhausted credits or a closed plan/quota window — - * rather than an invalid credential. Providers disagree on which HTTP status - * travels with them (402, 401/403, even 429); the structured code is the - * stable evidence, so it outranks every numeric fallback below. - */ -const PROVIDER_BILLING_PROVIDER_CODES: ReadonlySet = new Set([ - 'insufficient_quota', // OpenAI & OpenAI-compatible: error.code - 'insufficient_balance', // DeepSeek: error.code - 'quota_exceeded', // OpenAI-compatible variants: error.code - 'freeusagelimiterror', // OpenCode Zen free tier exhausted (HTTP 429): error.type - 'upgrade_required', // Command Code: the plan has no Provider API access (HTTP 403): error.code -]); - /** * Free-text usage/billing wording that overrides a credential-shaped HTTP * status (401/403): some providers report exhausted plan windows, credits, @@ -154,34 +143,6 @@ interface ProviderFailureSummary { const PROVIDER_FAILURE_FIELD_MAX_BYTES = 256; -const MAX_SAFE_TIMER_DELAY_MS = 2_147_483_647; - -/** Codes the incremental Responses transport raises before any HTTP response. */ -const OPENAI_RESPONSES_TRANSPORT_CODES: ReadonlySet = new Set([ - 'OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR', - 'OPENAI_RESPONSES_CONTINUATION_UNAVAILABLE', -]); - -/** Retryability follows the kind alone; never re-derive it from a status or header. */ -const MODEL_FAILURE_RETRY: Record = { - abort: null, - auth: null, - context_overflow: null, - network: 'network', - provider_billing: null, - provider_capacity: 'provider_capacity', - provider_unavailable: 'provider_unavailable', - rate_limit: 'rate_limit', - request_rejected: null, - stream_truncated: 'stream_truncated', - timeout: 'timeout', - unknown: null, -}; - -export function providerRetryReason(kind: ModelFailureKind): ProviderRetryReason | null { - return MODEL_FAILURE_RETRY[kind]; -} - function providerErrorTarget(error: unknown): unknown { return RetryError.isInstance(error) && error.lastError !== undefined && error.lastError !== error ? error.lastError @@ -239,35 +200,6 @@ function isTransportFailure(target: unknown, statusCode: string): boolean { return false; } -function responseHeadersFromError(error: unknown): Record | undefined { - if (typeof error !== 'object' || error === null) return undefined; - const value = (error as { responseHeaders?: unknown }).responseHeaders; - if (typeof value !== 'object' || value === null) return undefined; - const headers: Record = {}; - for (const [key, header] of Object.entries(value)) { - if (typeof header === 'string') headers[key.toLowerCase()] = header; - } - return headers; -} - -function parseRetryAfterMs(headers: Record): number | undefined { - const rawMilliseconds = headers['retry-after-ms']; - const rawRetryAfter = headers['retry-after']; - if (rawMilliseconds === undefined && rawRetryAfter === undefined) return undefined; - - let delayMs: number; - if (rawMilliseconds !== undefined) { - delayMs = Number(rawMilliseconds); - } else { - const seconds = Number(rawRetryAfter); - delayMs = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(rawRetryAfter!) - Date.now(); - } - if (!Number.isFinite(delayMs) || delayMs <= 0 || delayMs > MAX_SAFE_TIMER_DELAY_MS) { - return undefined; - } - return Math.ceil(delayMs); -} - function retryMetadataFromFacts( facts: ProviderErrorFacts, errorClass = classifyProviderFacts(facts), @@ -276,9 +208,11 @@ function retryMetadataFromFacts( // The Codex transport already spent its complete 2/10/30-second budget. // Do not let the outer model loop restart that same transport budget. if (isTrustedCodexEdgeRejection(facts)) return { retryable: false }; - if (MODEL_FAILURE_RETRY[errorClass] === null) return { retryable: false }; - const retryAfterMs = parseRetryAfterMs(facts.responseHeaders ?? {}); - return { retryable: true, ...(retryAfterMs === undefined ? {} : { retryAfterMs }) }; + const decision = providerRetryDecision(errorClass, facts.responseHeaders ?? {}); + return { + retryable: decision.reason !== null, + ...(decision.retryAfterMs === undefined ? {} : { retryAfterMs: decision.retryAfterMs }), + }; } /** Collects `code`/`type` strings from a payload and from its `error` wrapper. */ diff --git a/packages/runtime/src/provider-error-signals.ts b/packages/runtime/src/provider-error-signals.ts new file mode 100644 index 00000000000..4827207fb03 --- /dev/null +++ b/packages/runtime/src/provider-error-signals.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +/** Provider codes that identify an account-level usage or billing condition. */ +export const PROVIDER_BILLING_PROVIDER_CODES: ReadonlySet = new Set([ + 'insufficient_quota', + 'insufficient_balance', + 'quota_exceeded', + 'freeusagelimiterror', + 'upgrade_required', +]); + +/** Codes emitted by the incremental Responses transport before an HTTP response. */ +export const OPENAI_RESPONSES_TRANSPORT_CODES: ReadonlySet = new Set([ + 'OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR', + 'OPENAI_RESPONSES_CONTINUATION_UNAVAILABLE', +]); diff --git a/packages/runtime/src/provider-retry-policy.ts b/packages/runtime/src/provider-retry-policy.ts new file mode 100644 index 00000000000..e891e0c1ca7 --- /dev/null +++ b/packages/runtime/src/provider-retry-policy.ts @@ -0,0 +1,91 @@ +/* + * 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 type { ProviderRetryReason } from '@maka/core/events'; +import type { ModelFailureKind } from './model-protocol.js'; + +const MAX_TIMER_DELAY_MS = 2_147_483_647; + +const RETRY_REASON: Record = { + abort: null, + auth: null, + context_overflow: null, + network: 'network', + provider_billing: null, + provider_capacity: 'provider_capacity', + provider_unavailable: 'provider_unavailable', + rate_limit: 'rate_limit', + request_rejected: null, + stream_truncated: 'stream_truncated', + timeout: 'timeout', + unknown: null, +}; + +export interface ProviderRetryDecision { + readonly reason: ProviderRetryReason | null; + readonly retryAfterMs?: number; +} + +/** One pure policy decision consumed by both Runtime retrying and Host projection. */ +export function providerRetryDecision( + kind: ModelFailureKind, + headers: Readonly> = {}, +): ProviderRetryDecision { + const reason = RETRY_REASON[kind]; + if (reason === null) return { reason }; + const delay = retryAfterMs(headers); + return delay === undefined ? { reason } : { reason, retryAfterMs: delay }; +} + +export function providerRetryReason(kind: ModelFailureKind): ProviderRetryReason | null { + return providerRetryDecision(kind).reason; +} + +export function responseHeadersFromError(error: unknown): Record | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const value = (error as { responseHeaders?: unknown }).responseHeaders; + if (typeof value !== 'object' || value === null) return undefined; + const headers: Record = {}; + if (value instanceof Headers) { + value.forEach((header, key) => { + headers[key.toLowerCase()] = header; + }); + return headers; + } + for (const [key, header] of Object.entries(value)) { + if (typeof header === 'string') headers[key.toLowerCase()] = header; + } + return headers; +} + +export function retryAfterMs(headers: Readonly>): number | undefined { + const bounded = (delayMs: number): number | undefined => + Number.isFinite(delayMs) && delayMs > 0 && delayMs <= MAX_TIMER_DELAY_MS + ? Math.ceil(delayMs) + : undefined; + const milliseconds = headers['retry-after-ms']; + if (milliseconds !== undefined) { + const parsed = bounded(Number(milliseconds)); + if (parsed !== undefined) return parsed; + } + const retryAfter = headers['retry-after']; + if (retryAfter === undefined) return undefined; + const seconds = Number(retryAfter); + return bounded(Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(retryAfter) - Date.now()); +} diff --git a/packages/runtime/src/shell-run-manager.ts b/packages/runtime/src/shell-run-manager.ts index 2826b70089f..3128992b9e6 100644 --- a/packages/runtime/src/shell-run-manager.ts +++ b/packages/runtime/src/shell-run-manager.ts @@ -510,16 +510,21 @@ export class ShellRunProcessManager }), ); } - // A real PTY can exit while that persist is still in flight, leaving a - // running snapshot here even though finalizeOnce has already started. - if (live.driverExit || live.finalizeOnce) { - record = await this.markObserved(await live.finished.join()); - return shellRunContent(record, operation); - } - if (isTerminalShellRunStatus(record.status)) record = await this.markObserved(record); + // The snapshot was captured before persistence. Exit can begin during that + // await, so every control response goes through the same final-record + // reconciliation rather than returning a stale running snapshot. + record = await this.reconcilePtyControlRecord(live, record); return clientControl ? compactShellRunContent(record) : shellRunContent(record, operation); } + private async reconcilePtyControlRecord( + live: LiveShellRun, + snapshot: ShellRunRecord, + ): Promise { + const record = live.driverExit || live.finalizeOnce ? await live.finished.join() : snapshot; + return isTerminalShellRunStatus(record.status) ? this.markObserved(record) : record; + } + async readRuntimeResource( sessionId: string, ref: string, diff --git a/packages/storage/src/__tests__/project-catalog.test.ts b/packages/storage/src/__tests__/project-catalog.test.ts index afb03856938..a610d18047e 100644 --- a/packages/storage/src/__tests__/project-catalog.test.ts +++ b/packages/storage/src/__tests__/project-catalog.test.ts @@ -83,38 +83,58 @@ function sessionInput(cwd: string, projectId: string) { }; } -test('a regular file is rejected as a project location', async () => { - const base = await mkdtemp(join(tmpdir(), 'maka-project-file-')); - try { - const outsideFile = join(base, 'README.md'); - await writeFile(outsideFile, 'not a project\n'); - await assert.rejects( - () => resolveProjectLocation({ path: outsideFile }), - (error) => - error instanceof TypeError && - String(error.message).includes('Project path is not a directory'), - ); +function createNumberedCatalog(storageRoot: string): ProjectCatalog { + let nextId = 0; + return createProjectCatalog(storageRoot, { + createId: () => `project-${++nextId}`, + now: () => 1_000, + }); +} - const repository = join(base, 'repository'); - await mkdir(repository); +async function withNestedRepository( + label: string, + run: (layout: { + base: string; + nested: readonly [string, string]; + outside: readonly [string, string]; + repository: string; + }) => Promise, +): Promise { + const base = await mkdtemp(join(tmpdir(), `${label}-`)); + const repository = join(base, 'repository'); + const nested = [join(repository, 'nested-a'), join(repository, 'nested-b')] as const; + const outside = [join(base, 'outside-a'), join(base, 'outside-b')] as const; + try { + await Promise.all([...nested, ...outside].map((path) => mkdir(path, { recursive: true }))); await execFileAsync('git', ['init', '--quiet'], { cwd: repository }); - const insideFile = join(repository, 'README.md'); - await writeFile(insideFile, 'not a project\n'); - await assert.rejects( - () => resolveProjectLocation({ path: insideFile }), - (error) => - error instanceof TypeError && - String(error.message).includes('Project path is not a directory'), - ); + await run({ base, nested, outside, repository }); + } finally { + await rm(base, { recursive: true, force: true }); + } +} - const catalog = createProjectCatalog(join(base, 'storage')); - await assert.rejects( - () => catalog.register(insideFile), - (error) => - error instanceof TypeError && - String(error.message).includes('Project path is not a directory'), - ); +test('project registration accepts directories but never regular files', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-path-kind-')); + try { + const gitRoot = join(base, 'repo'); + await mkdir(gitRoot); + await execFileAsync('git', ['init', '--quiet'], { cwd: gitRoot }); + const catalog = createProjectCatalog(join(base, 'catalog')); + + for (const parent of [base, gitRoot]) { + const file = join(parent, 'project.txt'); + await writeFile(file, 'file, not a folder'); + const canonicalFile = await realpath(file); + await assert.rejects( + () => resolveProjectLocation({ path: file }), + (error: unknown) => + error instanceof TypeError && + error.message === `Project path is not a directory: ${canonicalFile}`, + ); + await assert.rejects(() => catalog.register(file), TypeError); + } assert.deepEqual(await catalog.list(), []); + assert.equal((await resolveProjectLocation({ path: gitRoot })).kind, 'git'); } finally { await rm(base, { recursive: true, force: true }); } @@ -174,44 +194,52 @@ test('a repository and its linked worktree resolve to one project identity', asy } }); -test('registering a nested folder keeps that folder instead of the enclosing repository', async () => { - const base = await mkdtemp(join(tmpdir(), 'maka-project-nested-folder-')); +test('explicit selection preserves a subfolder while historical paths retain repository identity', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-selection-intent-')); try { - const parent = join(base, 'parent-project'); - const child = join(parent, 'child-project'); - await mkdir(child, { recursive: true }); - await execFileAsync('git', ['init', '--quiet'], { cwd: parent }); - const catalog = createProjectCatalog(join(base, 'storage'), { - now: () => 1_000, - createId: (() => { - let id = 0; - return () => `project-${++id}`; - })(), + const repository = join(base, 'repository'); + const linkedWorktree = join(base, 'linked'); + await createGitRepositoryWithWorktree(repository, linkedWorktree, 'selection-intent-test'); + const child = join(linkedWorktree, 'feature'); + await mkdir(child); + + const selected = await resolveProjectLocation({ path: child, intent: 'selected' }); + const historical = await resolveProjectLocation({ path: child }); + const worktree = await resolveProjectLocation({ path: linkedWorktree, intent: 'selected' }); + const main = await resolveProjectLocation({ path: repository, intent: 'selected' }); + + assert.deepEqual(selected, { + canonicalPath: await realpath(child), + identity: `folder:${await realpath(child)}`, + kind: 'folder', }); - - const parentProject = await catalog.register(parent); - const childProject = await catalog.register(child); - const parentPath = await realpath(parent); - const childPath = await realpath(child); - - assert.notEqual(childProject.id, parentProject.id); - assert.equal(parentProject.preferredPath, parentPath); - assert.equal(childProject.preferredPath, childPath); - assert.equal(childProject.name, 'child-project'); - - // session.create → HostWorkspaceResolver.touch(projectId, preferredPath) - const touched = await catalog.touch(childProject.id, childProject.preferredPath); - assert.equal(touched.id, childProject.id); - assert.equal(touched.preferredPath, childPath); - await assert.rejects( - () => catalog.touch(childProject.id, parentPath), - (error) => error instanceof ProjectPathMismatchError && error.projectId === childProject.id, - ); + assert.equal(historical.kind, 'git'); + assert.equal(historical.identity, worktree.identity); + assert.equal(worktree.identity, main.identity); } finally { await rm(base, { recursive: true, force: true }); } }); +test('a selected repository subdirectory owns its catalog identity and touch path', async () => { + await withNestedRepository('maka-selected-subdirectory', async (layout) => { + const catalog = createNumberedCatalog(join(layout.base, 'storage')); + const repositoryProject = await catalog.register(layout.repository); + const nestedProject = await catalog.register(layout.nested[0]); + const canonicalNested = await realpath(layout.nested[0]); + + assert.notEqual(nestedProject.id, repositoryProject.id); + assert.equal(nestedProject.name, 'nested-a'); + assert.equal(nestedProject.preferredPath, canonicalNested); + assert.equal((await catalog.touch(nestedProject.id, canonicalNested)).id, nestedProject.id); + await assert.rejects( + () => catalog.touch(nestedProject.id, layout.repository), + (error: unknown) => + error instanceof ProjectPathMismatchError && error.projectId === nestedProject.id, + ); + }); +}); + test('registration validates the final canonical path against its boundary', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-project-boundary-')); try { @@ -230,56 +258,33 @@ test('registration validates the final canonical path against its boundary', asy } }); -test('relink and relinkWithSessions keep a nested repository directory', async () => { - const base = await mkdtemp(join(tmpdir(), 'maka-project-nested-relink-')); - const storage = join(base, 'storage'); - const sessions = createSessionStore(storage); - try { - const parent = join(base, 'parent-project'); - const child = join(parent, 'child-project'); - const childTwo = join(parent, 'child-two'); - const elsewhere = join(base, 'elsewhere'); - const elsewhereTwo = join(base, 'elsewhere-two'); - await mkdir(child, { recursive: true }); - await mkdir(childTwo, { recursive: true }); - await mkdir(elsewhere, { recursive: true }); - await mkdir(elsewhereTwo, { recursive: true }); - await execFileAsync('git', ['init', '--quiet'], { cwd: parent }); - const catalog = createProjectCatalog(storage, { - now: () => 1_000, - createId: (() => { - let id = 0; - return () => `project-${++id}`; - })(), - }); - - const parentProject = await catalog.register(parent); - const original = await catalog.register(elsewhere); - const originalSessions = await catalog.register(elsewhereTwo); - const childPath = await realpath(child); - const childTwoPath = await realpath(childTwo); - const assigned = await sessions.create(sessionInput(elsewhereTwo, originalSessions.id)); - - const relinked = await catalog.relink(original.id, child); - assert.equal(relinked.id, original.id); - assert.notEqual(relinked.id, parentProject.id); - assert.equal(relinked.preferredPath, childPath); - - const { project: relinkedSessions, updatedSessionIds } = await catalog.relinkWithSessions( - originalSessions.id, - childTwo, - ); - assert.equal(relinkedSessions.id, originalSessions.id); - assert.notEqual(relinkedSessions.id, parentProject.id); - assert.equal(relinkedSessions.preferredPath, childTwoPath); - assert.deepEqual(updatedSessionIds, [assigned.id]); - const header = await sessions.readHeaderSnapshot(assigned.id); - assert.equal(header.projectId, relinkedSessions.id); - assert.equal(header.cwd, childTwoPath); - } finally { - await sessions.close?.(); - await rm(base, { recursive: true, force: true }); - } +test('nested relinks preserve identity and move assigned sessions atomically', async () => { + await withNestedRepository('maka-nested-relink', async (layout) => { + const storage = join(layout.base, 'storage'); + const catalog = createNumberedCatalog(storage); + const sessions = createSessionStore(storage); + try { + const repositoryProject = await catalog.register(layout.repository); + const [plainProject, sessionProject] = await Promise.all( + layout.outside.map((path) => catalog.register(path)), + ); + const assigned = await sessions.create(sessionInput(layout.outside[1], sessionProject.id)); + + const plainRelink = await catalog.relink(plainProject.id, layout.nested[0]); + const sessionRelink = await catalog.relinkWithSessions(sessionProject.id, layout.nested[1]); + + assert.equal(plainRelink.id, plainProject.id); + assert.equal(plainRelink.preferredPath, await realpath(layout.nested[0])); + assert.equal(sessionRelink.project.id, sessionProject.id); + assert.notEqual(sessionRelink.project.id, repositoryProject.id); + assert.deepEqual(sessionRelink.updatedSessionIds, [assigned.id]); + const reassigned = await sessions.readHeaderSnapshot(assigned.id); + assert.equal(reassigned.cwd, await realpath(layout.nested[1])); + assert.equal(reassigned.projectId, sessionProject.id); + } finally { + await sessions.close?.(); + } + }); }); async function resolveProjectLocationWithoutGit(path: string): Promise { diff --git a/packages/storage/src/project-catalog.ts b/packages/storage/src/project-catalog.ts index 164fe9d272e..04e0b945afd 100644 --- a/packages/storage/src/project-catalog.ts +++ b/packages/storage/src/project-catalog.ts @@ -205,7 +205,7 @@ class SqliteProjectCatalog implements ProjectCatalog { } async register(path: string, options?: ProjectRegistrationOptions): Promise { - const resolved = await resolveUserSelectedProjectLocation(path); + const resolved = await resolveProjectLocation({ path, intent: 'selected' }); if (options?.withinRoot && !isPathWithin(options.withinRoot, resolved.canonicalPath)) { throw new ProjectPathBoundaryError(resolved.canonicalPath); } @@ -316,24 +316,13 @@ class SqliteProjectCatalog implements ProjectCatalog { } async touch(projectId: string, path?: string): Promise { - let canonicalPath: string | undefined; - if (path) { - try { - canonicalPath = normalize(await realpath(resolve(path))); - } catch { - throw new ProjectUnavailableError(projectId); - } - } + const requestedPath = path ? await canonicalTouchPath(projectId, path) : undefined; const touched = await this.mutate((file) => { const project = findProjectById(file.projects, projectId); if (!project) throw new ProjectNotFoundError(projectId); - const location = canonicalPath - ? project.locations.find((item) => item.path === canonicalPath) - : [...project.locations].sort( - (a, b) => b.lastUsedAt - a.lastUsedAt || a.path.localeCompare(b.path), - )[0]; - if (canonicalPath && !location) { - throw new ProjectPathMismatchError(projectId, canonicalPath); + const location = locationForTouch(project, requestedPath); + if (requestedPath && !location) { + throw new ProjectPathMismatchError(projectId, requestedPath); } const timestamp = this.now(); if (location) location.lastUsedAt = timestamp; @@ -344,7 +333,7 @@ class SqliteProjectCatalog implements ProjectCatalog { } async relink(projectId: string, path: string): Promise { - const resolved = await resolveUserSelectedProjectLocation(path); + const resolved = await resolveProjectLocation({ path, intent: 'selected' }); const timestamp = this.now(); const locationPath = resolved.kind === 'git' ? resolved.git!.worktreeRoot : resolved.canonicalPath; @@ -364,7 +353,7 @@ class SqliteProjectCatalog implements ProjectCatalog { projectId: string, path: string, ): Promise<{ project: ProjectRecord; updatedSessionIds: readonly string[] }> { - const resolved = await resolveUserSelectedProjectLocation(path); + const resolved = await resolveProjectLocation({ path, intent: 'selected' }); const timestamp = this.now(); const locationPath = resolved.kind === 'git' ? resolved.git!.worktreeRoot : resolved.canonicalPath; @@ -846,6 +835,30 @@ async function isDirectory(path: string): Promise { } } +async function canonicalTouchPath(projectId: string, path: string): Promise { + try { + return normalize(await realpath(resolve(path))); + } catch { + throw new ProjectUnavailableError(projectId); + } +} + +function locationForTouch(project: PersistedProject, requestedPath?: string) { + if (requestedPath) { + return project.locations.find((location) => location.path === requestedPath); + } + return project.locations.reduce<(typeof project.locations)[number] | undefined>( + (preferred, location) => { + if (!preferred) return location; + if (location.lastUsedAt !== preferred.lastUsedAt) { + return location.lastUsedAt > preferred.lastUsedAt ? location : preferred; + } + return location.path.localeCompare(preferred.path) < 0 ? location : preferred; + }, + undefined, + ); +} + export interface ResolvedProjectLocation { canonicalPath: string; identity: string; @@ -859,12 +872,13 @@ export interface ResolvedProjectLocation { export async function resolveProjectLocation(input: { path: string; + intent?: 'selected'; }): Promise { const canonicalPath = normalize(await realpath(resolve(input.path))); - // Regular files are not valid projects. Without this check, a file inside a - // Git worktree reaches `git -C rev-parse ...`, whose numeric failure - // is not treated as an invalid path by the Host coordinator and drains it. - if (!(await stat(canonicalPath)).isDirectory()) { + const location = await stat(canonicalPath); + // Reject files before Git discovery: `git -C ` reports a generic + // process failure that callers cannot classify as an invalid project path. + if (!location.isDirectory()) { throw new TypeError(`Project path is not a directory: ${canonicalPath}`); } if (!(await hasEnclosingGitEntry(canonicalPath))) { @@ -875,6 +889,15 @@ export async function resolveProjectLocation(input: { }; } const git = await resolveGitLocation(canonicalPath); + // A chooser selection names this directory exactly. Historical working + // directories may resolve upward to their enclosing worktree instead. + if (input.intent === 'selected' && canonicalPath !== git.worktreeRoot) { + return { + canonicalPath, + identity: `folder:${canonicalPath}`, + kind: 'folder', + }; + } return { canonicalPath, identity: `git:${git.commonDir}`, @@ -883,30 +906,6 @@ export async function resolveProjectLocation(input: { }; } -/** - * A directory the user picked in the add/relink chooser. - * - * `resolveProjectLocation` still walks to the enclosing Git worktree so a - * historical session cwd inside a repository stays on that repository. - * The chooser must not do that: selecting `repo/child` would otherwise - * silently become `repo` and reopen the parent project. - */ -async function resolveUserSelectedProjectLocation(path: string): Promise { - const resolved = await resolveProjectLocation({ path }); - if ( - resolved.kind !== 'git' || - !resolved.git || - resolved.canonicalPath === resolved.git.worktreeRoot - ) { - return resolved; - } - return { - canonicalPath: resolved.canonicalPath, - identity: `folder:${resolved.canonicalPath}`, - kind: 'folder', - }; -} - function isPathWithin(root: string, candidate: string): boolean { const path = relative(normalize(root), normalize(candidate)); return path === '' || (!isAbsolute(path) && path !== '..' && !path.startsWith(`..${sep}`)); diff --git a/packages/ui/src/__tests__/composer-message-queue.test.tsx b/packages/ui/src/__tests__/composer-message-queue.test.tsx index c09679306a1..6d820841dd3 100644 --- a/packages/ui/src/__tests__/composer-message-queue.test.tsx +++ b/packages/ui/src/__tests__/composer-message-queue.test.tsx @@ -24,6 +24,7 @@ import test from 'node:test'; import { act, createElement } from 'react'; import type { MessageQueueEntryProjection } from '@maka/core/events'; import { getConversationCopy } from '../conversation-copy.js'; +import type { ComposerQueueEntry } from '../composer-message-queue.js'; import { installDom } from './mermaid-test-dom.js'; const copy = getConversationCopy('en').composer; @@ -39,11 +40,14 @@ function queued(entryId: string, text: string): MessageQueueEntryProjection { } async function mountQueue(props: { - queuedMessages: readonly MessageQueueEntryProjection[]; + queuedMessages: readonly ComposerQueueEntry[]; + queueRevision?: number; onEditEntry?(entry: Pick): void | Promise; onDeleteEntry?(entryId: string): void | Promise; onPromoteEntry?(entryId: string): void | Promise; - onReorderEntries?(entryIds: readonly string[]): void | Promise; + onReorderEntries?( + entryIds: readonly string[], expectedQueueRevision: number, + ): void | Promise; }) { const dom = installDom(); const { createRoot } = await import('react-dom/client'); @@ -147,15 +151,45 @@ test('queue actions stay disabled until the entry is Host-admitted', async () => } }); +test('a local pending row exposes only renderer-owned delivery actions', async () => { + const local: ComposerQueueEntry = { + ...queued('local', 'pending follow-up'), + state: 'local', + localMessage: { + id: 'msg-local', + text: 'pending follow-up', + ts: 1, + transientPlacement: 'follow_up', + }, + }; + const view = await mountQueue({ + queuedMessages: [local], + onEditEntry: () => assert.fail('local rows are not Host-editable'), + onDeleteEntry: () => assert.fail('local rows are not Host-deletable'), + onPromoteEntry: () => assert.fail('local rows are not Host-promotable'), + }); + try { + const labels = [...view.document.querySelectorAll('button')].map( + (button) => button.getAttribute('aria-label') ?? button.textContent, + ); + assert.equal(labels.includes(copy.editQueuedEntry), false); + assert.equal(labels.includes(copy.deleteQueuedEntry), false); + assert.equal(labels.includes(copy.promoteQueuedEntry), false); + } finally { + await view.close(); + } +}); + test('dragging reorders the Host-owned id list', async () => { - const reordered: string[][] = []; + const reordered: Array<{ ids: readonly string[]; revision: number }> = []; const view = await mountQueue({ queuedMessages: [ queued('entry-1', 'first follow-up'), queued('entry-2', 'second follow-up'), queued('entry-3', 'retract this follow-up'), ], - onReorderEntries: (ids) => { reordered.push([...ids]); }, + queueRevision: 1, + onReorderEntries: (ids, revision) => { reordered.push({ ids: [...ids], revision }); }, }); try { const grips = view.document.querySelectorAll('[draggable="true"]'); @@ -167,8 +201,106 @@ test('dragging reorders the Host-owned id list', async () => { })); target.dispatchEvent(new window.Event('drop', { bubbles: true })); }); - assert.deepEqual(reordered, [['entry-2', 'entry-1', 'entry-3']]); + assert.deepEqual(reordered, [{ ids: ['entry-2', 'entry-1', 'entry-3'], revision: 1 }]); + } finally { + await view.close(); + } +}); + +test('queued entries cannot start a reorder without a Host queue revision', async () => { + const view = await mountQueue({ + queuedMessages: [queued('entry-1', 'first'), queued('entry-2', 'second')], + onReorderEntries: () => assert.fail('reorder must remain unavailable'), + }); + try { + assert.equal(view.document.querySelectorAll('[draggable="true"]').length, 0); + assert.equal(view.document.querySelectorAll('[data-maka-queue-drop-target="true"]').length, 0); + } finally { + await view.close(); + } +}); + +test('a drag submits its captured revision so the Host can reject stale order', async () => { + const reordered: Array<{ ids: string[]; revision: number }> = []; + const entries = [queued('entry-1', 'first'), queued('entry-2', 'second')]; + const onReorderEntries = (ids: readonly string[], revision: number) => { + reordered.push({ ids: [...ids], revision }); + }; + const view = await mountQueue({ queuedMessages: entries, queueRevision: 1, onReorderEntries }); + try { + const source = view.document.querySelectorAll('[draggable="true"]')[1]!; + await act(async () => { + source.dispatchEvent(Object.assign(new window.Event('dragstart', { bubbles: true }), { + dataTransfer: { effectAllowed: '', setData: () => {} }, + })); + }); + await view.rerender({ queuedMessages: entries, queueRevision: 2, onReorderEntries }); + await act(async () => { + view.document.querySelectorAll('[data-maka-queue-drop-target="true"]')[0]! + .dispatchEvent(new window.Event('drop', { bubbles: true })); + }); + assert.deepEqual(reordered, [{ ids: ['entry-2', 'entry-1'], revision: 1 }]); + } finally { + await view.close(); + } +}); + +test('promote and delete dispatch only for Host-owned queued entries', async () => { + const promoted: string[] = []; + const deleted: string[] = []; + const view = await mountQueue({ + queuedMessages: [ + queued('entry-1', 'first'), + { ...queued('entry-2', 'second'), state: 'in_flight' }, + ], + queueRevision: 4, + onPromoteEntry: (entryId) => { promoted.push(entryId); }, + onDeleteEntry: (entryId) => { deleted.push(entryId); }, + }); + try { + assert.equal(actionButton(view.document, copy.promoteQueuedEntry, 1).disabled, true); + assert.equal( + actionButton(view.document, copy.deleteQueuedEntry, 1).getAttribute('aria-disabled'), + 'true', + ); + await click(actionButton(view.document, copy.promoteQueuedEntry, 0)); + await click(actionButton(view.document, copy.deleteQueuedEntry, 0)); + assert.deepEqual(promoted, ['entry-1']); + assert.deepEqual(deleted, ['entry-1']); + } finally { + await view.close(); + } +}); + +test('one render cannot dispatch two mutations for the same pending queue', async () => { + let settlePromotion!: () => void; + const promotion = new Promise((resolve) => { + settlePromotion = resolve; + }); + const calls: string[] = []; + const view = await mountQueue({ + queuedMessages: [queued('entry-1', 'first')], + queueRevision: 4, + onPromoteEntry: () => { + calls.push('promote'); + return promotion; + }, + onDeleteEntry: () => { + calls.push('delete'); + }, + }); + try { + const promote = actionButton(view.document, copy.promoteQueuedEntry); + const remove = actionButton(view.document, copy.deleteQueuedEntry); + await act(async () => { + promote.dispatchEvent(new window.Event('click', { bubbles: true })); + remove.dispatchEvent(new window.Event('click', { bubbles: true })); + }); + assert.deepEqual(calls, ['promote']); + settlePromotion(); + await act(async () => promotion); } finally { + settlePromotion(); await view.close(); } }); diff --git a/packages/ui/src/__tests__/composer-send-toggle.test.tsx b/packages/ui/src/__tests__/composer-send-toggle.test.tsx index 0e3047a4145..3e7185da69c 100644 --- a/packages/ui/src/__tests__/composer-send-toggle.test.tsx +++ b/packages/ui/src/__tests__/composer-send-toggle.test.tsx @@ -17,24 +17,17 @@ * under the License. */ -/** - * The composer's send slot holds ONE control (Astryx's send/stop toggle), and - * mid-turn it reads Stop while the draft is empty. This is the shape the slot - * drifted out of more than once — a Stop button and a Steer button side by - * side, then a Queue/Steer mode switch beside Send — so the count is asserted, - * not just the label. Queue affordances live in the pending plate above the - * card, never in the send slot. - */ +/** The send slot is a single Send/Stop control; queue actions live above it. */ import assert from 'node:assert/strict'; import test from 'node:test'; -import { act, createRef } from 'react'; -import { createRoot } from 'react-dom/client'; +import { act } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { parseHTML } from 'linkedom'; -import { Composer, type ComposerHandle } from '../composer.js'; +import { Composer } from '../composer.js'; +import { deriveComposerSendPolicy, hasComposerStagedContext } from '../composer-send-policy.js'; import { LocaleProvider } from '../locale-context.js'; -import { ICON_SIZE, Search } from '../icons.js'; +import { mountComposer } from './composer-test-harness.js'; function renderComposer(streaming: boolean): string { return renderToStaticMarkup( @@ -45,7 +38,10 @@ function renderComposer(streaming: boolean): string { } function sendSlotControls(markup: string): string[] { - return markup.match(/aria-label="(?:Send|Stop)"/g) ?? []; + const document = parseHTML(`${markup}`).document; + return [...document.querySelectorAll('button[aria-label="Send"], button[aria-label="Stop"]')] + .map((button) => button.getAttribute('aria-label')) + .filter((label): label is string => label !== null); } /** The Send control's own `aria-disabled` value — asserted directly, not by a @@ -57,9 +53,25 @@ function sendButtonAriaDisabled(markup: string): string | null { return button.getAttribute('aria-disabled'); } +test('the send policy treats staged context as sendable content across all gates', () => { + const staged = hasComposerStagedContext({ pendingQuotes: [{}] }); + assert.deepEqual( + deriveComposerSendPolicy({ + text: '', + hasStagedContext: staged, + executorModelPending: false, + sendPending: false, + importActionBusy: false, + noModelConnection: false, + streaming: true, + }), + { hasSendableContent: true, sendDisabled: false, stopShown: false }, + ); +}); + test('an idle composer offers Send alone', () => { const controls = sendSlotControls(renderComposer(false)); - assert.deepEqual(controls, ['aria-label="Send"']); + assert.deepEqual(controls, ['Send']); }); test('a host-owned send gate disables Send without an inline notice', () => { @@ -79,12 +91,12 @@ test('a host-owned send gate disables Send without an inline notice', () => { test('a turn in flight turns the same single control into Stop', () => { const controls = sendSlotControls(renderComposer(true)); - assert.deepEqual(controls, ['aria-label="Stop"']); + assert.deepEqual(controls, ['Stop']); }); -test('a running composer keeps Send alone — no mode switch in the send slot', () => { +test('a running composer adds no queue mode switch beside Stop', () => { const markup = renderComposer(true); - assert.deepEqual(sendSlotControls(markup), ['aria-label="Stop"']); + assert.deepEqual(sendSlotControls(markup), ['Stop']); assert.doesNotMatch(markup, /Follow-up behavior/); assert.doesNotMatch(markup, /SegmentedControl/); }); @@ -132,7 +144,7 @@ test('a staged quote enables Send without any host opt-in (#4804)', () => { ); // The toggle and the disabled state agree: a quote-only draft is a live // Send, and it needs no per-host decision the way attachments do. - assert.deepEqual(sendSlotControls(staged), ['aria-label="Send"']); + assert.deepEqual(sendSlotControls(staged), ['Send']); assert.equal(sendButtonAriaDisabled(staged), null); // The same empty draft with nothing staged is what disabled looks like, so // the assertion above pins the staged quote as the enabling reason. @@ -140,272 +152,99 @@ test('a staged quote enables Send without any host opt-in (#4804)', () => { }); test('the three send gates agree about a staged quote while streaming (#4804)', async () => { - const original = { - document: globalThis.document, - window: globalThis.window, - IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - }).IS_REACT_ACT_ENVIRONMENT, - }; - const { document, window } = parseHTML('
'); - window.getComputedStyle = () => ({ - direction: 'ltr', - writingMode: 'horizontal-tb', - getPropertyValue: () => '', - }) as unknown as CSSStyleDeclaration; - Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); - const container = document.querySelector('#root'); - assert.ok(container); - const root = createRoot(container); const sends: string[] = []; + const harness = await mountComposer({ + streaming: true, + pendingQuotes: [{ text: 'the excerpt', sourceTurnId: 'turn-9' }], + onSend(text) { + sends.push(text); + }, + onStop() {}, + }); try { - await act(() => root.render( - - { - sends.push(text); - }} - onStop={() => undefined} - /> - , - )); // Gate 1 — the send/stop toggle: mid-turn the slot stays on Send because // the staged quote is handable content, not an empty draft. - assert.deepEqual(sendSlotControls(container.innerHTML), ['aria-label="Send"']); - const button = container.querySelector('button[aria-label="Send"]'); + assert.deepEqual(sendSlotControls(harness.container.innerHTML), ['Send']); + const button = harness.container.querySelector('button[aria-label="Send"]'); assert.ok(button); // Gate 2 — sendDisabled: the control is live. assert.equal(button.getAttribute('aria-disabled'), null); // Gate 3 — sendCurrent's content guard: submitting hands the empty draft // text over, the quote travelling as the message's structured content. // The control is type="submit", so its activation is the form's submit. - const form = container.querySelector('form'); - assert.ok(form); - await act(async () => { - form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); - await Promise.resolve(); - }); + await harness.submit(); assert.deepEqual(sends, ['']); } finally { - await act(() => root.unmount()); - Object.assign(globalThis, original); + await harness.unmount(); } }); test('the actual submit waits for Session references and keeps the draft on refusal', async () => { - const original = { document: globalThis.document, window: globalThis.window, - IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT }; - const { document, window } = parseHTML('
'); - window.getComputedStyle = () => ({ direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '' }) as unknown as CSSStyleDeclaration; - Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); - const container = document.querySelector('#root')!; - const root = createRoot(container); const sends: string[] = []; let release!: (ready: boolean) => void; + const harness = await mountComposer({ + pendingSessionReferences: [{ id: 'source', name: 'Research' }], + waitForSessionReference: () => new Promise((resolve) => { release = resolve; }), + onSend(text) { + sends.push(text); + }, + onStop() {}, + }); try { - await act(() => root.render( - - new Promise((resolve) => { release = resolve; })} - onSend={(text) => { sends.push(text); }} - onStop={() => undefined} - /> - , - )); - const form = container.querySelector('form')!; for (const ready of [false, true]) { - await act(async () => { - form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); - await Promise.resolve(); - }); + await harness.submit(); assert.deepEqual(sends, [], 'no send may precede snapshot resolution'); await act(async () => { release(ready); await Promise.resolve(); }); assert.deepEqual(sends, ready ? [''] : []); } } finally { - await act(() => root.unmount()); - Object.assign(globalThis, original); + await harness.unmount(); } }); -test('a send completing after navigation keeps the newer draft it left behind', async () => { - const original = { document: globalThis.document, window: globalThis.window, Node: globalThis.Node, HTMLElement: globalThis.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT }; - const { document, window } = parseHTML('
'); - window.getComputedStyle = () => ({ direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '' }) as unknown as CSSStyleDeclaration; - Object.assign(window, { getSelection: () => null }); - Object.assign(document, { getSelection: () => null }); - Object.assign(globalThis, { document, window, Node: window.Node, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); - const root = createRoot(document.querySelector('#root')!); - const handle = createRef(); - let finish!: (sent: boolean) => void; - const render = (draftKey: string) => root.render( - - new Promise((resolve) => { finish = resolve; })} - onStop={() => undefined} /> - , - ); - try { - await act(() => render('a')); - await act(() => handle.current!.setText('first request')); - await act(async () => { - document.querySelector('form')!.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); - await Promise.resolve(); - }); - await act(() => handle.current!.setText('second unsent draft')); - await act(() => render('b')); - await act(async () => { finish(true); await Promise.resolve(); }); - assert.equal(handle.current!.getDraft('a'), 'second unsent draft'); - } finally { - await act(() => root.unmount()); - Object.assign(globalThis, original); +test('deduplicates pending steering against Host queue entries and keeps the plate through an empty queue snapshot', () => { + const pending = { id: 'steer', text: 'new direction', ts: 1, transientPlacement: 'follow_up' as const }; + const queued = { entryId: 'host-entry', messageId: pending.id, placement: 'current_turn' as const, state: 'queued' as const, content: { text: pending.text } }; + for (const entries of [[queued], []]) { + const markup = renderToStaticMarkup( undefined} onStop={() => undefined} + queuedMessages={entries} pendingMessages={[pending]} />); + const document = parseHTML(`${markup}`).document; + assert.equal(document.querySelectorAll('.maka-composer-queue-text').length, 1); + assert.equal(document.querySelector('.maka-composer-queue-text')?.textContent, pending.text); + assert.equal(document.querySelector('.maka-composer-queue-status')?.textContent, 'queued'); } }); -test('keeps Host order visible until the reordered projection arrives', async () => { - const original = { - document: globalThis.document, - window: globalThis.window, - IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - }).IS_REACT_ACT_ENVIRONMENT, - }; - const { document, window } = parseHTML('
'); - window.getComputedStyle = () => ({ - direction: 'ltr', - writingMode: 'horizontal-tb', - getPropertyValue: () => '', - }) as unknown as CSSStyleDeclaration; - Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); - const container = document.querySelector('#root'); - assert.ok(container); - const root = createRoot(container); - let requestedOrder: readonly string[] | undefined; - const editedEntryIds: string[] = []; - const deletedEntryIds: string[] = []; - - try { - await act(() => root.render( - - ({ - entryId, - messageId: `message-${entryId}`, - content: { text: entryId }, - placement: 'next_turn' as const, - state: 'queued' as const, - })), - ]} - onPromoteQueuedEntry={() => undefined} - onEditQueuedEntry={(entry) => { - editedEntryIds.push(entry.entryId); - }} - onDeleteQueuedEntry={(entryId) => { - deletedEntryIds.push(entryId); - }} - onReorderQueuedEntries={(entryIds) => { - requestedOrder = entryIds; - return new Promise(() => undefined); - }} - onSend={() => undefined} - onStop={() => undefined} - /> - , - )); - const editButtons = [ - ...container.querySelectorAll('[aria-label="Edit"]'), - ]; - const deleteButtons = [ - ...container.querySelectorAll('[aria-label="Delete"]'), - ]; - // Queued steering lives in the transcript, not the follow-up plate. - assert.equal(container.querySelectorAll('[aria-label="Send now"]').length, 2); - assert.equal(editButtons.length, 2); - assert.equal(deleteButtons.length, 2); - assert.equal( - [...container.querySelectorAll('.maka-composer-queue-text')] - .every((row) => !row.textContent?.includes('steering')), - true, - ); - await act(async () => { - editButtons[0]?.dispatchEvent(new window.Event('click', { bubbles: true })); - await Promise.resolve(); - }); - await act(async () => { - container - .querySelector('[aria-label="Delete"]') - ?.dispatchEvent(new window.Event('click', { bubbles: true })); - await Promise.resolve(); - }); - assert.deepEqual(editedEntryIds, ['first']); - assert.deepEqual(deletedEntryIds, ['first']); - const grips = [...container.querySelectorAll('.maka-composer-queue-grip')]; - assert.equal(grips.length, 2); - const dragStart = new window.Event('dragstart', { bubbles: true }); - Object.defineProperty(dragStart, 'dataTransfer', { - value: { effectAllowed: '', setData() {} }, - }); - await act(() => grips[1]?.dispatchEvent(dragStart)); - const firstRow = grips[0]?.closest('li')?.parentElement; - assert.ok(firstRow); - const drop = new window.Event('drop', { bubbles: true }); - Object.defineProperty(drop, 'dataTransfer', { value: { types: [], files: [] } }); - await act(() => firstRow.dispatchEvent(drop)); - assert.deepEqual(requestedOrder, ['second', 'first']); - assert.deepEqual( - [...container.querySelectorAll('.maka-composer-queue-text')].map((row) => row.textContent), - ['first', 'second'], - ); - } finally { - await act(() => root.unmount()); - Object.assign(globalThis, original); - } +test('a locally saved follow-up keeps its delivery status and recovery actions in the pending list', () => { + const markup = renderToStaticMarkup( undefined} onStop={() => undefined} + pendingMessages={[{ id: 'local', text: 'offline follow-up', ts: 1, transientPlacement: 'follow_up', + deliveryStatus: 'Delivery uncertain', deliveryDetail: 'Connection interrupted', + deliveryActions: [{ label: 'Check delivery', icon: ); + const document = parseHTML(`${markup}`).document; + assert.equal(document.querySelector('.maka-composer-queue-delivery')?.textContent, 'Delivery uncertain'); + assert.ok(document.querySelector('.maka-composer-queue-actions button[aria-label="Check delivery"]')); }); - -test('local sends share the flat staging list with icon-only delivery actions', () => { - const markup = renderToStaticMarkup( undefined} onStop={() => undefined} - queuedMessages={[{ - entryId: 'admitted', messageId: 'message-admitted', - content: { text: 'admitted follow-up' }, placement: 'next_turn', state: 'queued', - }]} - pendingMessages={[ - { id: 'local', text: 'offline follow-up', ts: 1, transientPlacement: 'follow_up', - deliveryStatus: 'Delivery uncertain', deliveryDetail: 'Connection interrupted', - deliveryActions: [{ label: 'Check delivery', icon: ); +test('Composer forwards the compatibility edit action to Host-owned queue rows', () => { + const queued = { + entryId: 'host-entry', + messageId: 'host-message', + placement: 'next_turn' as const, + state: 'queued' as const, + content: { text: 'editable follow-up' }, + }; + const markup = renderToStaticMarkup( undefined} + onStop={() => undefined} + queuedMessages={[queued]} + onEditQueuedEntry={() => undefined} + />); const document = parseHTML(`${markup}`).document; - assert.deepEqual( - [...document.querySelectorAll('.maka-composer-queue-list li')].map((row) => row.textContent), - ['admitted follow-up', 'offline follow-up', 'still sending'], - 'admitted entries and in-flight sends share one flat list', + const edit = [...document.querySelectorAll('button')].find( + (button) => (button.getAttribute('aria-label') ?? button.textContent) === 'Edit', ); - assert.ok(document.querySelector('.maka-composer-queue-actions button[aria-label="Check delivery"]'), - 'the delivery action stays an accessible labelled icon control'); - const gripRows = [...document.querySelectorAll('li')] - .filter((row) => row.querySelector('.maka-composer-queue-grip') !== null); - assert.equal(gripRows.length, 1, 'only the admitted row carries a drag grip'); - const sendingRow = [...document.querySelectorAll('li')] - .find((row) => row.textContent?.includes('still sending')); - assert.ok(sendingRow); - assert.equal(sendingRow.querySelectorAll('.maka-composer-queue-actions button').length, 0, - 'a local row without delivery actions offers no Host edit/steer/delete operations'); + assert.ok(edit); + assert.equal(edit.disabled, false); + assert.notEqual(edit.getAttribute('aria-disabled'), 'true'); }); diff --git a/packages/ui/src/__tests__/composer-test-harness.tsx b/packages/ui/src/__tests__/composer-test-harness.tsx new file mode 100644 index 00000000000..2bb12f41f15 --- /dev/null +++ b/packages/ui/src/__tests__/composer-test-harness.tsx @@ -0,0 +1,55 @@ +/* + * 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 type { ComponentProps } from "react"; +import { act } from "react"; +import { Composer } from "../composer.js"; +import { LocaleProvider } from "../locale-context.js"; +import { installDom } from "./mermaid-test-dom.js"; + +export async function mountComposer(props: ComponentProps) { + const dom = installDom(); + const { createRoot } = await import("react-dom/client"); + const container = dom.document.querySelector("#root"); + assert.ok(container); + const root = createRoot(container); + + await act(() => root.render( + + + , + )); + + return { + container, + async submit() { + const form = container.querySelector("form"); + assert.ok(form); + await act(async () => { + form.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })); + await Promise.resolve(); + }); + }, + async unmount() { + await act(() => root.unmount()); + dom.restore(); + }, + }; +} diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 66a786f29d4..b0687202ef1 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -74,6 +74,7 @@ import { redactSecrets } from './redact.js'; import { useAttachmentImageSource } from './attachment-image.js'; import { resolvePreviewKind } from './artifact-preview-registry.js'; import { MakaClientSlotOutlet, useMakaClientSlotOccupied } from './client-plugin-slots.js'; +import { ProviderRetryNotice } from './provider-retry-notice.js'; export function LocalizedChatMessage({ accessibleLabel, @@ -1237,17 +1238,11 @@ export function ModelProviderRetryIndicator(props: { retry: LiveProviderRetry }) // is the whole status, the moving text is decoration). const scheduledA11y = retry.phase === 'scheduled'; return ( -