Merge pull request #2560 from langbot-app/fix/411-runner-live-acceptance

fix(4.11): merge master and repair PostgreSQL Runner execution
This commit is contained in:
RockChinQ
2026-09-22 01:36:27 +08:00
committed by GitHub
34 changed files with 1253 additions and 250 deletions
+1
View File
@@ -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.
@@ -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
@@ -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),
@@ -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
@@ -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)
+19 -2
View File
@@ -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(
@@ -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
@@ -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,
}
@@ -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 (
<>
<span>{parts.join(' · ')}</span>
<br />
<span className="opacity-70 break-words">{currentDep}</span>
</>
);
}
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;
}
@@ -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: '',
};
@@ -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<string, React.ElementType> = {
[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
@@ -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, [number, number]> = {
[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);
}
@@ -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<
</div>
) : filteredExtensions.length === 0 ? (
<div className="flex flex-col items-center justify-center text-muted-foreground min-h-[60vh] w-full gap-2">
<Puzzle className="h-[3rem] w-[3rem]" />
<div className="text-lg mb-2">
{t('plugins.noExtensionInstalled')}
</div>
{normalizedQuery ? (
<>
<Search className="h-[3rem] w-[3rem]" />
<div className="text-lg mb-2">
{t('plugins.noMatchingExtensions', {
query: searchQuery.trim(),
})}
</div>
<Button variant="outline" size="sm" onClick={clearSearch}>
{t('common.clear')}
</Button>
</>
) : (
<>
<Puzzle className="h-[3rem] w-[3rem]" />
<div className="text-lg mb-2">
{t('plugins.noExtensionInstalled')}
</div>
</>
)}
</div>
) : showGrouped ? (
<div className="flex flex-col gap-4 pb-4">
@@ -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<PluginMarketCardVO[]>([]);
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}
/>
)}
@@ -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<string, InstalledExtensionEntry>,
): 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<string, string>;
@@ -64,6 +80,7 @@ function RecommendationListRow({
installDisabled?: boolean;
installDisabledTooltip?: string;
isLast: boolean;
installedIndex?: Map<string, InstalledExtensionEntry>;
}) {
const { t } = useTranslation();
const [page, setPage] = useState(0);
@@ -264,7 +281,7 @@ function RecommendationListRow({
{visiblePlugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.author + ' / ' + plugin.name}
cardVO={pluginToVO(plugin, t)}
cardVO={pluginToVO(plugin, t, installedIndex)}
tagNames={tagNames}
onInstall={onInstall}
installDisabled={installDisabled}
@@ -285,12 +302,14 @@ export function RecommendationLists({
onInstall,
installDisabled,
installDisabledTooltip,
installedIndex,
}: {
lists: RecommendationList[];
tagNames: Record<string, string>;
onInstall: (cardVO: PluginMarketCardVO) => void;
installDisabled?: boolean;
installDisabledTooltip?: string;
installedIndex?: Map<string, InstalledExtensionEntry>;
}) {
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}
/>
))}
<div className="border-b border-border mb-6" />
@@ -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<string, InstalledExtensionEntry> {
const index = new Map<string, InstalledExtensionEntry>();
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<string, InstalledExtensionEntry>,
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 };
}
@@ -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 ? (
<PluginComponentList
components={cardVO.components}
showComponentName={false}
showTitle={false}
useBadge={true}
t={t}
responsive={false}
/>
) : null;
const cardContent = (
<div
role={installDisabled ? 'group' : 'button'}
tabIndex={0}
aria-disabled={installDisabled}
aria-label={t('market.installCard', { name: cardVO.label })}
aria-label={
isInstalled
? t('market.installedCard', { name: cardVO.label })
: t('market.installCard', { name: cardVO.label })
}
className={`w-[100%] h-[10rem] bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] transition-shadow duration-200 outline-none dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] relative ${
installDisabled
? 'cursor-not-allowed opacity-60'
@@ -321,20 +352,50 @@ export default function PluginMarketCardComponent({
>
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
{showInstalledMark ? (
// Hollow green ring enclosing a green check: the download
// affordance becomes an "installed" mark in place.
<span
title={t('market.installed')}
aria-label={t('market.installed')}
className="flex h-4 w-4 sm:h-[1.2rem] sm:w-[1.2rem] flex-shrink-0 items-center justify-center rounded-full border-2 border-green-500 dark:border-green-400"
>
<Check
className="h-2 w-2 text-green-500 dark:text-green-400 sm:h-2.5 sm:w-2.5"
strokeWidth={3.5}
/>
</span>
) : (
<svg
className={`w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] flex-shrink-0 ${
hasUpdate
? 'text-amber-500 dark:text-amber-400'
: 'text-[#2563eb] dark:text-[#5b8def]'
}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)}
<div
title={hasUpdate ? t('market.updateAvailable') : undefined}
className={`text-xs sm:text-sm font-medium whitespace-nowrap ${
showInstalledMark
? 'text-green-600 dark:text-green-400'
: hasUpdate
? 'text-amber-600 dark:text-amber-400'
: 'text-[#2563eb] dark:text-[#5b8def]'
}`}
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
{cardVO.installCount?.toLocaleString() ?? '0'}
{showInstalledMark
? t('market.installed')
: (cardVO.installCount?.toLocaleString() ?? '0')}
</div>
</div>
@@ -376,18 +437,11 @@ export default function PluginMarketCardComponent({
)}
</div>
{cardVO.components && Object.keys(cardVO.components).length > 0 && (
{bottomTrailing ? (
<div className="flex flex-row items-center gap-1 flex-shrink-0">
<PluginComponentList
components={cardVO.components}
showComponentName={false}
showTitle={false}
useBadge={true}
t={t}
responsive={false}
/>
{bottomTrailing}
</div>
)}
) : null}
</div>
</div>
</div>
@@ -12,6 +12,10 @@ export interface IPluginMarketCardVO {
components?: Record<string, number>;
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<string, number>;
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;
}
}
@@ -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],
);
}
+27 -1
View File
@@ -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<FilterType>('all');
const [installedSearchQuery, setInstalledSearchQuery] = useState('');
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
useEffect(() => {
@@ -188,6 +189,27 @@ function PluginListView() {
</Tabs>
</div>
<div className="flex flex-row items-center gap-2 flex-wrap">
{/* Search installed extensions by label / name / author / description */}
<div className="relative w-full sm:w-56">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={installedSearchQuery}
onChange={(e) => setInstalledSearchQuery(e.target.value)}
placeholder={t('plugins.searchInstalled')}
aria-label={t('plugins.searchInstalled')}
className="pl-9 pr-8 text-sm"
/>
{installedSearchQuery && (
<button
type="button"
aria-label={t('common.clear')}
onClick={() => setInstalledSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-2 px-1 sm:px-2">
<Switch
id="group-by-type"
@@ -236,6 +258,7 @@ function PluginListView() {
<Input
value={debugInfo?.debug_url || ''}
readOnly
aria-label={t('plugins.debugUrl')}
className="flex-1 min-w-0 font-mono text-xs h-8"
/>
<Button
@@ -264,6 +287,7 @@ function PluginListView() {
debugInfo?.plugin_debug_key || t('plugins.noDebugKey')
}
readOnly
aria-label={t('plugins.debugKey')}
className="w-[220px] font-mono text-xs h-8"
/>
<Button
@@ -309,6 +333,8 @@ function PluginListView() {
ref={pluginInstalledRef}
filterType={filterType}
groupByType={groupByType}
searchQuery={installedSearchQuery}
onClearSearch={() => setInstalledSearchQuery('')}
/>
</div>
</div>
+2 -1
View File
@@ -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;
}
+3 -2
View File
@@ -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, string> = {
[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',
};
+6
View File
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -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: '安装失败,请稍后重试',
+6
View File
@@ -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: '安裝失敗,請稍後重試',
+2 -2
View File
@@ -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',
);
});
+190
View File
@@ -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}`,
);
});
@@ -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(
@@ -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',
);
});