diff --git a/.github/workflows/_build-python-packages.yml b/.github/workflows/_build-python-packages.yml index be65bdc8..1e2aea40 100644 --- a/.github/workflows/_build-python-packages.yml +++ b/.github/workflows/_build-python-packages.yml @@ -77,6 +77,22 @@ jobs: assert (static_dir() / "index.html").is_file() PY + # qwenpaw composes independently released plugins. Keep this before the + # artifact upload so a core release cannot advertise an unavailable extra. + - name: Verify released qwenpaw dependencies + if: inputs.expected_version != '' + run: | + REME_WHEEL="$(pwd)/$(ls dist/reme/reme_ai-[0-9]*.whl)" + python -m venv "${RUNNER_TEMP}/reme-qwenpaw-package-smoke" + "${RUNNER_TEMP}/reme-qwenpaw-package-smoke/bin/python" -m pip install "${REME_WHEEL}[qwenpaw]" + cd "${RUNNER_TEMP}" + "${RUNNER_TEMP}/reme-qwenpaw-package-smoke/bin/python" - <<'PY' + from importlib.metadata import distribution + + assert distribution("reme-auto-fin") + assert distribution("reme-daily-paper") + PY + - name: Upload ReMe distributions if: inputs.upload_artifacts uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index b9144d31..a8fc1435 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -1,5 +1,8 @@ name: Release / Python packages +# reme-ai[qwenpaw] is verified before publication. Publish the independently +# versioned reme-auto-fin and reme-daily-paper requirements first. + on: workflow_dispatch: inputs: diff --git a/reme_studio/app/api.ts b/reme_studio/app/api.ts index 104b1300..82a07997 100644 --- a/reme_studio/app/api.ts +++ b/reme_studio/app/api.ts @@ -7,6 +7,7 @@ import type { StreamChunk, } from "./types"; import { decodeSseEvent } from "./chat-stream"; +import { healthFromResponse } from "./health-status"; import { translate, useLanguageStore, type TranslationKey } from "./i18n"; import { WORKSPACE_FILE_LIMIT, @@ -70,10 +71,7 @@ export async function getReMeStatus(): Promise> { export async function getReMeHealth(): Promise { const response = await callReMe("health_check"); - const health = response.metadata.health; - return health && typeof health === "object" - ? (health as ReMeHealth) - : undefined; + return healthFromResponse(response); } export async function rebuildReMeIndex(): Promise> { diff --git a/reme_studio/app/health-status.ts b/reme_studio/app/health-status.ts new file mode 100644 index 00000000..feebbe1a --- /dev/null +++ b/reme_studio/app/health-status.ts @@ -0,0 +1,42 @@ +import type { ReMeComponentHealth, ReMeHealth, ReMeResponse } from "./types"; + +export interface ComponentMemoryUsage { + human?: string; +} + +export interface HealthComponentEntry { + type: string; + name: string; + component: ReMeComponentHealth; + memory?: string; +} + +export function healthFromResponse( + response: Pick, +): ReMeHealth | undefined { + const health = response.metadata.health; + return health && typeof health === "object" + ? (health as ReMeHealth) + : undefined; +} + +export function isComponentHealthy(component: ReMeComponentHealth): boolean { + return ( + component.is_healthy === true || + (component.is_started === true && component.is_healthy !== false) + ); +} + +export function healthComponentEntries( + health?: ReMeHealth, + memory?: Record>, +): HealthComponentEntry[] { + return Object.entries(health?.components || {}).flatMap(([type, entries]) => + Object.entries(entries).map(([name, component]) => ({ + type, + name, + component, + memory: memory?.[type]?.[name]?.human, + })), + ); +} diff --git a/reme_studio/app/settings-center.tsx b/reme_studio/app/settings-center.tsx index 0336a7c4..96df34be 100644 --- a/reme_studio/app/settings-center.tsx +++ b/reme_studio/app/settings-center.tsx @@ -24,6 +24,7 @@ import { rebuildReMeIndex, REME_API_ENDPOINT, } from "./api"; +import { healthComponentEntries, isComponentHealthy } from "./health-status"; import { useI18n, type TranslationKey } from "./i18n"; import type { AppConfig, @@ -61,13 +62,6 @@ const COMPONENT_ICONS: Record = { keyword_index: , }; -function isComponentHealthy(component: ReMeComponentHealth): boolean { - return ( - component.is_healthy === true || - (component.is_started === true && component.is_healthy !== false) - ); -} - const COMPONENT_LABELS = { embedding_store: "embeddingStore", file_graph: "fileGraph", @@ -256,15 +250,7 @@ export default function SettingsCenter({ ([type, entries]) => Object.entries(entries).map(([name, usage]) => ({ type, name, usage })), ); - const healthComponents = Object.entries(health?.components || {}).flatMap( - ([type, entries]) => - Object.entries(entries).map(([name, component]) => ({ - type, - name, - component, - memory: memory?.components?.[type]?.[name]?.human, - })), - ); + const healthComponents = healthComponentEntries(health, memory?.components); const healthyComponents = healthComponents.filter(({ component }) => isComponentHealthy(component), ).length; diff --git a/reme_studio/tests/api-health.test.mjs b/reme_studio/tests/api-health.test.mjs new file mode 100644 index 00000000..9d928e9d --- /dev/null +++ b/reme_studio/tests/api-health.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { healthFromResponse } from "../app/health-status.ts"; + +test("health metadata is returned from the existing health_check response", () => { + const health = { + version: "0.4.1.8", + healthy: true, + components: { file_store: { default: { is_started: true } } }, + }; + + const result = healthFromResponse({ metadata: { health } }); + + assert.deepEqual(result, health); +}); + +test("invalid health metadata keeps the settings fallback available", () => { + assert.equal(healthFromResponse({ metadata: {} }), undefined); + assert.equal(healthFromResponse({ metadata: { health: null } }), undefined); + assert.equal( + healthFromResponse({ metadata: { health: "unknown" } }), + undefined, + ); +}); diff --git a/reme_studio/tests/health-status.test.mjs b/reme_studio/tests/health-status.test.mjs new file mode 100644 index 00000000..f8183969 --- /dev/null +++ b/reme_studio/tests/health-status.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + healthComponentEntries, + isComponentHealthy, +} from "../app/health-status.ts"; + +test("component health honors explicit failures and started fallbacks", () => { + assert.equal(isComponentHealthy({ is_healthy: true }), true); + assert.equal( + isComponentHealthy({ is_started: true, is_healthy: null }), + true, + ); + assert.equal( + isComponentHealthy({ is_started: true, is_healthy: false }), + false, + ); + assert.equal(isComponentHealthy({ is_started: false }), false); +}); + +test("health components are flattened with matching memory usage", () => { + const components = healthComponentEntries( + { + version: "0.4.1.8", + healthy: true, + components: { + embedding_store: { + default: { is_started: true, dimensions: 1024 }, + }, + file_graph: { + memory: { is_healthy: false, n_nodes: 12 }, + }, + }, + }, + { + embedding_store: { default: { human: "12 MiB" } }, + }, + ); + + assert.deepEqual(components, [ + { + type: "embedding_store", + name: "default", + component: { is_started: true, dimensions: 1024 }, + memory: "12 MiB", + }, + { + type: "file_graph", + name: "memory", + component: { is_healthy: false, n_nodes: 12 }, + memory: undefined, + }, + ]); + assert.deepEqual(healthComponentEntries(), []); +});