diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5a167c64b..688d8f5c1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -232,6 +232,7 @@ Persistence is centered on `pkg/persistence/mgr.py`. - SQLite is the default database; PostgreSQL is supported. - Models live under `pkg/entity/persistence/`. +- Timezone-less SQL `DateTime` columns store UTC-naive values on both backends. Normalize aware values with `pkg/persistence/datetime_utils.py::as_naive_utc` at the bind boundary, including deadlines, leases, event times, and query/retention cutoffs; do not merely strip an offset. Existing naive rows represent UTC. Restore UTC awareness for application comparisons and epoch serialization. Runner ledger/event/transcript stores follow this existing schema contract without a data migration or changes to journal/tenant authorization. - Fresh schemas are created from current metadata, then Alembic migrations run to head. LangBot 4.x does not upgrade 3.x databases. - New schema changes use Alembic under `pkg/persistence/alembic/versions/`; there is no legacy migration chain in 4.x. diff --git a/src/langbot/pkg/agent/runner/event_log_store.py b/src/langbot/pkg/agent/runner/event_log_store.py index 0212dc46e..1dbd8a8b2 100644 --- a/src/langbot/pkg/agent/runner/event_log_store.py +++ b/src/langbot/pkg/agent/runner/event_log_store.py @@ -11,6 +11,8 @@ import sqlalchemy from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from sqlalchemy.orm import sessionmaker +from ...persistence.datetime_utils import as_naive_utc + from ...entity.persistence.event_log import EventLog @@ -106,7 +108,7 @@ class EventLogStore: event = EventLog( event_id=event_id, event_type=event_type, - event_time=event_time, + event_time=as_naive_utc(event_time), source=source, bot_id=bot_id, workspace_id=workspace_id, @@ -123,7 +125,7 @@ class EventLogStore: run_id=run_id, runner_id=runner_id, metadata_json=json.dumps(metadata) if metadata else None, - created_at=_utc_now(), + created_at=as_naive_utc(_utc_now()), ) session.add(event) await session.commit() @@ -279,7 +281,9 @@ class EventLogStore: ) -> int: """Delete EventLog rows created before the supplied timestamp.""" async with self._session_factory() as session: - result = await session.execute(sqlalchemy.delete(EventLog).where(EventLog.created_at < before)) + result = await session.execute( + sqlalchemy.delete(EventLog).where(EventLog.created_at < as_naive_utc(before)) + ) await session.commit() return result.rowcount or 0 diff --git a/src/langbot/pkg/agent/runner/run_ledger_store.py b/src/langbot/pkg/agent/runner/run_ledger_store.py index 0bcd88e11..1cb3d476d 100644 --- a/src/langbot/pkg/agent/runner/run_ledger_store.py +++ b/src/langbot/pkg/agent/runner/run_ledger_store.py @@ -11,6 +11,8 @@ import sqlalchemy from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from sqlalchemy.orm import sessionmaker +from ...persistence.datetime_utils import as_naive_utc + from ...entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime @@ -45,7 +47,7 @@ def _epoch_to_datetime(value: typing.Any) -> datetime.datetime | None: if value is None: return None try: - return datetime.datetime.fromtimestamp(float(value), UTC) + return as_naive_utc(datetime.datetime.fromtimestamp(float(value), UTC)) except (TypeError, ValueError, OSError): return None @@ -138,9 +140,9 @@ class RunLedgerStore: queue_name=queue_name, priority=priority, requested_runtime_id=requested_runtime_id, - created_at=now, - started_at=now if status == 'running' else None, - updated_at=now, + created_at=as_naive_utc(now), + started_at=as_naive_utc(now) if status == 'running' else None, + updated_at=as_naive_utc(now), deadline_at=_epoch_to_datetime(deadline_at), authorization_json=_json_dumps(authorization), metadata_json=_json_dumps(metadata), @@ -172,7 +174,7 @@ class RunLedgerStore: sqlalchemy.and_( AgentRun.status == 'claimed', AgentRun.claim_lease_expires_at.is_not(None), - AgentRun.claim_lease_expires_at <= now, + AgentRun.claim_lease_expires_at <= as_naive_utc(now), ), ), sqlalchemy.or_( @@ -199,10 +201,10 @@ class RunLedgerStore: run.status = 'claimed' run.claimed_by_runtime_id = runtime_id run.claim_token = uuid.uuid4().hex - run.claim_lease_expires_at = lease_expires_at + run.claim_lease_expires_at = as_naive_utc(lease_expires_at) run.dispatch_attempts = (run.dispatch_attempts or 0) + 1 - run.last_claimed_at = now - run.updated_at = now + run.last_claimed_at = as_naive_utc(now) + run.updated_at = as_naive_utc(now) await session.commit() return self._run_to_dict(run, include_claim_token=True) @@ -221,8 +223,8 @@ class RunLedgerStore: if run is None or not _claim_is_active(run, runtime_id=runtime_id, claim_token=claim_token, now=now): return None - run.claim_lease_expires_at = now + datetime.timedelta(seconds=max(int(lease_seconds), 1)) - run.updated_at = now + run.claim_lease_expires_at = as_naive_utc(now + datetime.timedelta(seconds=max(int(lease_seconds), 1))) + run.updated_at = as_naive_utc(now) await session.commit() return self._run_to_dict(run) @@ -248,9 +250,9 @@ class RunLedgerStore: run.claimed_by_runtime_id = None run.claim_token = None run.claim_lease_expires_at = None - run.updated_at = now + run.updated_at = as_naive_utc(now) if status in TERMINAL_STATUSES: - run.finished_at = run.finished_at or now + run.finished_at = run.finished_at or as_naive_utc(now) await session.commit() return self._run_to_dict(run) @@ -264,9 +266,7 @@ class RunLedgerStore: ) -> list[dict[str, typing.Any]]: """Release claimed runs whose claim lease has expired.""" status = _validate_run_status(status) - current_time = now or _utc_now() - if current_time.tzinfo is None: - current_time = current_time.replace(tzinfo=UTC) + current_time = as_naive_utc(now or _utc_now()) limit = min(max(int(limit), 1), 500) async with self._session_factory() as session: @@ -326,7 +326,7 @@ class RunLedgerStore: type=event_type, data_json=_json_dumps(data or {}), usage_json=_json_dumps(usage), - created_at=_utc_now(), + created_at=as_naive_utc(_utc_now()), source=source, metadata_json=_json_dumps(metadata), ) @@ -360,7 +360,7 @@ class RunLedgerStore: type=event_type, data_json=_json_dumps(data or {}), usage_json=None, - created_at=_utc_now(), + created_at=as_naive_utc(_utc_now()), source='host', metadata_json=_json_dumps(metadata or {}), ) @@ -392,9 +392,9 @@ class RunLedgerStore: run.status = status if status_reason is not None: run.status_reason = status_reason - run.updated_at = now + run.updated_at = as_naive_utc(now) if status in TERMINAL_STATUSES: - run.finished_at = run.finished_at or now + run.finished_at = run.finished_at or as_naive_utc(now) run.claimed_by_runtime_id = None run.claim_token = None run.claim_lease_expires_at = None @@ -439,8 +439,8 @@ class RunLedgerStore: run = await self._get_run_row(session, run_id) if run is None: return None - run.cancel_requested_at = now - run.updated_at = now + run.cancel_requested_at = as_naive_utc(now) + run.updated_at = as_naive_utc(now) run.status_reason = status_reason or run.status_reason await session.commit() return self._run_to_dict(run) @@ -469,7 +469,7 @@ class RunLedgerStore: async with self._session_factory() as session: runtime = await self._get_runtime_row(session, runtime_id) if runtime is None: - runtime = AgentRuntime(runtime_id=runtime_id, created_at=now) + runtime = AgentRuntime(runtime_id=runtime_id, created_at=as_naive_utc(now)) session.add(runtime) runtime.status = status @@ -479,9 +479,11 @@ class RunLedgerStore: runtime.capabilities_json = _json_dumps(capabilities or {}) runtime.labels_json = _json_dumps(labels or {}) runtime.metadata_json = _json_dumps(metadata or {}) - runtime.last_heartbeat_at = now - runtime.heartbeat_deadline_at = now + datetime.timedelta(seconds=max(int(heartbeat_deadline_seconds), 1)) - runtime.updated_at = now + runtime.last_heartbeat_at = as_naive_utc(now) + runtime.heartbeat_deadline_at = as_naive_utc( + now + datetime.timedelta(seconds=max(int(heartbeat_deadline_seconds), 1)) + ) + runtime.updated_at = as_naive_utc(now) await session.commit() return self._runtime_to_dict(runtime) @@ -503,9 +505,11 @@ class RunLedgerStore: return None runtime.status = status - runtime.last_heartbeat_at = now - runtime.heartbeat_deadline_at = now + datetime.timedelta(seconds=max(int(heartbeat_deadline_seconds), 1)) - runtime.updated_at = now + runtime.last_heartbeat_at = as_naive_utc(now) + runtime.heartbeat_deadline_at = as_naive_utc( + now + datetime.timedelta(seconds=max(int(heartbeat_deadline_seconds), 1)) + ) + runtime.updated_at = as_naive_utc(now) if capabilities is not None: runtime.capabilities_json = _json_dumps(capabilities) if labels is not None: @@ -588,9 +592,7 @@ class RunLedgerStore: stale_after_seconds: int | float | None = None, ) -> list[dict[str, typing.Any]]: """Mark runtimes stale when their heartbeat deadline has passed.""" - current_time = now or _utc_now() - if current_time.tzinfo is None: - current_time = current_time.replace(tzinfo=UTC) + current_time = as_naive_utc(now or _utc_now()) stale_conditions: list[typing.Any] = [ sqlalchemy.and_( AgentRuntime.heartbeat_deadline_at.is_not(None), diff --git a/src/langbot/pkg/agent/runner/transcript_store.py b/src/langbot/pkg/agent/runner/transcript_store.py index 3bb6ae61b..aec02502b 100644 --- a/src/langbot/pkg/agent/runner/transcript_store.py +++ b/src/langbot/pkg/agent/runner/transcript_store.py @@ -11,6 +11,8 @@ import sqlalchemy from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from sqlalchemy.orm import sessionmaker +from ...persistence.datetime_utils import as_naive_utc + from ...entity.persistence.transcript import Transcript from langbot_plugin.api.entities.builtin.provider import message as provider_message @@ -110,7 +112,7 @@ class TranscriptStore: seq=0, run_id=run_id, runner_id=runner_id, - created_at=_utc_now(), + created_at=as_naive_utc(_utc_now()), metadata_json=json.dumps(metadata) if metadata else None, ) session.add(item) @@ -350,7 +352,9 @@ class TranscriptStore: ) -> int: """Delete Transcript rows created before the supplied timestamp.""" async with self._session_factory() as session: - result = await session.execute(sqlalchemy.delete(Transcript).where(Transcript.created_at < before)) + result = await session.execute( + sqlalchemy.delete(Transcript).where(Transcript.created_at < as_naive_utc(before)) + ) await session.commit() return result.rowcount or 0 diff --git a/src/langbot/pkg/persistence/datetime_utils.py b/src/langbot/pkg/persistence/datetime_utils.py new file mode 100644 index 000000000..9935402cb --- /dev/null +++ b/src/langbot/pkg/persistence/datetime_utils.py @@ -0,0 +1,17 @@ +"""Datetime values bound to LangBot's timezone-less SQL columns.""" + +from __future__ import annotations + +import datetime + + +def as_naive_utc(value: datetime.datetime | None) -> datetime.datetime | None: + """Keep the existing UTC-naive storage contract on SQLite and PostgreSQL. + + Legacy naive values already represent UTC. Aware values must be converted + to UTC *before* dropping tzinfo, including query cutoffs and deadlines. + This is a bind-boundary conversion, not a schema or wire-format change. + """ + if value is None or value.tzinfo is None: + return value + return value.astimezone(datetime.timezone.utc).replace(tzinfo=None) diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 99c072dc8..47b54c20f 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -104,7 +104,7 @@ async def _read_httpx_response_limited( task_context: taskmgr.TaskContext | None = None, ) -> bytes: content_length = response.headers.get('content-length') - declared_size = None + declared_size: int | None = None if content_length is not None: try: declared_size = int(content_length) @@ -1759,6 +1759,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): client, f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{version}', max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES, + task_context=task_context, ) return plugin_package, version @@ -1814,6 +1815,18 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): plugin_name = str(install_info.get('plugin_name') or '') file_bytes: bytes | None + if task_context is not None: + # Reset the per-install counters so re-installing the same plugin + # cannot inherit stale progress metadata from a previous task. + task_context.set_current_action('preparing plugin install') + task_context.metadata.update( + { + 'download_total': 0, + 'download_current': 0, + 'download_speed': 0, + } + ) + if install_source == PluginInstallSource.MARKETPLACE: if task_context is not None: task_context.set_current_action('downloading plugin package') @@ -1860,8 +1873,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): task_context.set_current_action('preparing plugin installation') task_context.metadata['progress_percent'] = 45 + if task_context is not None: + task_context.set_current_action('storing plugin package') artifact_digest = hashlib.sha256(file_bytes).hexdigest() await self._store_artifact_package(execution_context, artifact_digest, file_bytes) + if task_context is not None: + task_context.set_current_action('persisting the installation') try: # Persist and publish the new desired generation under the same # gate used by request-time reconciliation. This closes the small @@ -1901,7 +1918,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): if task_context is not None: operation = task_context.metadata.get('operation') task_context.set_current_action( - 'applying plugin update' if operation == 'upgrade' else 'installing plugin dependencies' + 'applying plugin update' if operation == 'upgrade' else 'installing or starting plugin' ) task_context.metadata['progress_percent'] = 62 await self._apply_desired_state( diff --git a/tests/integration/persistence/test_runner_timestamps_postgres.py b/tests/integration/persistence/test_runner_timestamps_postgres.py new file mode 100644 index 000000000..445d3b3e0 --- /dev/null +++ b/tests/integration/persistence/test_runner_timestamps_postgres.py @@ -0,0 +1,243 @@ +"""Runner timestamp contract against real asyncpg and published schema DDL. + +Run only on a disposable PostgreSQL via TEST_POSTGRES_URL. Each case owns a +unique schema and an unprivileged role; no existing tables or policies change. +""" + +from __future__ import annotations + +import datetime as dt +import importlib +import os +import uuid +from types import SimpleNamespace + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.agent.runner import event_log_store, run_ledger_store, transcript_store +from langbot.pkg.agent.runner.run_journal import AgentRunJournal +from langbot.pkg.entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.event_log import EventLog +from langbot.pkg.entity.persistence.transcript import Transcript + +pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio] + +UTC = dt.timezone.utc +NOW = dt.datetime(2026, 9, 22, 4, 5, 6, 123000, tzinfo=UTC) +TABLES = [AgentRun.__table__, AgentRunEvent.__table__, AgentRuntime.__table__, EventLog.__table__, Transcript.__table__] + + +def _published_schema(connection): + with Operations.context(MigrationContext.configure(connection)): + for revision in ('58846a8d7a81_add_event_log_and_transcript_tables', '8d3a1f2c4b6e_add_agent_run_ledger'): + importlib.import_module(f'langbot.pkg.persistence.alembic.versions.{revision}').upgrade() + + +@pytest.fixture(params=['metadata', 'published_migrations']) +async def pg_engine(request, monkeypatch): + url = os.environ.get('TEST_POSTGRES_URL') + if not url: + pytest.skip('TEST_POSTGRES_URL is required (disposable PostgreSQL)') + namespace = f'runner_ts_{uuid.uuid4().hex}' + admin = create_async_engine(url) + engine = None + try: + async with admin.begin() as conn: + await conn.execute(sa.text(f'CREATE ROLE {namespace} NOLOGIN NOSUPERUSER NOBYPASSRLS')) + await conn.execute(sa.text(f'CREATE SCHEMA {namespace} AUTHORIZATION {namespace}')) + engine = create_async_engine( + url, + connect_args={ + 'server_settings': {'search_path': namespace, 'role': namespace, 'timezone': 'Asia/Shanghai'} + }, + ) + async with engine.begin() as conn: + role = ( + await conn.execute(sa.text('SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user')) + ).one() + assert role == (False, False) + if request.param == 'metadata': + await conn.run_sync(lambda sync: Base.metadata.create_all(sync, tables=TABLES)) + else: + await conn.run_sync(_published_schema) + columns = ( + await conn.execute( + sa.text( + 'SELECT table_name, column_name, data_type FROM information_schema.columns ' + "WHERE table_schema = current_schema() AND data_type LIKE 'timestamp%'" + ) + ) + ).all() + assert columns + assert all(column.data_type == 'timestamp without time zone' for column in columns) + for module in (run_ledger_store, event_log_store, transcript_store): + monkeypatch.setattr(module, '_utc_now', lambda: NOW) + yield engine + finally: + if engine is not None: + await engine.dispose() + async with admin.begin() as conn: + await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS {namespace} CASCADE')) + await conn.execute(sa.text(f'DROP ROLE IF EXISTS {namespace}')) + await admin.dispose() + + +async def _legacy_run(engine, *, status='queued'): + """An existing UTC-naive row must remain readable/updatable without DDL.""" + async with engine.begin() as conn: + await conn.execute( + sa.insert(AgentRun).values( + run_id='legacy', + runner_id='runner', + status=status, + created_at=NOW.replace(tzinfo=None), + updated_at=(NOW - dt.timedelta(minutes=1)).replace(tzinfo=None), + claim_token='old-token' if status == 'claimed' else None, + claimed_by_runtime_id='old-runtime' if status == 'claimed' else None, + claim_lease_expires_at=(NOW - dt.timedelta(seconds=1)).replace(tzinfo=None) + if status == 'claimed' + else None, + ) + ) + + +async def test_run_lifecycle_deadline_events_and_stats(pg_engine): + store = run_ledger_store.RunLedgerStore(pg_engine) + deadline = NOW + dt.timedelta(minutes=5) + journal = AgentRunJournal(SimpleNamespace(persistence_mgr=SimpleNamespace(get_db_engine=lambda: pg_engine))) + created = await journal.create_run( + event=SimpleNamespace( + event_id='event', + conversation_id='conversation', + thread_id=None, + workspace_id='workspace-a', + bot_id=None, + event_type='message.received', + source='debug', + delivery=SimpleNamespace(model_dump=lambda **kwargs: {}), + ), + binding=SimpleNamespace(binding_id='binding', agent_id='agent', processor_id='agent', processor_type='agent'), + descriptor=SimpleNamespace(id='runner'), + context={'run_id': 'debug-run', 'runtime': {'deadline_at': deadline.timestamp()}, 'input': {'text': 'test'}}, + authorization={}, + ) + assert created['created_at'] == int(NOW.timestamp()) + assert created['created_at_ms'] == round(NOW.timestamp() * 1000) + assert created['started_at_ms'] == created['created_at_ms'] + assert created['deadline_at'] == int(deadline.timestamp()) + assert await store.get_run('debug-run') == created + assert ( + await store.create_run( + run_id='debug-run', + event_id=None, + binding_id=None, + runner_id='runner', + ) + ) == created + event = await store.append_event( + run_id='debug-run', sequence=1, event_type='message.completed', data={'text': 'ok'} + ) + assert event['created_at'] == int(NOW.timestamp()) + assert await store.append_event(run_id='debug-run', sequence=1, event_type='duplicate') == event + audit = await store.append_audit_event(run_id='debug-run', event_type='host.cancel_requested') + assert audit['sequence'] == 2 + assert audit['created_at'] == int(NOW.timestamp()) + cancelled = await store.request_cancel(run_id='debug-run') + assert cancelled['cancel_requested_at'] == int(NOW.timestamp()) + finished = await store.finalize_run(run_id='debug-run', status='completed') + assert finished['finished_at_ms'] == round(NOW.timestamp() * 1000) + assert await store.get_run('debug-run') == finished + events, _, _, has_more = await store.page_run_events(run_id='debug-run') + assert [row['sequence'] for row in events] == [1, 2] + assert not has_more + window = {'start_time': int(NOW.timestamp()) - 1, 'end_time': int(NOW.timestamp()) + 1} + assert (await store.get_run_stats(**window))['completed_count'] == 1 + assert (await store.get_runner_stats(**window))[0]['completed_runs'] == 1 + async with pg_engine.connect() as conn: + raw = (await conn.execute(sa.select(AgentRun.created_at, AgentRun.deadline_at))).one() + assert raw == (NOW.replace(tzinfo=None), deadline.replace(tzinfo=None)) + + +async def test_claim_renew_release_existing_naive_row(pg_engine): + await _legacy_run(pg_engine) + store = run_ledger_store.RunLedgerStore(pg_engine) + claimed = await store.claim_next_run(runtime_id='runtime', lease_seconds=30) + assert claimed['last_claimed_at'] == int(NOW.timestamp()) + token = claimed['claim_token'] + assert await store.validate_active_claim(run_id='legacy', runtime_id='runtime', claim_token=token) + assert await store.renew_claim(run_id='legacy', claim_token='wrong') is None + renewed = await store.renew_claim(run_id='legacy', claim_token=token, lease_seconds=90) + assert renewed['claim_lease_expires_at'] == int(NOW.timestamp()) + 90 + released = await store.release_claim(run_id='legacy', claim_token=token, status='cancelled') + assert released['finished_at'] == int(NOW.timestamp()) + assert released['claim_lease_expires_at'] is None + assert await store.get_run('legacy') == released + + +async def test_reclaim_expired_naive_lease(pg_engine): + await _legacy_run(pg_engine, status='claimed') + store = run_ledger_store.RunLedgerStore(pg_engine) + assert not await store.validate_active_claim(run_id='legacy', runtime_id='old-runtime', claim_token='old-token') + assert await store.release_claim(run_id='legacy', claim_token='old-token') is None + claimed = await store.claim_next_run(runtime_id='replacement') + assert claimed['claimed_by_runtime_id'] == 'replacement' + assert claimed['claim_token'] != 'old-token' + + +@pytest.mark.parametrize('offset', [None, 0, 8, -7]) +async def test_expired_claim_cutoff_accepts_naive_and_offset_times(pg_engine, offset): + await _legacy_run(pg_engine, status='claimed') + store = run_ledger_store.RunLedgerStore(pg_engine) + cutoff = NOW.replace(tzinfo=None) if offset is None else NOW.astimezone(dt.timezone(dt.timedelta(hours=offset))) + released = await store.release_expired_claims(now=cutoff, status='timeout') + assert len(released) == 1 + assert released[0]['updated_at'] == int(NOW.timestamp()) + assert released[0]['finished_at'] == int(NOW.timestamp()) + assert not await store.validate_active_claim(run_id='legacy', runtime_id='old-runtime', claim_token='old-token') + + +@pytest.mark.parametrize('offset', [None, 8, -7]) +async def test_runtime_heartbeat_stale_cutoffs_and_stats(pg_engine, offset): + store = run_ledger_store.RunLedgerStore(pg_engine) + registered = await store.register_runtime(runtime_id='runtime', heartbeat_deadline_seconds=30) + assert registered['last_heartbeat_at'] == int(NOW.timestamp()) + heartbeat = await store.heartbeat_runtime(runtime_id='runtime', heartbeat_deadline_seconds=60) + assert heartbeat['heartbeat_deadline_at'] == int(NOW.timestamp()) + 60 + assert (await store.get_runtime_stats())['avg_heartbeat_age_seconds'] == 0 + later = NOW + dt.timedelta(seconds=61) + cutoff = later.replace(tzinfo=None) if offset is None else later.astimezone(dt.timezone(dt.timedelta(hours=offset))) + stale = await store.mark_stale_runtimes(now=cutoff, stale_after_seconds=60) + assert len(stale) == 1 + assert stale[0]['updated_at'] == int(later.timestamp()) + assert (await store.get_runtime('runtime'))['status'] == 'stale' + + +@pytest.mark.parametrize('offset', [None, 8, -7]) +async def test_event_log_timestamp_and_retention(pg_engine, offset): + store = event_log_store.EventLogStore(pg_engine) + event_time = NOW.replace(tzinfo=None) if offset is None else NOW.astimezone(dt.timezone(dt.timedelta(hours=offset))) + await store.append_event(event_id='event', event_type='message', source='test', event_time=event_time) + async with pg_engine.connect() as conn: + row = (await conn.execute(sa.select(EventLog.event_time, EventLog.created_at))).one() + assert row == (NOW.replace(tzinfo=None), NOW.replace(tzinfo=None)) + assert await store.cleanup_events_older_than(event_time) == 0 + assert await store.cleanup_events_older_than(event_time + dt.timedelta(seconds=1)) == 1 + + +@pytest.mark.parametrize('offset', [None, 8, -7]) +async def test_transcript_timestamp_and_retention(pg_engine, offset): + store = transcript_store.TranscriptStore(pg_engine) + await store.append_transcript( + transcript_id='transcript', event_id='event', conversation_id='conversation', role='assistant' + ) + async with pg_engine.connect() as conn: + raw = (await conn.execute(sa.select(Transcript.created_at))).scalar_one() + assert raw == NOW.replace(tzinfo=None) + cutoff = NOW.replace(tzinfo=None) if offset is None else NOW.astimezone(dt.timezone(dt.timedelta(hours=offset))) + assert await store.cleanup_transcripts_older_than(cutoff) == 0 + assert await store.cleanup_transcripts_older_than(cutoff + dt.timedelta(seconds=1)) == 1 diff --git a/tests/unit_tests/plugin/test_connector_reconcile.py b/tests/unit_tests/plugin/test_connector_reconcile.py index d5292f8f3..2a7ea1e1e 100644 --- a/tests/unit_tests/plugin/test_connector_reconcile.py +++ b/tests/unit_tests/plugin/test_connector_reconcile.py @@ -381,7 +381,7 @@ async def test_marketplace_upgrade_reports_multistep_progress(): assert observed_actions == [ 'downloading plugin package', 'validating plugin package', - 'preparing plugin installation', + 'storing plugin package', 'applying plugin update', 'waiting for plugin initialization', 'refreshing plugin components', @@ -392,6 +392,9 @@ async def test_marketplace_upgrade_reports_multistep_progress(): 'install_source': 'marketplace', 'operation': 'upgrade', 'progress_percent': 100, + 'download_total': 0, + 'download_current': 0, + 'download_speed': 0, } diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx index d69c8a22a..6714c2753 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx @@ -12,12 +12,12 @@ import { Package, Server, Sparkles, + Rocket, CheckCircle2, XCircle, Loader2, RefreshCcw, ShieldCheck, - Rocket, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { @@ -63,7 +63,7 @@ function getStages(task: PluginInstallTask): StageConfig[] { : 'plugins.installProgress.installingDeps', }, { - key: InstallStage.ACTIVATING, + key: InstallStage.LAUNCHING, icon: Rocket, i18nKey: 'plugins.installProgress.activating', }, @@ -71,7 +71,9 @@ function getStages(task: PluginInstallTask): StageConfig[] { } function getStageIndex(stages: StageConfig[], stage: InstallStage): number { - const idx = stages.findIndex((item) => item.key === stage); + const displayStage = + stage === InstallStage.INITIALIZING ? InstallStage.LAUNCHING : stage; + const idx = stages.findIndex((item) => item.key === displayStage); return idx >= 0 ? idx : -1; } @@ -252,53 +254,9 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) { } if (stageKey === InstallStage.INSTALLING_DEPS) { - const total = task.depsTotal; - const installed = task.depsInstalled; - const remaining = task.depsRemaining; - const currentDep = task.currentDep; - const dlSize = task.depsDownloadedSize; - const speed = task.depsSpeed; - - if (isCompletedView && total != null) { - const parts: string[] = []; - parts.push(t('plugins.installProgress.depsInfo', { count: total })); - if (dlSize && dlSize > 0) { - parts.push(formatFileSize(dlSize)); - } - return parts.join(' · '); - } - - if (total != null && installed != null) { - const parts: string[] = []; - parts.push( - t('plugins.installProgress.depsProgress', { - installed, - total, - remaining: remaining ?? total - installed, - }), - ); - if (dlSize && dlSize > 0) { - parts.push(formatFileSize(dlSize)); - } - if (speed && speed > 0) { - parts.push(formatSpeed(speed)); - } - if (currentDep) { - return ( - <> - {parts.join(' · ')} -
- {currentDep} - - ); - } - return parts.join(' · '); - } - - if (total != null) { - return t('plugins.installProgress.depsInfo', { count: total }); - } - + // The runtime installs dependencies and starts the plugin in one step + // and does not report per-dependency progress, so this stage has no + // detail to show beyond its label. return undefined; } diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx index 0ed8d5cdd..34a7e44db 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx @@ -8,19 +8,14 @@ import React, { } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { AsyncTask } from '@/app/infra/entities/api'; +import { + InstallStage, + INSTALL_PROGRESS_CAP, + computeStageProgress, + mapActionToStage, +} from './install-progress'; -/** - * Installation stages mapped from backend current_action strings. - */ -export enum InstallStage { - CHECKING = 'checking', - DOWNLOADING = 'downloading', - VALIDATING = 'validating', - INSTALLING_DEPS = 'installing_deps', - ACTIVATING = 'activating', - DONE = 'done', - ERROR = 'error', -} +export { InstallStage } from './install-progress'; export type PluginTaskOperation = 'install' | 'upgrade'; @@ -39,15 +34,10 @@ export interface PluginInstallTask { downloadCurrent?: number; // bytes downloaded so far downloadTotal?: number; // total bytes to download downloadSpeed?: number; // bytes per second - // Dependency progress - depsTotal?: number; // total dependency count - depsInstalled?: number; // deps installed so far - depsRemaining?: number; // remaining - currentDep?: string; // currently installing dep name - depsDownloadedSize?: number; // total bytes of downloaded deps - depsSpeed?: number; // deps download speed bytes/s error?: string; startedAt: number; // timestamp + /** When the current stage began, used to bound in-stage drift. */ + stageStartedAt: number; currentAction: string; // raw backend action string } @@ -90,64 +80,6 @@ export function usePluginInstallTasks() { return ctx; } -/** - * Map backend `current_action` to our InstallStage. - */ -function mapActionToStage(action: string): InstallStage { - if (!action) return InstallStage.DOWNLOADING; - const lower = action.toLowerCase(); - if (lower.includes('check')) return InstallStage.CHECKING; - if (lower.includes('download')) return InstallStage.DOWNLOADING; - if (lower.includes('validat') || lower.includes('inspect')) - return InstallStage.VALIDATING; - if (lower.includes('dependencies') || lower.includes('requirements')) - return InstallStage.INSTALLING_DEPS; - if ( - lower.includes('preparing') || - lower.includes('applying') || - lower.includes('installing') - ) - return InstallStage.INSTALLING_DEPS; - if ( - lower.includes('initializ') || - lower.includes('launch') || - lower.includes('refresh') || - lower.includes('waiting') - ) - return InstallStage.ACTIVATING; - if ( - lower.includes('installed') || - lower.includes('updated') || - lower.includes('complete') - ) - return InstallStage.DONE; - return InstallStage.DOWNLOADING; -} - -/** - * Get overall progress percentage from a stage. - */ -function stageToProgress(stage: InstallStage): number { - switch (stage) { - case InstallStage.CHECKING: - return 5; - case InstallStage.DOWNLOADING: - return 18; - case InstallStage.VALIDATING: - return 35; - case InstallStage.INSTALLING_DEPS: - return 60; - case InstallStage.ACTIVATING: - return 85; - case InstallStage.DONE: - return 100; - case InstallStage.ERROR: - return 0; - default: - return 0; - } -} - /** * Extract install source from backend task name. */ @@ -210,7 +142,10 @@ export function asyncTaskToPluginInstallTask( if (exception) { stage = InstallStage.ERROR; failedStage = mapActionToStage(action); - overallProgress = stageToProgress(failedStage); + overallProgress = computeStageProgress({ + stage: failedStage, + stageElapsedSeconds: 0, + }); error = exception; } else { stage = InstallStage.DONE; @@ -219,8 +154,14 @@ export function asyncTaskToPluginInstallTask( } else { stage = mapActionToStage(action); overallProgress = Math.min( - 95, - num(md.progress_percent) ?? stageToProgress(stage), + INSTALL_PROGRESS_CAP, + computeStageProgress({ + stage, + downloadCurrent: num(md.download_current), + downloadTotal: num(md.download_total), + reportedProgress: num(md.progress_percent), + stageElapsedSeconds: 0, + }), ); } @@ -246,14 +187,9 @@ export function asyncTaskToPluginInstallTask( downloadCurrent: num(md.download_current), downloadTotal: num(md.download_total), downloadSpeed: num(md.download_speed), - depsTotal: num(md.deps_total), - depsInstalled: num(md.deps_installed), - depsRemaining: num(md.deps_remaining), - currentDep: str(md.current_dep), - depsDownloadedSize: num(md.deps_downloaded_size), - depsSpeed: num(md.deps_speed), error, startedAt: task.created_at ? task.created_at * 1000 : Date.now(), + stageStartedAt: Date.now(), currentAction: action, }; } @@ -327,20 +263,13 @@ export function PluginInstallTaskProvider({ unknown >; - // Extract progress fields from metadata + // Download byte counts are the only measurable install progress + // the backend reports for this task. const num = (v: unknown) => (typeof v === 'number' ? v : undefined); - const str = (v: unknown) => (typeof v === 'string' ? v : undefined); const downloadCurrent = num(md.download_current); const downloadTotal = num(md.download_total); const downloadSpeed = num(md.download_speed); - const depsTotal = num(md.deps_total); - const depsInstalled = num(md.deps_installed); - const depsRemaining = num(md.deps_remaining); - const currentDep = str(md.current_dep); - const depsDownloadedSize = num(md.deps_downloaded_size); - const depsSpeed = num(md.deps_speed); - const reportedProgress = num(md.progress_percent); setTasks((prev) => prev.map((t) => { @@ -350,13 +279,6 @@ export function PluginInstallTaskProvider({ downloadCurrent: downloadCurrent ?? t.downloadCurrent, downloadTotal: downloadTotal ?? t.downloadTotal, downloadSpeed: downloadSpeed ?? t.downloadSpeed, - depsTotal: depsTotal ?? t.depsTotal, - depsInstalled: depsInstalled ?? t.depsInstalled, - depsRemaining: depsRemaining ?? t.depsRemaining, - currentDep: currentDep ?? t.currentDep, - depsDownloadedSize: - depsDownloadedSize ?? t.depsDownloadedSize, - depsSpeed: depsSpeed ?? t.depsSpeed, }; if (done) { @@ -374,9 +296,10 @@ export function PluginInstallTaskProvider({ stage: InstallStage.ERROR, failedStage: mapActionToStage(action), error: exception, - overallProgress: stageToProgress( - mapActionToStage(action), - ), + overallProgress: computeStageProgress({ + stage: mapActionToStage(action), + stageElapsedSeconds: 0, + }), currentAction: action, ...progressFields, }; @@ -393,21 +316,29 @@ export function PluginInstallTaskProvider({ } const stage = mapActionToStage(action); - const baseProgress = stageToProgress(stage); - // Add small time-based increment within stage - const elapsed = (Date.now() - t.startedAt) / 1000; - const withinStageIncrement = Math.min( - 15, - Math.floor(elapsed / 2), - ); + // Reset the in-stage clock whenever the reported stage moves + // so drift reflects time spent in this stage, not the whole + // installation. + const stageChanged = stage !== t.stage; + const stageStartedAt = stageChanged + ? Date.now() + : t.stageStartedAt; + const stageProgress = computeStageProgress({ + stage, + downloadCurrent, + downloadTotal, + reportedProgress: num(md.progress_percent), + stageElapsedSeconds: (Date.now() - stageStartedAt) / 1000, + }); const progress = Math.min( - 95, - reportedProgress ?? baseProgress + withinStageIncrement, + INSTALL_PROGRESS_CAP, + Math.max(t.overallProgress, stageProgress), ); return { ...t, stage, + stageStartedAt, overallProgress: progress, currentAction: action, ...progressFields, @@ -540,6 +471,7 @@ export function PluginInstallTaskProvider({ overallProgress: operation === 'upgrade' ? 3 : 5, fileSize: params.fileSize, startedAt: Date.now(), + stageStartedAt: Date.now(), currentAction: '', }; diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx index 748ee2dd3..2a5ebbab5 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx @@ -4,6 +4,7 @@ import { Progress } from '@/components/ui/progress'; import { Download, Package, + Rocket, CheckCircle2, XCircle, Loader2, @@ -14,7 +15,6 @@ import { Sparkles, RefreshCcw, ShieldCheck, - Rocket, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -35,7 +35,8 @@ const STAGE_ICONS: Record = { [InstallStage.DOWNLOADING]: Download, [InstallStage.VALIDATING]: ShieldCheck, [InstallStage.INSTALLING_DEPS]: Package, - [InstallStage.ACTIVATING]: Rocket, + [InstallStage.INITIALIZING]: Rocket, + [InstallStage.LAUNCHING]: Rocket, [InstallStage.DONE]: CheckCircle2, [InstallStage.ERROR]: XCircle, }; @@ -110,7 +111,8 @@ function TaskQueueItem({ return task.operation === 'upgrade' ? t('plugins.installProgress.applyingUpdate') : t('plugins.installProgress.installingDeps'); - case InstallStage.ACTIVATING: + case InstallStage.INITIALIZING: + case InstallStage.LAUNCHING: return t('plugins.installProgress.activating'); case InstallStage.DONE: return isDone diff --git a/web/src/app/home/plugins/components/plugin-install-task/install-progress.ts b/web/src/app/home/plugins/components/plugin-install-task/install-progress.ts new file mode 100644 index 000000000..519e0b4ce --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-install-task/install-progress.ts @@ -0,0 +1,157 @@ +/** + * Pure install-stage model shared by the install-task UI. + * + * Kept free of React imports so the mapping and progress maths can be + * exercised directly in unit tests. + */ + +/** + * Installation stages mapped from backend current_action strings. + */ +export enum InstallStage { + CHECKING = 'checking', + VALIDATING = 'validating', + DOWNLOADING = 'downloading', + INSTALLING_DEPS = 'installing_deps', + INITIALIZING = 'initializing', + LAUNCHING = 'launching', + DONE = 'done', + ERROR = 'error', +} + +/** + * Map the backend `current_action` string to an InstallStage. + * + * The runtime connector emits human-readable stage strings; each branch here + * matches the wording produced by the connector so newly added stages show up + * in the UI without a protocol change. + */ +export function mapActionToStage(action: string): InstallStage { + const lower = (action || '').toLowerCase(); + + // Terminal wording first: "installed" would otherwise also match the + // in-progress "installing" branch below. + if ( + lower.includes('installed') || + lower.includes('updated') || + lower.includes('complete') + ) { + return InstallStage.DONE; + } + // "waiting for plugin to become ready" is the post-install readiness wait, + // checked before the stage branches so "ready" is not read as "done". + if (lower.includes('waiting') || lower.includes('ready')) { + return InstallStage.LAUNCHING; + } + if (lower.includes('checking')) return InstallStage.CHECKING; + if (lower.includes('validat')) return InstallStage.VALIDATING; + if (lower.includes('refresh')) return InstallStage.LAUNCHING; + if (lower.includes('applying')) return InstallStage.INSTALLING_DEPS; + + // Pre-download wording, checked before the "install" branches because + // "preparing plugin install" also contains "install". + if (lower.includes('prepar') || lower.includes('checking')) { + return InstallStage.DOWNLOADING; + } + if (lower.includes('download')) return InstallStage.DOWNLOADING; + + // The runtime installs the plugin's dependencies and starts it in a single + // step ("installing or starting plugin"), and persisting the installation + // precedes it. None of these stream finer-grained progress, so they all map + // to one honest stage rather than pretending to be a separate dependency + // step. This is checked before the generic "launch"/"start" branch, which + // would otherwise catch the "...or starting..." wording. + if ( + lower.includes('installing') || + lower.includes('starting') || + lower.includes('persisting') || + lower.includes('storing') || + lower.includes('inspect') + ) { + return InstallStage.INSTALLING_DEPS; + } + if (lower.includes('launch')) return InstallStage.LAUNCHING; + if (lower.includes('initializ') || lower.includes('configur')) { + return InstallStage.INITIALIZING; + } + + return InstallStage.DOWNLOADING; +} + +/** + * Progress range (start → end) attributed to each stage, used to build a + * smooth determinate bar that never goes backwards. The ranges are contiguous + * and non-overlapping, so progress never has to move backwards when the stage + * advances. + */ +export const STAGE_PROGRESS_RANGE: Record = { + [InstallStage.CHECKING]: [3, 5], + [InstallStage.VALIDATING]: [45, 45], + [InstallStage.DOWNLOADING]: [5, 45], + [InstallStage.INSTALLING_DEPS]: [45, 85], + [InstallStage.INITIALIZING]: [85, 88], + [InstallStage.LAUNCHING]: [88, 97], + [InstallStage.DONE]: [100, 100], + [InstallStage.ERROR]: [0, 0], +}; + +/** Progress never reaches 100 until the backend reports the task as done. */ +export const INSTALL_PROGRESS_CAP = 99; + +function clampToRange(value: number, start: number, end: number): number { + return Math.min(end, Math.max(start, value)); +} + +export interface StageProgressInput { + stage: InstallStage; + downloadCurrent?: number; + downloadTotal?: number; + /** Host coarse stage progress, used only when measured bytes are absent. */ + reportedProgress?: number; + /** Seconds spent in the current stage, used to bound fallback drift. */ + stageElapsedSeconds: number; +} + +/** + * Progress contributed by a single stage, always inside that stage's range. + * + * Real byte counts take priority: when the backend has reported a download + * size, the measured ratio is authoritative and no time-based drift is added + * on top of it. Drift is only a fallback for stages that report no measurable + * progress, and it is clamped to the current stage so it can never spill into + * a later stage's range. + */ +export function computeStageProgress(input: StageProgressInput): number { + const [start, end] = STAGE_PROGRESS_RANGE[input.stage] ?? [0, 0]; + + const hasMeasuredBytes = + input.stage === InstallStage.DOWNLOADING && + input.downloadTotal != null && + input.downloadTotal > 0 && + input.downloadCurrent != null; + + if (hasMeasuredBytes) { + const ratio = Math.min( + 1, + (input.downloadCurrent as number) / (input.downloadTotal as number), + ); + return clampToRange(Math.round(start + (end - start) * ratio), start, end); + } + + if ( + input.reportedProgress != null && + Number.isFinite(input.reportedProgress) + ) { + return clampToRange(input.reportedProgress, start, end); + } + + // Nothing measurable to show yet: drift slowly, but never past this stage's + // own ceiling (hence `end - start - 1`, leaving the final point to the real + // stage transition). + const maxDrift = Math.max(0, end - start - 1); + const drift = Math.min( + maxDrift, + Math.floor(Math.max(0, input.stageElapsedSeconds) / 2), + ); + return clampToRange(start + drift, start, end); +} diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx index 8719b36f5..76e7ad956 100644 --- a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx @@ -21,7 +21,7 @@ import { extractI18nObject } from '@/i18n/I18nProvider'; import { toast } from 'sonner'; import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; -import { Loader2, Puzzle, Server, Sparkles } from 'lucide-react'; +import { Loader2, Puzzle, Search, Server, Sparkles } from 'lucide-react'; import { pluginTaskKey, usePluginInstallTasks, @@ -64,12 +64,16 @@ export const FilterOptions = [ interface PluginInstalledComponentProps { filterType: FilterType; groupByType: boolean; + /** Free-text filter over label / name / author / description. */ + searchQuery?: string; + /** Invoked when the user clears the search from the empty state. */ + onClearSearch?: () => void; } const PluginInstalledComponent = forwardRef< PluginInstalledComponentRef, PluginInstalledComponentProps ->(({ filterType, groupByType }, ref) => { +>(({ filterType, groupByType, searchQuery = '', onClearSearch }, ref) => { const { t } = useTranslation(); const navigate = useNavigate(); const { addTask, setSelectedTaskId } = usePluginInstallTasks(); @@ -317,11 +321,21 @@ const PluginInstalledComponent = forwardRef< }); } + // Match the query against the fields a user can actually see on the card + // (label / name / author) plus the description, case-insensitively. + const normalizedQuery = searchQuery.trim().toLowerCase(); const filteredExtensions = extensionList.filter((ext) => { - if (filterType === 'all') return true; - return ext.type === filterType; + if (filterType !== 'all' && ext.type !== filterType) return false; + if (!normalizedQuery) return true; + return [ext.label, ext.name, ext.author, ext.description].some((field) => + (field || '').toLowerCase().includes(normalizedQuery), + ); }); + const clearSearch = () => { + onClearSearch?.(); + }; + const showGrouped = groupByType && filterType === 'all'; const groupOrder: ExtensionType[] = ['plugin', 'mcp', 'skill']; const groupedExtensions = groupOrder @@ -471,10 +485,26 @@ const PluginInstalledComponent = forwardRef< ) : filteredExtensions.length === 0 ? (
- -
- {t('plugins.noExtensionInstalled')} -
+ {normalizedQuery ? ( + <> + +
+ {t('plugins.noMatchingExtensions', { + query: searchQuery.trim(), + })} +
+ + + ) : ( + <> + +
+ {t('plugins.noExtensionInstalled')} +
+ + )}
) : showGrouped ? (
diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx index ea39dcb5f..1dd640e0e 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx @@ -1,4 +1,11 @@ -import { useState, useEffect, useCallback, useRef, Suspense } from 'react'; +import { + useState, + useEffect, + useCallback, + useMemo, + useRef, + Suspense, +} from 'react'; import { useSearchParams } from 'react-router-dom'; import { Input } from '@/components/ui/input'; import { @@ -38,6 +45,8 @@ import { } from '@/components/ui/tooltip'; import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent'; import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO'; +import { resolveInstalledState } from './marketplace-installed'; +import { useMarketplaceInstalledIndex } from './useMarketplaceInstalledIndex'; import { RecommendationLists } from './RecommendationLists'; import type { RecommendationList } from './RecommendationLists'; import { @@ -157,6 +166,8 @@ function MarketPageContent({ const [recommendationLists, setRecommendationLists] = useState< RecommendationList[] >([]); + // Installed extensions from the sidebar; used to mark market cards. + const installedIndex = useMarketplaceInstalledIndex(); const [plugins, setPlugins] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); @@ -622,7 +633,27 @@ function MarketPageContent({ }; }, []); - const visiblePlugins = plugins; + // Annotate cards with installed state derived from the sidebar index. This is + // computed (rather than baked into `plugins`) so a finished install updates + // the badges as soon as the sidebar refreshes. + const visiblePlugins = useMemo( + () => + plugins.map((plugin) => { + const state = resolveInstalledState(installedIndex, plugin); + if ( + state.installed === plugin.installed && + state.hasUpdate === plugin.hasUpdate + ) { + return plugin; + } + return new PluginMarketCardVO({ + ...plugin, + installed: state.installed, + hasUpdate: state.hasUpdate, + }); + }), + [plugins, installedIndex], + ); // 加载更多 const loadMore = useCallback(() => { @@ -936,6 +967,7 @@ function MarketPageContent({ onInstall={handleInstallPlugin} installDisabled={installDisabled} installDisabledTooltip={installDisabledTooltip} + installedIndex={installedIndex} /> )} diff --git a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx index 2eb5a22eb..6f8dbfe11 100644 --- a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx +++ b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx @@ -8,6 +8,10 @@ import { I18nObject } from '@/app/infra/entities/common'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { getCloudServiceClientSync } from '@/app/infra/http'; import { useTranslation } from 'react-i18next'; +import { + resolveInstalledState, + type InstalledExtensionEntry, +} from './marketplace-installed'; export interface RecommendationList { uuid: string; @@ -21,6 +25,7 @@ export interface RecommendationList { function pluginToVO( plugin: PluginV4, t: (key: string) => string, + installedIndex?: Map, ): PluginMarketCardVO { const cloudClient = getCloudServiceClientSync(); // Recommendation lists are mixed-type; resolve the icon per extension type, @@ -32,6 +37,14 @@ function pluginToVO( plugin.icon, ); + const installedState = installedIndex + ? resolveInstalledState(installedIndex, { + type: plugin.type, + author: plugin.author, + pluginName: plugin.name, + }) + : undefined; + return new PluginMarketCardVO({ pluginId: plugin.author + ' / ' + plugin.name, author: plugin.author, @@ -47,6 +60,8 @@ function pluginToVO( components: plugin.components, tags: plugin.tags || [], type: plugin.type, + installed: installedState?.installed, + hasUpdate: installedState?.hasUpdate, }); } @@ -57,6 +72,7 @@ function RecommendationListRow({ installDisabled, installDisabledTooltip, isLast, + installedIndex, }: { list: RecommendationList; tagNames: Record; @@ -64,6 +80,7 @@ function RecommendationListRow({ installDisabled?: boolean; installDisabledTooltip?: string; isLast: boolean; + installedIndex?: Map; }) { const { t } = useTranslation(); const [page, setPage] = useState(0); @@ -264,7 +281,7 @@ function RecommendationListRow({ {visiblePlugins.map((plugin) => ( ; onInstall: (cardVO: PluginMarketCardVO) => void; installDisabled?: boolean; installDisabledTooltip?: string; + installedIndex?: Map; }) { if (!lists || lists.length === 0) return null; @@ -305,6 +324,7 @@ export function RecommendationLists({ installDisabled={installDisabled} installDisabledTooltip={installDisabledTooltip} isLast={index === lists.length - 1} + installedIndex={installedIndex} /> ))}
diff --git a/web/src/app/home/plugins/components/plugin-market/marketplace-installed.ts b/web/src/app/home/plugins/components/plugin-market/marketplace-installed.ts new file mode 100644 index 000000000..27d25da46 --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-market/marketplace-installed.ts @@ -0,0 +1,107 @@ +/** + * Marketplace extensions are addressed as `author/name`. Installed extensions + * only carry a publisher-scoped identity for plugins and MCP servers: + * - plugins: `author/name` + * - MCP servers: `author__name` (double underscore) + * - skills: the bare skill name, with no publisher recorded + * + * The index below normalises those to a single `type:author/name` shape so a + * marketplace card can be matched with one lookup. + * + * Skills are deliberately *not* indexable: the backend derives a skill's name + * from the `name` field in its own SKILL.md (falling back to the package + * directory name), so a skill published by `alice/review` and one published by + * `bob/review` both install as the plain name `review`. Matching on that bare + * name would mark every publisher's `review` as installed once any single one + * of them is. Until the installed skill carries its publisher, a skill card + * cannot be resolved authoritatively, and so is reported as not installed. + * + * This module is intentionally free of React imports so it can be unit tested + * directly; the reactive hook lives in `useMarketplaceInstalledIndex.ts`. + */ + +export interface InstalledExtensionEntry { + /** An installed extension of the same identity has a newer remote version. */ + hasUpdate: boolean; +} + +export interface MarketplaceInstalledState { + installed: boolean; + hasUpdate: boolean; +} + +/** Identity for a marketplace extension card. */ +export function installedExtensionKey( + type: string | undefined, + author: string, + name: string, +): string { + return `${type || 'plugin'}:${author}/${name}`; +} + +/** Split an `author/name` identity, tolerating a missing author. */ +function splitIdentity(identity: string): [string, string] { + const slash = identity.indexOf('/'); + if (slash < 0) return ['', identity]; + return [identity.slice(0, slash), identity.slice(slash + 1)]; +} + +/** + * Build the installed-extension lookup from the sidebar entity lists. + * + * Skills are intentionally omitted — see the module header for why a bare + * skill name cannot be attributed to a publisher. + */ +export function buildInstalledIndex( + plugins: { id: string; hasUpdate?: boolean }[], + mcpServers: { id: string }[], + skills: { id: string }[], +): Map { + const index = new Map(); + + for (const plugin of plugins) { + index.set(installedExtensionKey('plugin', ...splitIdentity(plugin.id)), { + hasUpdate: plugin.hasUpdate ?? false, + }); + } + + for (const server of mcpServers) { + // MCP servers are keyed with `__`; normalise to `author/name`. + index.set( + installedExtensionKey( + 'mcp', + ...splitIdentity(server.id.replace(/__/g, '/')), + ), + { hasUpdate: false }, + ); + } + + // `skills` is accepted for call-site symmetry (and so the surrounding + // useMemo still re-runs when the list changes) but contributes nothing. + void skills; + + return index; +} + +/** + * Resolve whether a marketplace extension is already installed. + * + * Matching requires the full `type:author/name` identity, so a card is only + * marked installed when the installed extension carries the same publisher. + * Unknown types fall back to `plugin`, matching the marketplace defaults. + */ +export function resolveInstalledState( + index: Map, + extension: { type?: string; author: string; pluginName: string }, +): MarketplaceInstalledState { + const type = extension.type || 'plugin'; + const entry = index.get( + `${type}:${extension.author}/${extension.pluginName}`, + ); + + if (entry) { + return { installed: true, hasUpdate: entry.hasUpdate }; + } + + return { installed: false, hasUpdate: false }; +} diff --git a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx index c4d741ed6..c8393ac1b 100644 --- a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx @@ -3,7 +3,14 @@ import { useRef, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import PluginComponentList from '../PluginComponentList'; import { Badge } from '@/components/ui/badge'; -import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react'; +import { + Info, + Package, + ExternalLink, + Heart, + Loader2, + Check, +} from 'lucide-react'; import { Tooltip, TooltipContent, @@ -48,6 +55,9 @@ export default function PluginMarketCardComponent({ return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever'); })(); + const isInstalled = !!cardVO.installed; + const hasUpdate = !!cardVO.hasUpdate; + const showTypeBadge = cardVO.type; const typeLabel = cardVO.type === 'mcp' @@ -158,12 +168,33 @@ export default function PluginMarketCardComponent({ } }; + // An already-installed extension turns its download affordance into a filled + // green circle-check, so the card reads as "installed" in place instead of + // offering another install. + const showInstalledMark = isInstalled && !hasUpdate; + + // Bottom-right slot: the component list. + const bottomTrailing = + cardVO.components && Object.keys(cardVO.components).length > 0 ? ( + + ) : null; const cardContent = (
- + + + ) : ( + + + + + + )} +
- - - - -
- {cardVO.installCount?.toLocaleString() ?? '0'} + {showInstalledMark + ? t('market.installed') + : (cardVO.installCount?.toLocaleString() ?? '0')}
@@ -376,18 +437,11 @@ export default function PluginMarketCardComponent({ )}
- {cardVO.components && Object.keys(cardVO.components).length > 0 && ( + {bottomTrailing ? (
- + {bottomTrailing}
- )} + ) : null}
diff --git a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts index 11579fe4c..4816d5d3a 100644 --- a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts +++ b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts @@ -12,6 +12,10 @@ export interface IPluginMarketCardVO { components?: Record; tags?: string[]; type?: 'plugin' | 'mcp' | 'skill'; + /** Whether this extension is already installed in the current workspace. */ + installed?: boolean; + /** Whether the installed extension has a newer marketplace version. */ + hasUpdate?: boolean; } export class PluginMarketCardVO implements IPluginMarketCardVO { @@ -28,6 +32,8 @@ export class PluginMarketCardVO implements IPluginMarketCardVO { components?: Record; tags?: string[]; type?: 'plugin' | 'mcp' | 'skill'; + installed?: boolean; + hasUpdate?: boolean; constructor(prop: IPluginMarketCardVO) { this.description = prop.description; @@ -43,5 +49,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO { this.components = prop.components; this.tags = prop.tags; this.type = prop.type; + this.installed = prop.installed ?? false; + this.hasUpdate = prop.hasUpdate ?? false; } } diff --git a/web/src/app/home/plugins/components/plugin-market/useMarketplaceInstalledIndex.ts b/web/src/app/home/plugins/components/plugin-market/useMarketplaceInstalledIndex.ts new file mode 100644 index 000000000..8d675a264 --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-market/useMarketplaceInstalledIndex.ts @@ -0,0 +1,24 @@ +import { useMemo } from 'react'; +import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; +import { + buildInstalledIndex, + type InstalledExtensionEntry, +} from './marketplace-installed'; + +/** + * Reactive installed-extension index derived from the sidebar data context. + * + * Because the index is memoised on the sidebar lists, a finished install (which + * triggers a sidebar refresh) automatically re-evaluates the marketplace cards. + */ +export function useMarketplaceInstalledIndex(): Map< + string, + InstalledExtensionEntry +> { + const { plugins, mcpServers, skills } = useSidebarData(); + + return useMemo( + () => buildInstalledIndex(plugins, mcpServers, skills), + [plugins, mcpServers, skills], + ); +} diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 323de8ba1..0cf603042 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -9,7 +9,7 @@ import { Label } from '@/components/ui/label'; import PluginDetailContent from './PluginDetailContent'; import styles from './plugins.module.css'; import { Button } from '@/components/ui/button'; -import { Power, Code, Copy, Check, Bug, Unlink } from 'lucide-react'; +import { Power, Code, Copy, Check, Bug, Unlink, Search, X } from 'lucide-react'; import { copyToClipboard } from '@/app/utils/clipboard'; import { Popover, @@ -63,6 +63,7 @@ function PluginListView() { const [copiedDebugUrl, setCopiedDebugUrl] = useState(false); const [copiedDebugKey, setCopiedDebugKey] = useState(false); const [filterType, setFilterType] = useState('all'); + const [installedSearchQuery, setInstalledSearchQuery] = useState(''); const pluginInstalledRef = useRef(null); useEffect(() => { @@ -188,6 +189,27 @@ function PluginListView() {
+ {/* Search installed extensions by label / name / author / description */} +
+ + setInstalledSearchQuery(e.target.value)} + placeholder={t('plugins.searchInstalled')} + aria-label={t('plugins.searchInstalled')} + className="pl-9 pr-8 text-sm" + /> + {installedSearchQuery && ( + + )} +
diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index c55810e6c..301538bbb 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -649,11 +649,12 @@ export interface AsyncTaskTaskContext { export interface AsyncTask { id: number; - created_at?: number; kind: string; name: string; label: string; task_type: string; // system or user + /** Unix epoch seconds (float) when the task was created. */ + created_at?: number; runtime: AsyncTaskRuntimeInfo; task_context: AsyncTaskTaskContext; } diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index be411bed2..8e9f51dee 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -523,7 +523,7 @@ export default function WizardPage() { setRunnerInstallProgress((current) => ({ ...current, [pluginId]: { - stage: registering ? InstallStage.ACTIVATING : progress.stage, + stage: registering ? InstallStage.LAUNCHING : progress.stage, percent: registering ? 95 : progress.overallProgress, }, })); @@ -1870,7 +1870,8 @@ const RUNNER_INSTALL_STAGE_LABELS: Record = { [InstallStage.DOWNLOADING]: 'plugins.installProgress.downloading', [InstallStage.VALIDATING]: 'plugins.installProgress.validating', [InstallStage.INSTALLING_DEPS]: 'plugins.installProgress.installingDeps', - [InstallStage.ACTIVATING]: 'plugins.installProgress.activating', + [InstallStage.INITIALIZING]: 'plugins.installProgress.activating', + [InstallStage.LAUNCHING]: 'plugins.installProgress.activating', [InstallStage.DONE]: 'plugins.installProgress.completed', [InstallStage.ERROR]: 'plugins.installProgress.failed', }; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index ba0168a73..1b66621c0 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -290,6 +290,7 @@ const enUS = { delete: 'Delete', add: 'Add', select: 'Select', + clear: 'Clear', skill: 'Skill', cancel: 'Cancel', submit: 'Submit', @@ -1364,6 +1365,8 @@ const enUS = { getPluginListError: 'Failed to get plugin list:', noPluginInstalled: 'No plugins installed', noExtensionInstalled: 'No extensions installed', + searchInstalled: 'Search installed extensions', + noMatchingExtensions: 'No extensions match "{{query}}"', loadingExtensions: 'Loading extensions...', groupByType: 'Group by format', groupByTypeShort: 'Group', @@ -1572,6 +1575,9 @@ const enUS = { allLoadedCount: 'All {{count}} extensions displayed', install: 'Install', installCard: 'Install {{name}}', + installedCard: 'Installed {{name}}', + installed: 'Installed', + updateAvailable: 'Update available', installConfirm: 'Are you sure you want to install plugin "{{name}}" ({{version}})?', downloadComplete: 'Plugin "{{name}}" download completed', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 784de31cb..dbfe88ab2 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -295,6 +295,7 @@ const esES = { delete: 'Eliminar', add: 'Añadir', select: 'Seleccionar', + clear: 'Limpiar', skill: 'Habilidad', cancel: 'Cancelar', submit: 'Enviar', @@ -1397,6 +1398,8 @@ const esES = { getPluginListError: 'Error al obtener la lista de plugins:', noPluginInstalled: 'No hay plugins instalados', noExtensionInstalled: 'No hay extensiones instaladas', + searchInstalled: 'Buscar extensiones instaladas', + noMatchingExtensions: 'Ninguna extensión coincide con "{{query}}"', loadingExtensions: 'Cargando extensiones...', groupByType: 'Agrupar por formato', groupByTypeShort: 'Agrupar', @@ -1688,6 +1691,9 @@ const esES = { noTags: 'No hay etiquetas disponibles', }, installCard: 'Instalar {{name}}', + installedCard: '{{name}} instalado', + installed: 'Instalado', + updateAvailable: 'Actualización disponible', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index bf388ff38..3b5d6c1fd 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -292,6 +292,7 @@ const jaJP = { delete: '削除', add: '追加', select: '選択してください', + clear: 'クリア', skill: 'スキル', cancel: 'キャンセル', submit: '送信', @@ -1384,6 +1385,8 @@ const jaJP = { getPluginListError: 'プラグインリストの取得に失敗しました:', noPluginInstalled: 'プラグインがインストールされていません', noExtensionInstalled: '拡張機能がインストールされていません', + searchInstalled: 'インストール済み拡張機能を検索', + noMatchingExtensions: '「{{query}}」に一致する拡張機能はありません', loadingExtensions: '拡張機能を読み込み中...', groupByType: '形式でグループ化', groupByTypeShort: 'グループ', @@ -1672,6 +1675,9 @@ const jaJP = { deprecatedTooltip: '対応する「ナレッジエンジン」プラグインをインストールしてください。', installCard: '{{name}} をインストール', + installedCard: '{{name}} はインストール済み', + installed: 'インストール済み', + updateAvailable: '更新があります', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 438dae4d9..fd9f8831f 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -294,6 +294,7 @@ const ruRU = { delete: 'Удалить', add: 'Добавить', select: 'Выбрать', + clear: 'Очистить', skill: 'Навык', cancel: 'Отмена', submit: 'Отправить', @@ -1397,6 +1398,8 @@ const ruRU = { getPluginListError: 'Не удалось получить список плагинов:', noPluginInstalled: 'Плагины не установлены', noExtensionInstalled: 'Расширения не установлены', + searchInstalled: 'Поиск установленных расширений', + noMatchingExtensions: 'Нет расширений, соответствующих «{{query}}»', loadingExtensions: 'Загрузка расширений...', groupByType: 'Группировать по формату', groupByTypeShort: 'Группа', @@ -1685,6 +1688,9 @@ const ruRU = { noTags: 'Нет доступных тегов', }, installCard: 'Установить {{name}}', + installedCard: '{{name}} установлен', + installed: 'Установлено', + updateAvailable: 'Доступно обновление', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 8cbf6e716..8f6f1faf6 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -289,6 +289,7 @@ const thTH = { delete: 'ลบ', add: 'เพิ่ม', select: 'เลือก', + clear: 'ล้าง', skill: 'สกิล', cancel: 'ยกเลิก', submit: 'ส่ง', @@ -1347,6 +1348,8 @@ const thTH = { getPluginListError: 'ไม่สามารถดึงรายการปลั๊กอินได้:', noPluginInstalled: 'ยังไม่มีปลั๊กอินที่ติดตั้ง', noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง', + searchInstalled: 'ค้นหาส่วนขยายที่ติดตั้งแล้ว', + noMatchingExtensions: 'ไม่มีส่วนขยายที่ตรงกับ "{{query}}"', loadingExtensions: 'กำลังโหลดส่วนขยาย...', groupByType: 'จัดกลุ่มตามรูปแบบ', groupByTypeShort: 'จัดกลุ่ม', @@ -1628,6 +1631,9 @@ const thTH = { noTags: 'ไม่มีแท็กที่พร้อมใช้งาน', }, installCard: 'ติดตั้ง {{name}}', + installedCard: 'ติดตั้ง {{name}} แล้ว', + installed: 'ติดตั้งแล้ว', + updateAvailable: 'มีอัปเดต', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 9a542d8d2..b23f30580 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -291,6 +291,7 @@ const viVN = { delete: 'Xóa', add: 'Thêm', select: 'Chọn', + clear: 'Xóa', skill: 'Kỹ năng', cancel: 'Hủy', submit: 'Gửi', @@ -1367,6 +1368,8 @@ const viVN = { getPluginListError: 'Lấy danh sách plugin thất bại:', noPluginInstalled: 'Chưa cài đặt plugin nào', noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào', + searchInstalled: 'Tìm tiện ích mở rộng đã cài đặt', + noMatchingExtensions: 'Không có tiện ích mở rộng nào khớp với "{{query}}"', loadingExtensions: 'Đang tải tiện ích mở rộng...', groupByType: 'Nhóm theo định dạng', groupByTypeShort: 'Nhóm', @@ -1653,6 +1656,9 @@ const viVN = { noTags: 'Không có thẻ nào', }, installCard: 'Cài đặt {{name}}', + installedCard: 'Đã cài đặt {{name}}', + installed: 'Đã cài đặt', + updateAvailable: 'Có bản cập nhật', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index c32caef36..5382b3f03 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -273,6 +273,7 @@ const zhHans = { delete: '删除', add: '添加', select: '请选择', + clear: '清除', skill: '技能', cancel: '取消', submit: '提交', @@ -1285,6 +1286,8 @@ const zhHans = { pluginConfig: '插件配置', noPluginInstalled: '暂未安装任何插件', noExtensionInstalled: '暂未安装任何扩展', + searchInstalled: '搜索已安装扩展', + noMatchingExtensions: '没有匹配「{{query}}」的扩展', loadingExtensions: '正在加载扩展...', groupByType: '按格式分组', groupByTypeShort: '分组', @@ -1482,6 +1485,9 @@ const zhHans = { allLoadedCount: '已显示全部 {{count}} 个扩展', install: '安装', installCard: '安装 {{name}}', + installedCard: '已安装 {{name}}', + installed: '已安装', + updateAvailable: '有可用更新', installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?', downloadComplete: '插件 "{{name}}" 下载完成', installFailed: '安装失败,请稍后重试', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index fe63dcd97..2b8b15e3a 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -273,6 +273,7 @@ const zhHant = { delete: '刪除', add: '新增', select: '請選擇', + clear: '清除', skill: '技能', cancel: '取消', submit: '提交', @@ -1292,6 +1293,8 @@ const zhHant = { pluginConfig: '外掛設定', noPluginInstalled: '暫未安裝任何外掛', noExtensionInstalled: '暫未安裝任何擴充功能', + searchInstalled: '搜尋已安裝擴充功能', + noMatchingExtensions: '沒有符合「{{query}}」的擴充功能', loadingExtensions: '正在載入擴充功能...', groupByType: '依格式分組', groupByTypeShort: '分組', @@ -1489,6 +1492,9 @@ const zhHant = { allLoadedCount: '已顯示全部 {{count}} 個擴展', install: '安裝', installCard: '安裝 {{name}}', + installedCard: '已安裝 {{name}}', + installed: '已安裝', + updateAvailable: '有可用更新', installConfirm: '確定要安裝插件 "{{name}}" ({{version}}) 嗎?', downloadComplete: '插件 "{{name}}" 下載完成', installFailed: '安裝失敗,請稍後重試', diff --git a/web/tests/e2e/home-smoke.spec.ts b/web/tests/e2e/home-smoke.spec.ts index fa15dcaba..34a69670d 100644 --- a/web/tests/e2e/home-smoke.spec.ts +++ b/web/tests/e2e/home-smoke.spec.ts @@ -84,10 +84,10 @@ test.describe('authenticated app shell', () => { await page.getByRole('button', { name: 'Debug Info' }).click(); await expect(page.getByText('Plugin Debug Information')).toBeVisible(); - await expect(page.getByRole('textbox').nth(0)).toHaveValue( + await expect(page.getByRole('textbox', { name: 'Debug URL' })).toHaveValue( 'ws://127.0.0.1:5300/plugin/debug', ); - await expect(page.getByRole('textbox').nth(1)).toHaveValue( + await expect(page.getByRole('textbox', { name: 'Debug Key' })).toHaveValue( 'test-debug-key', ); }); diff --git a/web/tests/unit/install-progress.test.mjs b/web/tests/unit/install-progress.test.mjs new file mode 100644 index 000000000..84f176ed6 --- /dev/null +++ b/web/tests/unit/install-progress.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const sourcePath = path.resolve( + currentDirectory, + '../../src/app/home/plugins/components/plugin-install-task/install-progress.ts', +); +const source = fs.readFileSync(sourcePath, 'utf8'); +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, +}).outputText; +const sourceRequire = createRequire(sourcePath); +const loadedModule = { exports: {} }; +new Function('require', 'module', 'exports', compiled)( + sourceRequire, + loadedModule, + loadedModule.exports, +); + +const { + InstallStage, + INSTALL_PROGRESS_CAP, + STAGE_PROGRESS_RANGE, + computeStageProgress, + mapActionToStage, +} = loadedModule.exports; + +test('maps the connector stage strings the runtime actually emits', () => { + assert.equal( + mapActionToStage('preparing plugin install'), + InstallStage.DOWNLOADING, + '"preparing plugin install" must not be read as the dependency stage', + ); + assert.equal( + mapActionToStage('downloading plugin package'), + InstallStage.DOWNLOADING, + ); + assert.equal( + mapActionToStage('inspecting plugin package'), + InstallStage.INSTALLING_DEPS, + ); + assert.equal( + mapActionToStage('storing plugin package'), + InstallStage.INSTALLING_DEPS, + ); + assert.equal( + mapActionToStage('persisting the installation'), + InstallStage.INSTALLING_DEPS, + ); + assert.equal(mapActionToStage('launching plugin'), InstallStage.LAUNCHING); + assert.equal( + mapActionToStage('waiting for plugin to become ready'), + InstallStage.LAUNCHING, + 'the readiness wait is still an active stage, not completion', + ); +}); + +test('the combined install-and-start stage is not reported as launching', () => { + // The runtime installs dependencies and starts the plugin in one step; the + // wording contains "starting" and must not be mapped to the launch stage. + assert.equal( + mapActionToStage('installing or starting plugin'), + InstallStage.INSTALLING_DEPS, + ); +}); + +test('measured byte counts stay inside the download stage range', () => { + const [start, end] = STAGE_PROGRESS_RANGE[InstallStage.DOWNLOADING]; + + // 90 of 100 bytes is 90% of the download range, even after 40s elapsed. + const progress = computeStageProgress({ + stage: InstallStage.DOWNLOADING, + downloadCurrent: 90, + downloadTotal: 100, + stageElapsedSeconds: 40, + }); + + assert.ok( + progress >= start && progress <= end, + `expected progress within [${start}, ${end}], received ${progress}`, + ); + assert.equal( + progress, + 41, + 'drift must not be layered on top of a measured byte ratio', + ); +}); + +test('fallback drift never spills into the next stage range', () => { + for (const stage of [ + InstallStage.DOWNLOADING, + InstallStage.INSTALLING_DEPS, + InstallStage.INITIALIZING, + InstallStage.LAUNCHING, + ]) { + const [start, end] = STAGE_PROGRESS_RANGE[stage]; + const progress = computeStageProgress({ + stage, + stageElapsedSeconds: 100000, + }); + + assert.ok( + progress >= start && progress <= end, + `stage ${stage} produced ${progress}, outside [${start}, ${end}]`, + ); + assert.ok( + progress < INSTALL_PROGRESS_CAP, + `stage ${stage} must not reach the completion cap on drift alone`, + ); + } +}); + +test('preserves Host stage progress when byte counts are unavailable', () => { + assert.equal( + computeStageProgress({ + stage: InstallStage.DOWNLOADING, + reportedProgress: 23, + stageElapsedSeconds: 0, + }), + 23, + ); + assert.equal( + computeStageProgress({ + stage: InstallStage.INSTALLING_DEPS, + reportedProgress: 64, + stageElapsedSeconds: 0, + }), + 64, + ); + assert.equal( + mapActionToStage('checking plugin update'), + InstallStage.CHECKING, + ); + assert.equal( + mapActionToStage('validating plugin package'), + InstallStage.VALIDATING, + ); + assert.equal( + mapActionToStage('applying plugin update'), + InstallStage.INSTALLING_DEPS, + ); + assert.equal( + mapActionToStage('refreshing plugin components'), + InstallStage.LAUNCHING, + ); + assert.equal(mapActionToStage('plugin updated'), InstallStage.DONE); +}); + +test('measured bytes override Host coarse progress and fallback stays bounded', () => { + assert.equal( + computeStageProgress({ + stage: InstallStage.DOWNLOADING, + downloadCurrent: 90, + downloadTotal: 100, + reportedProgress: 15, + stageElapsedSeconds: 40, + }), + 41, + ); + for (const reportedProgress of [-5, 100]) { + const progress = computeStageProgress({ + stage: InstallStage.DOWNLOADING, + reportedProgress, + stageElapsedSeconds: 0, + }); + assert.ok(progress >= 5 && progress <= 45); + } +}); + +test('a missing or zero download total falls back to bounded drift', () => { + const [start, end] = STAGE_PROGRESS_RANGE[InstallStage.DOWNLOADING]; + + const progress = computeStageProgress({ + stage: InstallStage.DOWNLOADING, + downloadCurrent: 10, + downloadTotal: 0, + stageElapsedSeconds: 100000, + }); + + assert.ok( + progress >= start && progress <= end, + `expected progress within [${start}, ${end}], received ${progress}`, + ); +}); diff --git a/web/tests/unit/knowledge-engine-marketplace.test.mjs b/web/tests/unit/knowledge-engine-marketplace.test.mjs index b01bc947a..a803ba3c3 100644 --- a/web/tests/unit/knowledge-engine-marketplace.test.mjs +++ b/web/tests/unit/knowledge-engine-marketplace.test.mjs @@ -71,10 +71,11 @@ test('offers KnowledgeEngine marketplace plugins inside the selector', () => { test('tracks plugin upgrades as recoverable multistep async tasks', () => { assert.match(taskContextSource, /name\.startsWith\('plugin-upgrade-'\)/); assert.match(taskContextSource, /operation: PluginTaskOperation/); - assert.match(taskContextSource, /progress_percent/); + assert.match(taskContextSource, /computeStageProgress/); + assert.match(taskContextSource, /INSTALL_PROGRESS_CAP/); assert.match(progressDialogSource, /InstallStage\.CHECKING/); assert.match(progressDialogSource, /InstallStage\.VALIDATING/); - assert.match(progressDialogSource, /InstallStage\.ACTIVATING/); + assert.match(progressDialogSource, /InstallStage\.LAUNCHING/); assert.match(progressDialogSource, /plugins\.installProgress\.updateTitle/); for (const source of [installedPluginsSource, homeSidebarSource]) { assert.match( diff --git a/web/tests/unit/marketplace-installed-state.test.mjs b/web/tests/unit/marketplace-installed-state.test.mjs new file mode 100644 index 000000000..cb3695f39 --- /dev/null +++ b/web/tests/unit/marketplace-installed-state.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const sourcePath = path.resolve( + currentDirectory, + '../../src/app/home/plugins/components/plugin-market/marketplace-installed.ts', +); +const source = fs.readFileSync(sourcePath, 'utf8'); +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, +}).outputText; +const sourceRequire = createRequire(sourcePath); +const loadedModule = { exports: {} }; +new Function('require', 'module', 'exports', compiled)( + sourceRequire, + loadedModule, + loadedModule.exports, +); + +const { buildInstalledIndex, resolveInstalledState, installedExtensionKey } = + loadedModule.exports; + +test('matches installed plugins by author and name', () => { + const index = buildInstalledIndex( + [{ id: 'alice/review', hasUpdate: true }], + [], + [], + ); + + assert.deepEqual( + resolveInstalledState(index, { + type: 'plugin', + author: 'alice', + pluginName: 'review', + }), + { installed: true, hasUpdate: true }, + ); + assert.deepEqual( + resolveInstalledState(index, { + type: 'plugin', + author: 'bob', + pluginName: 'review', + }), + { installed: false, hasUpdate: false }, + 'a different publisher must not match', + ); +}); + +test('normalises MCP servers from `author__name` to `author/name`', () => { + const index = buildInstalledIndex([], [{ id: 'acme__search' }], []); + + assert.equal( + resolveInstalledState(index, { + type: 'mcp', + author: 'acme', + pluginName: 'search', + }).installed, + true, + ); + assert.equal( + resolveInstalledState(index, { + type: 'mcp', + author: 'other', + pluginName: 'search', + }).installed, + false, + ); +}); + +test('does not mark skills installed from a bare name', () => { + // Two publishers ship a skill that both install as the plain name + // `review`; the sidebar records no publisher for either. + const index = buildInstalledIndex([], [], [{ id: 'review' }]); + + const alice = resolveInstalledState(index, { + type: 'skill', + author: 'alice', + pluginName: 'review', + }); + const bob = resolveInstalledState(index, { + type: 'skill', + author: 'bob', + pluginName: 'review', + }); + + assert.equal( + alice.installed, + false, + 'alice/review must not be reported installed from a bare skill name', + ); + assert.equal( + bob.installed, + false, + 'bob/review must not be reported installed from a bare skill name', + ); +}); + +test('keeps extension kinds separate for identical identities', () => { + const index = buildInstalledIndex([{ id: 'alice/toolkit' }], [], []); + + assert.equal( + resolveInstalledState(index, { + type: 'mcp', + author: 'alice', + pluginName: 'toolkit', + }).installed, + false, + 'a plugin must not mark the same-named MCP server as installed', + ); + assert.equal( + installedExtensionKey(undefined, 'alice', 'toolkit'), + 'plugin:alice/toolkit', + 'a missing type must default to plugin', + ); +});