mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-28 04:16:50 +08:00
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:
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+8
-50
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+46
-114
@@ -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 };
|
||||
}
|
||||
+79
-25
@@ -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>
|
||||
|
||||
+8
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '安装失败,请稍后重试',
|
||||
|
||||
@@ -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: '安裝失敗,請稍後重試',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user