Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
099f5ae7c0 | ||
|
|
2735d8693a | ||
|
|
47bbfb4b27 | ||
|
|
3f169c9951 | ||
|
|
cb97aa5822 | ||
|
|
48a5d70026 | ||
|
|
b21c929728 | ||
|
|
b9b3c587ff | ||
|
|
5a5650fc94 | ||
|
|
0e149b6c9c | ||
|
|
0dd2bcd6b1 | ||
|
|
567a8b349d | ||
|
|
d89afb3887 |
No files matched your search
@@ -253,6 +253,11 @@ class HealthResponse(BaseModel):
|
||||
# is answering. Reads fall back to exact LIKE while this is not ready.
|
||||
content_candidate_backfill: Literal["idle", "running", "ready", "failed"] = "idle"
|
||||
content_candidate_backfill_failure: str | None = None
|
||||
# Picking up work an earlier run left unfinished starts after readiness.
|
||||
# "running" is not degraded: Core is ready and answers throughout; the
|
||||
# failed steps are named so a stuck recovery is visible without a log.
|
||||
recovery: Literal["pending", "running", "done", "failed"] = "done"
|
||||
recovery_failed_steps: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class BackupRequest(BaseModel):
|
||||
@@ -685,28 +690,8 @@ def create_app(
|
||||
# Only the maintenance Core (restore) may open such a home.
|
||||
raise SdkMigrationRequired()
|
||||
if app.state.memory_authority is MemoryAuthority.SDK:
|
||||
from one_memory.hy_sdk_migration import (
|
||||
repair_sdk_metadata_from_bundle,
|
||||
)
|
||||
|
||||
try:
|
||||
repair = await asyncio.to_thread(
|
||||
repair_sdk_metadata_from_bundle,
|
||||
data_dir=runtime_settings.data_dir,
|
||||
database_path=database_path,
|
||||
)
|
||||
if repair.vector_rows or repair.coding_rows:
|
||||
logger.info(
|
||||
"sdk_metadata_repair_completed mapped=%d vector=%d coding=%d",
|
||||
repair.mapped,
|
||||
repair.vector_rows,
|
||||
repair.coding_rows,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"sdk_metadata_repair_failed error_type=%s",
|
||||
type(error).__name__,
|
||||
)
|
||||
# The metadata repair after a cutover reads every bundle: it is
|
||||
# recovery work, so it runs after readiness (`recover_after_ready`).
|
||||
app.state.memory_governance_store = RetiredGovernanceStore(database_path)
|
||||
app.state.evidence_store = RetiredEvidenceStore(database_path)
|
||||
else:
|
||||
@@ -921,8 +906,6 @@ def create_app(
|
||||
extraction_settings=app.state.extraction_settings_store,
|
||||
data_dir=runtime_settings.data_dir,
|
||||
)
|
||||
if app.state.memory_authority is MemoryAuthority.SDK:
|
||||
app.state.sdk_memory_runtime.start_raw_retention()
|
||||
# C2 owns the single production budget (handle 5s + reserve 250ms,
|
||||
# total 5.25s, lease 10s, renew 1s, claim 250ms). The composition
|
||||
# root only consumes it: no local formula, and the 250ms claim
|
||||
@@ -1012,11 +995,9 @@ def create_app(
|
||||
source_claim_enabled=lambda source_id: _sedimentation_claim_enabled(app, source_id),
|
||||
)
|
||||
app.state.memory_write_drain = memory_write_drain
|
||||
app.state.memory_write_drain_task = watched_task(
|
||||
asyncio.get_running_loop(),
|
||||
lambda: memory_write_drain.run_forever(),
|
||||
name="one-memory-durable-write-drain",
|
||||
)
|
||||
# Started by `recover_after_ready`: a running drain claims jobs and
|
||||
# holds the single writer, which the rest of startup still needs.
|
||||
app.state.memory_write_drain_task = None
|
||||
# D52: every entry that already holds committed Evidence goes
|
||||
# through the one durable queue. The intake turns one record into
|
||||
# one complete-turn request; the drain stays the only consumer, so
|
||||
@@ -1089,60 +1070,6 @@ def create_app(
|
||||
sdk_authority=lambda: app.state.memory_authority is MemoryAuthority.SDK,
|
||||
)
|
||||
app.state.import_job_store.replace_never_published_snapshot_failures()
|
||||
# A file's Evidence and its pending-dispatch row commit atomically,
|
||||
# but the callback that turns them into a task runs after the
|
||||
# transaction. A crash in that window leaves durable Evidence with
|
||||
# no in-memory timer left to notice it; drain those rows once on
|
||||
# startup through the same permission/debounce path as live work.
|
||||
if (
|
||||
runtime_settings.automatic_memory_processing
|
||||
and app.state.memory_authority is MemoryAuthority.LEGACY
|
||||
):
|
||||
# Two-phase, like the live runner: enumerate the file
|
||||
# identities, read each without consuming its pending marker,
|
||||
# await the durable submit, and only then consume. A crash or
|
||||
# a failed/cancelled submit leaves the marker for the next
|
||||
# startup instead of dropping the committed Evidence. The
|
||||
# legacy `take_all_stable_records()` consumes while reading and
|
||||
# is deliberately no longer the startup path.
|
||||
for user_id, source_id, file_key in await asyncio.to_thread(
|
||||
app.state.evidence_store.list_pending_dispatch_files
|
||||
):
|
||||
try:
|
||||
for dispatch in await asyncio.to_thread(
|
||||
app.state.evidence_store.take_stable_file_dispatches,
|
||||
user_id=user_id,
|
||||
source_id=source_id,
|
||||
file_key=file_key,
|
||||
):
|
||||
# Same intake as the live callback, so the crash
|
||||
# window between Evidence commit and durable submit
|
||||
# is closed by the same idempotent path rather than
|
||||
# a second one.
|
||||
token = live_sync_arrival.set(dispatch.priority == "live")
|
||||
try:
|
||||
await enqueue_stable_evidence(dispatch.record)
|
||||
finally:
|
||||
live_sync_arrival.reset(token)
|
||||
except Exception:
|
||||
# The submit did not succeed, so the pending marker is
|
||||
# deliberately left in place: the next startup will
|
||||
# replay this file. Startup continues for the other
|
||||
# files instead of failing the whole composition root.
|
||||
logger.warning(
|
||||
"stable_evidence_startup_replay_deferred source=%s file=%s",
|
||||
source_id,
|
||||
file_key,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
await asyncio.to_thread(
|
||||
app.state.evidence_store.consume_stable_file_records,
|
||||
user_id=user_id,
|
||||
source_id=source_id,
|
||||
file_key=file_key,
|
||||
)
|
||||
app.state.import_supervisor.resume_interrupted()
|
||||
app.state.source_connection_store = SourceConnectionStore(
|
||||
app.state.source_grant_store,
|
||||
app.state.import_job_store,
|
||||
@@ -1185,11 +1112,6 @@ def create_app(
|
||||
app.state.live_sync_supervisor.set_watched_sources(
|
||||
watch_roots if watching and runtime_settings.live_sync_enabled else ()
|
||||
)
|
||||
if runtime_settings.live_sync_enabled:
|
||||
app.state.live_sync_supervisor.start()
|
||||
if app.state.live_sync_watch is not None:
|
||||
app.state.live_sync_watch.start(prime=True)
|
||||
await _reconcile_source_sync_from_config()
|
||||
# Taking up the memory work again — the tasks left unfinished, then
|
||||
# the conversations with no memory yet — reads every conversation
|
||||
# ever imported: 13.5 seconds on this machine's real database, and
|
||||
@@ -1203,9 +1125,6 @@ def create_app(
|
||||
warm_bulk_pool()
|
||||
app.state.memory_backfill = None
|
||||
app.state.memory_visibility_task = None
|
||||
if app.state.memory_authority is MemoryAuthority.LEGACY:
|
||||
_start_backfill(app)
|
||||
_start_visibility_recompute(app)
|
||||
# Both times the loop was blocked here it was found from outside,
|
||||
# by hand, days later. This says so at the time, and says what was
|
||||
# holding it.
|
||||
@@ -1224,15 +1143,182 @@ def create_app(
|
||||
app.state.resource_diagnostics = watched_task(
|
||||
asyncio.get_running_loop(), _emit_resource_snapshots
|
||||
)
|
||||
# Readiness is "Core answers": the stores above are open and
|
||||
# /health, recall and the library work. Everything below picks up
|
||||
# work an earlier run left unfinished -- the post-cutover metadata
|
||||
# repair, the durable write queue, interrupted imports, live sync,
|
||||
# source reconcile. On a library with pending work that took longer
|
||||
# than the Runtime's readiness budget, so the Runtime killed a
|
||||
# healthy Core and the next one began the same recovery again. It
|
||||
# now runs only after readiness, one step at a time, and a failed
|
||||
# step is reported without taking readiness or the other steps
|
||||
# with it.
|
||||
app.state.recovery = "pending"
|
||||
app.state.recovery_failed_steps = ()
|
||||
|
||||
async def recover_after_ready() -> None:
|
||||
app.state.recovery = "running"
|
||||
recovery_started = time.monotonic()
|
||||
failed: list[str] = []
|
||||
|
||||
async def step(name: str, action: Callable[[], Awaitable[None]]) -> None:
|
||||
step_started = time.monotonic()
|
||||
try:
|
||||
await action()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error:
|
||||
failed.append(name)
|
||||
app.state.recovery_failed_steps = tuple(failed)
|
||||
logger.warning(
|
||||
"core_recovery_step_failed step=%s error_type=%s",
|
||||
name,
|
||||
type(error).__name__,
|
||||
)
|
||||
diagnostic_event(
|
||||
level="warn",
|
||||
component="core.recovery",
|
||||
event="core.recovery_step_failed",
|
||||
outcome="failed",
|
||||
error_type=type(error).__name__,
|
||||
duration_ms=elapsed_ms(step_started),
|
||||
details={"phase": name},
|
||||
)
|
||||
|
||||
async def repair_sdk_metadata() -> None:
|
||||
from one_memory.hy_sdk_migration import (
|
||||
repair_sdk_metadata_from_bundle,
|
||||
)
|
||||
|
||||
repair = await asyncio.to_thread(
|
||||
repair_sdk_metadata_from_bundle,
|
||||
data_dir=runtime_settings.data_dir,
|
||||
database_path=database_path,
|
||||
)
|
||||
if repair.vector_rows or repair.coding_rows:
|
||||
logger.info(
|
||||
"sdk_metadata_repair_completed mapped=%d vector=%d coding=%d",
|
||||
repair.mapped,
|
||||
repair.vector_rows,
|
||||
repair.coding_rows,
|
||||
)
|
||||
|
||||
async def start_write_drain() -> None:
|
||||
app.state.memory_write_drain_task = watched_task(
|
||||
asyncio.get_running_loop(),
|
||||
lambda: memory_write_drain.run_forever(),
|
||||
name="one-memory-durable-write-drain",
|
||||
)
|
||||
|
||||
async def start_raw_retention() -> None:
|
||||
app.state.sdk_memory_runtime.start_raw_retention()
|
||||
|
||||
async def replay_pending_dispatch() -> None:
|
||||
# A file's Evidence and its pending-dispatch row commit atomically,
|
||||
# but the callback that turns them into a task runs after the
|
||||
# transaction. A crash in that window leaves durable Evidence with
|
||||
# no in-memory timer left to notice it; drain those rows once on
|
||||
# startup through the same permission/debounce path as live work.
|
||||
if (
|
||||
runtime_settings.automatic_memory_processing
|
||||
and app.state.memory_authority is MemoryAuthority.LEGACY
|
||||
):
|
||||
# Two-phase, like the live runner: enumerate the file
|
||||
# identities, read each without consuming its pending marker,
|
||||
# await the durable submit, and only then consume. A crash or
|
||||
# a failed/cancelled submit leaves the marker for the next
|
||||
# startup instead of dropping the committed Evidence. The
|
||||
# legacy `take_all_stable_records()` consumes while reading and
|
||||
# is deliberately no longer the startup path.
|
||||
for user_id, source_id, file_key in await asyncio.to_thread(
|
||||
app.state.evidence_store.list_pending_dispatch_files
|
||||
):
|
||||
try:
|
||||
for dispatch in await asyncio.to_thread(
|
||||
app.state.evidence_store.take_stable_file_dispatches,
|
||||
user_id=user_id,
|
||||
source_id=source_id,
|
||||
file_key=file_key,
|
||||
):
|
||||
# Same intake as the live callback, so the crash
|
||||
# window between Evidence commit and durable submit
|
||||
# is closed by the same idempotent path rather than
|
||||
# a second one.
|
||||
token = live_sync_arrival.set(dispatch.priority == "live")
|
||||
try:
|
||||
await enqueue_stable_evidence(dispatch.record)
|
||||
finally:
|
||||
live_sync_arrival.reset(token)
|
||||
except Exception:
|
||||
# The submit did not succeed, so the pending marker is
|
||||
# deliberately left in place: the next startup will
|
||||
# replay this file. Startup continues for the other
|
||||
# files instead of failing the whole composition root.
|
||||
logger.warning(
|
||||
"stable_evidence_startup_replay_deferred source=%s file=%s",
|
||||
source_id,
|
||||
file_key,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
await asyncio.to_thread(
|
||||
app.state.evidence_store.consume_stable_file_records,
|
||||
user_id=user_id,
|
||||
source_id=source_id,
|
||||
file_key=file_key,
|
||||
)
|
||||
|
||||
async def resume_imports() -> None:
|
||||
app.state.import_supervisor.resume_interrupted()
|
||||
|
||||
async def start_live_sync() -> None:
|
||||
if runtime_settings.live_sync_enabled:
|
||||
app.state.live_sync_supervisor.start()
|
||||
if app.state.live_sync_watch is not None:
|
||||
app.state.live_sync_watch.start(prime=True)
|
||||
|
||||
async def start_legacy_catch_up() -> None:
|
||||
_start_backfill(app)
|
||||
_start_visibility_recompute(app)
|
||||
|
||||
# Yield once first so the HTTP server finishes binding before
|
||||
# any of this asks the loop for time.
|
||||
await asyncio.sleep(0)
|
||||
try:
|
||||
sdk = app.state.memory_authority is MemoryAuthority.SDK
|
||||
if sdk:
|
||||
# Before the drain: the repair restores metadata the
|
||||
# queued writes are read back against.
|
||||
await step("sdk_metadata_repair", repair_sdk_metadata)
|
||||
await step("write_drain", start_write_drain)
|
||||
if sdk:
|
||||
await step("raw_retention", start_raw_retention)
|
||||
else:
|
||||
await step("pending_dispatch_replay", replay_pending_dispatch)
|
||||
await step("import_resume", resume_imports)
|
||||
await step("live_sync", start_live_sync)
|
||||
await step("source_reconcile", _reconcile_source_sync_from_config)
|
||||
if not sdk:
|
||||
await step("legacy_catch_up", start_legacy_catch_up)
|
||||
finally:
|
||||
# Startup writes are done. Only now let the derived-content
|
||||
# rebuild take the single write checkout: it is a long
|
||||
# background job, and on a large library it would otherwise
|
||||
# hold the writer while the recovery above still needs it
|
||||
# (ISSUE-CONTENT-BACKFILL-BLOCKS-STARTUP).
|
||||
app.state.memory_governance_store.release_content_backfill_startup_gate()
|
||||
app.state.recovery = "failed" if failed else "done"
|
||||
diagnostic_event(
|
||||
level="warn" if failed else "info",
|
||||
component="core.recovery",
|
||||
event="core.recovery_finished",
|
||||
outcome="failed" if failed else "succeeded",
|
||||
duration_ms=elapsed_ms(recovery_started),
|
||||
details={"count": len(failed)},
|
||||
)
|
||||
|
||||
app.state.recovery_task = None
|
||||
try:
|
||||
# Startup writes are done. Only now let the derived-content
|
||||
# rebuild take the single write checkout: it is a long
|
||||
# background job, and on a large library it would otherwise
|
||||
# hold the writer while the store initializations above still
|
||||
# need it, so startup would wait out the write budget and fail
|
||||
# with `database is locked`
|
||||
# (ISSUE-CONTENT-BACKFILL-BLOCKS-STARTUP).
|
||||
app.state.memory_governance_store.release_content_backfill_startup_gate()
|
||||
diagnostic_event(
|
||||
level="info",
|
||||
component="core.lifecycle",
|
||||
@@ -1241,6 +1327,11 @@ def create_app(
|
||||
instance_id=runtime_settings.instance_id,
|
||||
duration_ms=elapsed_ms(lifecycle_started),
|
||||
)
|
||||
app.state.recovery_task = watched_task(
|
||||
asyncio.get_running_loop(),
|
||||
recover_after_ready,
|
||||
name="one-memory-post-ready-recovery",
|
||||
)
|
||||
yield
|
||||
finally:
|
||||
shutdown_started = time.monotonic()
|
||||
@@ -1251,6 +1342,14 @@ def create_app(
|
||||
outcome="started",
|
||||
instance_id=runtime_settings.instance_id,
|
||||
)
|
||||
# First: recovery may be about to start what is stopped below.
|
||||
if app.state.recovery_task is not None:
|
||||
app.state.recovery_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await app.state.recovery_task
|
||||
# Cancelled before it got that far, recovery never let the
|
||||
# rebuild go; a held rebuild would hold shutdown below too.
|
||||
app.state.memory_governance_store.release_content_backfill_startup_gate()
|
||||
app.state.main_database_report.cancel()
|
||||
app.state.loop_heartbeat.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
@@ -1278,8 +1377,9 @@ def create_app(
|
||||
await app.state.live_sync_supervisor.shutdown()
|
||||
await app.state.import_supervisor.shutdown()
|
||||
app.state.memory_write_drain.stop()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await app.state.memory_write_drain_task
|
||||
if app.state.memory_write_drain_task is not None:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await app.state.memory_write_drain_task
|
||||
await app.state.sdk_memory_runtime.shutdown()
|
||||
await app.state.sdk_write_coordinator.close()
|
||||
if app.state.sdk_client_handle is not None:
|
||||
@@ -1497,6 +1597,8 @@ def create_app(
|
||||
content_candidate_backfill_failure=(
|
||||
governance.content_candidate_backfill_failure() if governance is not None else None
|
||||
),
|
||||
recovery=getattr(app.state, "recovery", "pending"),
|
||||
recovery_failed_steps=getattr(app.state, "recovery_failed_steps", ()),
|
||||
)
|
||||
|
||||
@app.get("/health", response_model=HealthResponse, include_in_schema=False)
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
| `test_conversation_search.py` | `integration` | Searching imported conversations before any generation model is configured. |
|
||||
| `test_conversation_seed_ids.py` | `contract` | 覆盖 seed ids read metadata only without hydrating、seed eligibility keeps person and memory content only、latest activity includes nonseed turns and import time fallback 等 13 个场景。 |
|
||||
| `test_conversation_turn_grouping.py` | `contract` | A proposal and the turn that adopts it must reach HY as one turn. |
|
||||
| `test_core_ready_before_recovery.py` | `integration` | Readiness never waits for picking up work an earlier run left unfinished. |
|
||||
| `test_core_test_reporting.py` | `contract` | 覆盖 report writes sanitized machine readable views、file and marker aggregates are stable、classification suggestions never guess unit contract or integration 等 6 个场景。 |
|
||||
| `test_credential_responsiveness.py` | `integration` | 覆盖 credentials do not block the event loop、disabled automatic processing does not digest onboarding、answering reads credentials off loop 等 5 个场景。 |
|
||||
| `test_cursor_default_root_windows.py` | `contract` | R-29 剩余:Cursor 默认根路径不能写死成 macOS。 |
|
||||
@@ -302,4 +303,4 @@
|
||||
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
|
||||
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
|
||||
|
||||
共 297 个测试文件。
|
||||
共 298 个测试文件。
|
||||
@@ -14,8 +14,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
||||
EXPECTED_TEST_FILE_COUNT = 297
|
||||
EXPECTED_TEST_FILES_SHA256 = "50fc934916818220efdd6259dafb8831c2e568a19fc63e2ea6a9b865d1a75c90"
|
||||
EXPECTED_TEST_FILE_COUNT = 298
|
||||
EXPECTED_TEST_FILES_SHA256 = "3b2bece1e20fc1db0e8a82db65bb2cd189317ca34922c97ae8849d38283761e8"
|
||||
|
||||
# Pure transformations, validation models, parity checks and state machines.
|
||||
# This list is intentionally small: uncertain files default to contract.
|
||||
@@ -51,6 +51,7 @@ INTEGRATION_FILES = frozenset(
|
||||
{
|
||||
"test_auth.py",
|
||||
"test_backup_endpoint.py",
|
||||
"test_core_ready_before_recovery.py",
|
||||
"test_backup_restore_api.py",
|
||||
"test_c1_import_via_api_path.py",
|
||||
"test_capture_deadline.py",
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Readiness never waits for picking up work an earlier run left unfinished.
|
||||
|
||||
A 1.0.4 home with pending imports and write jobs used to resume all of it
|
||||
before the lifespan yielded, so /health did not answer within the Runtime's
|
||||
60s budget and the Runtime killed a healthy Core, which then began the same
|
||||
recovery again. Recovery now runs after readiness.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from one_memory import hy_sdk_migration
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.hy_sdk_migration import MetadataRepairResult
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
|
||||
pytestmark = pytest.mark.fresh_sdk
|
||||
|
||||
|
||||
def application(tmp_path):
|
||||
return create_app(
|
||||
Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=tmp_path,
|
||||
api_token=SecretStr("ready-test"),
|
||||
imports_enabled=False,
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
),
|
||||
secret_store=InMemorySecretStore(),
|
||||
)
|
||||
|
||||
|
||||
def client_for(app):
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer ready-test"},
|
||||
)
|
||||
|
||||
|
||||
async def health(client):
|
||||
response = await client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
async def wait_for_recovery(client, *, seconds=10.0):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
body = await health(client)
|
||||
if body["recovery"] in {"done", "failed"}:
|
||||
return body
|
||||
await asyncio.sleep(0.01)
|
||||
raise AssertionError("recovery did not finish")
|
||||
|
||||
|
||||
def test_core_is_ready_while_recovery_is_still_running(tmp_path, monkeypatch):
|
||||
release = threading.Event()
|
||||
entered = threading.Event()
|
||||
|
||||
def slow_repair(**_):
|
||||
entered.set()
|
||||
release.wait(10)
|
||||
return MetadataRepairResult(None, 0, 0, 0)
|
||||
|
||||
monkeypatch.setattr(hy_sdk_migration, "repair_sdk_metadata_from_bundle", slow_repair)
|
||||
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
try:
|
||||
started = time.monotonic()
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
# Startup finished although the first recovery step cannot.
|
||||
assert time.monotonic() - started < 5
|
||||
assert await asyncio.to_thread(entered.wait, 5)
|
||||
body = await health(client)
|
||||
assert body["status"] == "ok"
|
||||
assert body["recovery"] == "running"
|
||||
# Nothing after the blocked step has been started yet: the
|
||||
# write drain in particular would hold the single writer.
|
||||
assert app.state.memory_write_drain_task is None
|
||||
# Reads answer during recovery.
|
||||
assert (await client.get("/api/v1/sources")).status_code == 200
|
||||
release.set()
|
||||
body = await wait_for_recovery(client)
|
||||
assert body["recovery"] == "done"
|
||||
assert body["recovery_failed_steps"] == []
|
||||
assert app.state.memory_write_drain_task is not None
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_a_failed_recovery_step_does_not_stop_readiness_or_later_steps(tmp_path, monkeypatch):
|
||||
def broken_repair(**_):
|
||||
raise OSError("bundle unreadable")
|
||||
|
||||
monkeypatch.setattr(hy_sdk_migration, "repair_sdk_metadata_from_bundle", broken_repair)
|
||||
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
body = await wait_for_recovery(client)
|
||||
assert body["status"] == "ok"
|
||||
assert body["recovery"] == "failed"
|
||||
assert body["recovery_failed_steps"] == ["sdk_metadata_repair"]
|
||||
# The steps after the failed one still ran.
|
||||
assert app.state.memory_write_drain_task is not None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_shutdown_during_recovery_stops_it_without_starting_more(tmp_path, monkeypatch):
|
||||
release = threading.Event()
|
||||
entered = threading.Event()
|
||||
|
||||
def slow_repair(**_):
|
||||
entered.set()
|
||||
release.wait(10)
|
||||
return MetadataRepairResult(None, 0, 0, 0)
|
||||
|
||||
monkeypatch.setattr(hy_sdk_migration, "repair_sdk_metadata_from_bundle", slow_repair)
|
||||
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
try:
|
||||
async with app.router.lifespan_context(app):
|
||||
assert await asyncio.to_thread(entered.wait, 5)
|
||||
recovery = app.state.recovery_task
|
||||
# Shutdown cancelled recovery before it could start the drain.
|
||||
assert recovery.done()
|
||||
assert app.state.memory_write_drain_task is None
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -45,6 +45,9 @@ def test_health_reports_the_single_core() -> None:
|
||||
"memory_backfill_error": None,
|
||||
"content_candidate_backfill": "idle",
|
||||
"content_candidate_backfill_failure": None,
|
||||
# No lifespan ran, so recovery has not begun.
|
||||
"recovery": "pending",
|
||||
"recovery_failed_steps": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -210,6 +213,8 @@ def test_lifespan_drains_committed_pending_dispatch_through_enqueue(tmp_path, mo
|
||||
|
||||
async def scenario() -> None:
|
||||
async with app.router.lifespan_context(app):
|
||||
# Replayed by the recovery that runs after readiness.
|
||||
await app.state.recovery_task
|
||||
assert record.evidence_id in seen
|
||||
assert evidence.take_all_stable_records() == ()
|
||||
|
||||
@@ -434,6 +439,7 @@ def test_a_backfill_that_fails_says_so_rather_than_disappearing(tmp_path, monkey
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client,
|
||||
):
|
||||
await app.state.recovery_task
|
||||
await app.state.memory_backfill
|
||||
|
||||
response = await client.get("/api/v1/health")
|
||||
@@ -497,6 +503,7 @@ def test_a_failed_catch_up_leaves_a_traceback_behind(tmp_path, monkeypatch, capl
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client,
|
||||
):
|
||||
await app.state.recovery_task
|
||||
with suppress(BaseException):
|
||||
await app.state.memory_backfill
|
||||
return (await client.get("/api/v1/health")).json()
|
||||
|
||||
@@ -295,6 +295,8 @@ def test_startup_reconciles_enabled_source_from_config(tmp_path, monkeypatch):
|
||||
secret_store=InMemorySecretStore(),
|
||||
)
|
||||
async with restored.router.lifespan_context(restored):
|
||||
# The reconcile runs after readiness, in recovery.
|
||||
await restored.state.recovery_task
|
||||
history = [
|
||||
job
|
||||
for job in restored.state.import_job_store.list_jobs()
|
||||
|
||||
@@ -111,6 +111,7 @@ def test_large_import_status_does_not_scan_source_trees(tmp_path, monkeypatch):
|
||||
app, catalog = build_app(tmp_path, monkeypatch)
|
||||
seed_layouts(catalog)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
await app.state.recovery_task
|
||||
await app.state.memory_backfill
|
||||
await asyncio.to_thread(seed_evidence, app)
|
||||
probes = []
|
||||
|
||||
@@ -62,6 +62,8 @@ def test_the_switch_on_starts_it(library, monkeypatch):
|
||||
async def scenario() -> None:
|
||||
app = _app(library)
|
||||
async with app.router.lifespan_context(app):
|
||||
# Started by the recovery that runs after readiness.
|
||||
await app.state.recovery_task
|
||||
assert app.state.memory_visibility_task is not None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -234,9 +234,14 @@ describe("Core ownership", () => {
|
||||
it("attaches when a standalone Core wins the race with the first spawn", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "core-attach-race-"));
|
||||
let probes = 0;
|
||||
// The winner has finished publishing its receipt and token by the time
|
||||
// our child loses the lock. Writing them from inside the spawn raced the
|
||||
// readiness loop: on a slow runner the token was not there yet, the
|
||||
// re-read rejected the receipt, and the test saw a false conflict.
|
||||
let winner: Promise<void> = Promise.resolve();
|
||||
const spawnProcess = vi.fn(() => {
|
||||
const child = fakeChild();
|
||||
void writeRuntime(dataDir, {
|
||||
winner = writeRuntime(dataDir, {
|
||||
schema_version: 1,
|
||||
service: "one-memory-core",
|
||||
instance_id: "cli-race-winner",
|
||||
@@ -245,7 +250,9 @@ describe("Core ownership", () => {
|
||||
host: "127.0.0.1",
|
||||
port: 9236,
|
||||
started_at: "2026-09-19T01:00:00Z",
|
||||
}).then(() => child.emit("exit", 1, null));
|
||||
}).then(() => {
|
||||
child.emit("exit", 1, null);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
const supervisor = new CoreSupervisor({
|
||||
@@ -256,6 +263,10 @@ describe("Core ownership", () => {
|
||||
spawnProcess,
|
||||
probeHealth: async (port, token) => {
|
||||
probes += 1;
|
||||
// Our own child never answers: it lost the race. Waiting here for the
|
||||
// winner's files keeps the readiness loop from concluding before the
|
||||
// winner exists, which is the order this test is about.
|
||||
await winner;
|
||||
if (port !== 9236 || token !== "attached-secret")
|
||||
throw new Error("not attached yet");
|
||||
return {
|
||||
|
||||
@@ -199,7 +199,7 @@ it("localizes failure and empty states without leaking errors", async () => {
|
||||
).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain("synthetic_private_error");
|
||||
});
|
||||
it("shows latest recorded call separately from newly generated memory and opens its history", async () => {
|
||||
it("shows latest recorded call separately without exposing recent-update detail entries", async () => {
|
||||
history.mockResolvedValue({
|
||||
available: true,
|
||||
recording_error: false,
|
||||
@@ -233,12 +233,11 @@ it("shows latest recorded call separately from newly generated memory and opens
|
||||
await screen.findByText("Codex 查到 3 条相关内容");
|
||||
expect(document.querySelector(".r6-home-call-direction")).toBeTruthy();
|
||||
expect(screen.queryByText("实际治理记忆")).toBeNull();
|
||||
expect(screen.getByText(/今天 .+ · 查询成功/)).toBeTruthy();
|
||||
expect(screen.getByText(/Codex · 今天 .+ · 查询成功/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "最近更新" }));
|
||||
expect(screen.getByText("实际治理记忆")).toBeTruthy();
|
||||
const memoryActions = screen.getAllByRole("button", { name: "查看记忆" });
|
||||
fireEvent.click(memoryActions[memoryActions.length - 1]);
|
||||
expect(onMemories).toHaveBeenCalledWith("m1");
|
||||
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
|
||||
expect(onMemories).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "最近调用" }));
|
||||
expect(history).toHaveBeenCalledWith({ limit: 20, offset: 0 });
|
||||
if (CALL_HISTORY_ENTRY_VISIBLE) {
|
||||
@@ -247,11 +246,56 @@ it("shows latest recorded call separately from newly generated memory and opens
|
||||
} else {
|
||||
// D96: the recent call still shows on the home page; only the way into
|
||||
// the call-history page is hidden.
|
||||
expect(screen.getByText(/今天 .+ · 查询成功/)).toBeTruthy();
|
||||
expect(screen.getByText(/Codex · 今天 .+ · 查询成功/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "查看记录" })).toBeNull();
|
||||
expect(onCalls).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
it("skips direct diagnostics and shows the latest attributed application call", async () => {
|
||||
history.mockResolvedValue({
|
||||
available: true,
|
||||
recording_error: false,
|
||||
items: [
|
||||
{
|
||||
event_id: "direct-diagnostic",
|
||||
host_id: null,
|
||||
state: "grounded",
|
||||
occurred_at: new Date().toISOString(),
|
||||
memory_count: 1,
|
||||
raw_count: 0,
|
||||
references: [],
|
||||
},
|
||||
{
|
||||
event_id: "workbuddy-call",
|
||||
host_id: "workbuddy",
|
||||
state: "grounded",
|
||||
occurred_at: new Date(Date.now() - 60_000).toISOString(),
|
||||
memory_count: 5,
|
||||
raw_count: 0,
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
onCalls={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
|
||||
expect(await screen.findByText("WorkBuddy 查到 5 条相关内容")).toBeTruthy();
|
||||
expect(screen.queryByText(/直接调用/)).toBeNull();
|
||||
expect(screen.getByText(/WorkBuddy · 今天 .+ · 查询成功/)).toBeTruthy();
|
||||
const direction = document.querySelector(".r6-home-call-direction");
|
||||
expect(direction?.querySelector("img")?.getAttribute("src")).toContain(
|
||||
"/r6/workbuddy.png",
|
||||
);
|
||||
expect(direction?.querySelector(".r6-app-initial")).toBeNull();
|
||||
});
|
||||
it("shows only the host for memory hits whose references have no source", async () => {
|
||||
history.mockResolvedValue({
|
||||
available: true,
|
||||
@@ -834,10 +878,38 @@ it.each([null, "", "not-a-date"])(
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("实际治理记忆");
|
||||
expect(screen.getByText(/时间未知.*版本 2/)).toBeTruthy();
|
||||
expect(screen.getByText("时间未知 · App 0")).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows only time and source for an SDK recent update", async () => {
|
||||
summary.mockResolvedValue({
|
||||
memory_count: 1,
|
||||
latest: {
|
||||
memory_id: "sdk-memory-1",
|
||||
content: "SDK 原生记忆",
|
||||
layer: "l2_fact",
|
||||
source: "app-0",
|
||||
gmt_created: 1_761_000_000,
|
||||
},
|
||||
observed_at: "2026-09-20T09:00:00Z",
|
||||
});
|
||||
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText("SDK 原生记忆");
|
||||
expect(screen.getByText(/^\d{2}:\d{2} · App 0$/)).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain("SDK l2_fact");
|
||||
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat an available adapter as an installed application", async () => {
|
||||
render(
|
||||
<ApplicationsHome
|
||||
|
||||
@@ -83,13 +83,6 @@ function stamped(value: string, locale: GuiLocale, t: Translate, suffix = "") {
|
||||
return `${parts.today ? t("apps.today") : ""}${parts.clock}${suffix}`;
|
||||
}
|
||||
|
||||
function memoryAction(action: string | undefined, t: Translate) {
|
||||
if (action === "create") return t("apps.newMemory");
|
||||
if (action === "correct" || action === "restore")
|
||||
return t("apps.updatedMemory");
|
||||
return t("apps.saved");
|
||||
}
|
||||
|
||||
type ActivityChoice = "call" | "update";
|
||||
|
||||
function sourceMatchesHost(sourceId: string, hostId: string | null) {
|
||||
@@ -101,6 +94,10 @@ function successfulCall(item: CallEvent) {
|
||||
return item.state === "grounded" || item.state === "empty";
|
||||
}
|
||||
|
||||
function attributedSuccessfulCall(item: CallEvent) {
|
||||
return item.host_id !== null && successfulCall(item);
|
||||
}
|
||||
|
||||
function callAttention(events: CallEvent[]) {
|
||||
const hosts = new Set<string>();
|
||||
for (const event of events) {
|
||||
@@ -219,7 +216,10 @@ export function ApplicationsHome({
|
||||
const result = await getCallHistory({ limit: 20, offset: 0 });
|
||||
if (cancelled) return;
|
||||
setCallEvents(result.items);
|
||||
setRecentCall(result.items.find(successfulCall) ?? null);
|
||||
// The home card promises an application identity. Direct diagnostic
|
||||
// calls have no host and remain available in call history, but cannot
|
||||
// truthfully supply the icon and name required by this surface.
|
||||
setRecentCall(result.items.find(attributedSuccessfulCall) ?? null);
|
||||
setCallState(
|
||||
!result.available
|
||||
? "disabled"
|
||||
@@ -604,6 +604,9 @@ export function ApplicationsHome({
|
||||
const recentCallHost = imports.sources.find((source) =>
|
||||
sourceMatchesHost(source.source_id, recentCall?.host_id ?? null),
|
||||
);
|
||||
const recentCallHostName = recentCall
|
||||
? (recentCallHost?.display_name ?? callHostName(recentCall.host_id, locale))
|
||||
: null;
|
||||
const attention = callAttention(callEvents);
|
||||
const attentionHost = attention ? callHostName(attention.host, locale) : null;
|
||||
const activity = summaryUnavailable
|
||||
@@ -665,6 +668,11 @@ export function ApplicationsHome({
|
||||
: latest.source
|
||||
? [latest.source]
|
||||
: [];
|
||||
const latestSourceNames = latestSourceIds.map(
|
||||
(id) =>
|
||||
imports.sources.find((source) => source.source_id === id)?.display_name ??
|
||||
id,
|
||||
);
|
||||
const time = latestClock?.clock ?? (latest ? t("apps.unknownTime") : null);
|
||||
const checked = visible ? clockParts(visible.observed_at, locale) : null;
|
||||
// A failed read leaves the last health in place on purpose: clearing it would
|
||||
@@ -936,8 +944,7 @@ export function ApplicationsHome({
|
||||
: activity === "call" && recentCall
|
||||
? callDetailHeadline(
|
||||
recentCall,
|
||||
recentCallHost?.display_name ??
|
||||
callHostName(recentCall.host_id, locale),
|
||||
recentCallHostName ?? undefined,
|
||||
locale,
|
||||
)
|
||||
: activity === "memory" && latest
|
||||
@@ -958,19 +965,18 @@ export function ApplicationsHome({
|
||||
? t("apps.callReadFailure")
|
||||
: t("apps.memoryReadFailure")
|
||||
: activity === "call" && recentCall
|
||||
? `${stamped(recentCall.occurred_at, locale, t)} · ${callRowDetail(recentCall, locale)}${callState === "incomplete" ? t("apps.incompleteCalls") : ""}`
|
||||
? `${recentCallHostName} · ${stamped(recentCall.occurred_at, locale, t)} · ${callRowDetail(recentCall, locale)}${callState === "incomplete" ? t("apps.incompleteCalls") : ""}`
|
||||
: activity === "memory" && latest
|
||||
? "revision" in latest
|
||||
? `${time} · ${memoryAction(latest.action, t)} · ${latestSourceIds.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join(", ")} · ${t("apps.revision", { count: new Intl.NumberFormat(locale).format(latest.revision) })}`
|
||||
: `${time} · SDK ${latest.layer} · ${latestSourceIds.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join("、")}`
|
||||
? [time, latestSourceNames.join(", ")]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: activity === "importing"
|
||||
? t("apps.savingConversations")
|
||||
: t("apps.waitingMemories")}
|
||||
</small>
|
||||
</section>
|
||||
{(activity === "unavailable" ||
|
||||
(activity === "call" && CALL_HISTORY_ENTRY_VISIBLE) ||
|
||||
activity === "memory") && (
|
||||
(activity === "call" && CALL_HISTORY_ENTRY_VISIBLE)) && (
|
||||
<button
|
||||
type="button"
|
||||
className="r6-home-activity-link"
|
||||
|
||||
@@ -58,6 +58,40 @@ it.each(["zh-CN", "en", "es"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["system", "zh-CN", "en", "es"] as const)(
|
||||
"keeps one stable settings row for the %s preference",
|
||||
(preference) => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, preference);
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
const row = selector.closest(".r6-language-setting-row");
|
||||
expect(selector.value).toBe(preference);
|
||||
expect(row).toBeTruthy();
|
||||
expect(row?.querySelector("label")?.hidden).toBe(false);
|
||||
expect(
|
||||
screen
|
||||
.getByRole("option", {
|
||||
name: /简体中文|Simplified Chinese|Chino simplificado/,
|
||||
})
|
||||
.getAttribute("lang"),
|
||||
).toBe("zh-CN");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("option", { name: /英语|English|Inglés/ })
|
||||
.getAttribute("lang"),
|
||||
).toBe("en");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("option", { name: /西班牙语|Spanish|Español/ })
|
||||
.getAttribute("lang"),
|
||||
).toBe("es");
|
||||
},
|
||||
);
|
||||
|
||||
it("offers an accessible selector, persists and updates its label", async () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
render(
|
||||
|
||||
@@ -13,8 +13,16 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
||||
compact ? "r6-language-selector--compact" : "r6-language-setting"
|
||||
}
|
||||
>
|
||||
{!compact && <h2 id={`${id}-title`}>{t("languageTitle")}</h2>}
|
||||
<div className={compact ? "r6-language-selector-row" : "r6-setting-row"}>
|
||||
{!compact && (
|
||||
<h2 id={`${id}-title`} className="r6-language-setting-title">
|
||||
{t("languageTitle")}
|
||||
</h2>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
compact ? "r6-language-selector-row" : "r6-language-setting-row"
|
||||
}
|
||||
>
|
||||
<label id={`${id}-label`} htmlFor={`${id}-select`} hidden={compact}>
|
||||
{t(compact ? "languageTitle" : "languageLabel")}
|
||||
</label>
|
||||
@@ -36,18 +44,22 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
||||
}}
|
||||
>
|
||||
<option value="system">{t("languageSystem")}</option>
|
||||
<option value="zh-CN" lang={compact ? "zh-CN" : undefined}>
|
||||
<option value="zh-CN" lang="zh-CN">
|
||||
{compact ? "简体中文" : t("languageChinese")}
|
||||
</option>
|
||||
<option value="en" lang={compact ? "en" : undefined}>
|
||||
<option value="en" lang="en">
|
||||
{compact ? "English" : t("languageEnglish")}
|
||||
</option>
|
||||
<option value="es" lang={compact ? "es" : undefined}>
|
||||
<option value="es" lang="es">
|
||||
{compact ? "Español" : t("languageSpanish")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{!compact && <p id={`${id}-description`}>{t("languageDescription")}</p>}
|
||||
{!compact && (
|
||||
<p id={`${id}-description`} className="r6-language-setting-description">
|
||||
{t("languageDescription")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="r6-settings-error">
|
||||
{t(error)}
|
||||
|
||||
@@ -82,3 +82,38 @@ it("SYS-01: reduced motion skips animation and does not require animationend", (
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts a new or resumed detail at the top without resetting the current detail", () => {
|
||||
vi.stubGlobal("matchMedia", () => ({
|
||||
matches: false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
const content = (scrollResetKey: string, suspended = false) => (
|
||||
<MemoryEvidencePanel
|
||||
open
|
||||
suspended={suspended}
|
||||
scrollResetKey={scrollResetKey}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<p>{scrollResetKey}</p>
|
||||
</MemoryEvidencePanel>
|
||||
);
|
||||
const view = render(content("memory-a"));
|
||||
const panel = view.container.querySelector<HTMLElement>(".map-inspector");
|
||||
expect(panel).toBeTruthy();
|
||||
if (!panel) return;
|
||||
|
||||
panel.scrollTop = 180;
|
||||
view.rerender(content("memory-a"));
|
||||
expect(panel.scrollTop).toBe(180);
|
||||
|
||||
view.rerender(content("memory-b"));
|
||||
expect(panel.scrollTop).toBe(0);
|
||||
|
||||
panel.scrollTop = 120;
|
||||
view.rerender(content("memory-b", true));
|
||||
expect(panel.scrollTop).toBe(120);
|
||||
view.rerender(content("memory-b"));
|
||||
expect(panel.scrollTop).toBe(0);
|
||||
});
|
||||
@@ -15,11 +15,14 @@ export function MemoryEvidencePanel({
|
||||
onClose,
|
||||
children,
|
||||
suspended = false,
|
||||
scrollResetKey,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose(): void;
|
||||
children: ReactNode;
|
||||
suspended?: boolean;
|
||||
/** A different detail starts at the top instead of inheriting old scroll. */
|
||||
scrollResetKey?: string | null;
|
||||
}) {
|
||||
const { text } = useMemoryLanguage();
|
||||
const compact = useMediaPreference("(max-width: 1100px)");
|
||||
@@ -37,9 +40,21 @@ export function MemoryEvidencePanel({
|
||||
reduceMotion,
|
||||
reduceTransparency,
|
||||
});
|
||||
const priorScrollOwner = useRef({ key: scrollResetKey, suspended });
|
||||
const present = !compact || open || phase !== "closed";
|
||||
const modal = compact && present && !suspended;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previous = priorScrollOwner.current;
|
||||
priorScrollOwner.current = { key: scrollResetKey, suspended };
|
||||
if (
|
||||
!suspended &&
|
||||
(previous.key !== scrollResetKey || previous.suspended) &&
|
||||
panel.current
|
||||
)
|
||||
panel.current.scrollTop = 0;
|
||||
}, [scrollResetKey, suspended]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = panel.current;
|
||||
if (!element) return;
|
||||
|
||||
@@ -186,18 +186,17 @@ it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads"
|
||||
/>,
|
||||
);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "刷新地图记忆" }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("button", { name: "刷新地图记忆" })).toBeNull();
|
||||
// Typing alone does not search; Enter submits the words.
|
||||
const input = screen.getByRole("textbox", { name: "筛选地图记忆" });
|
||||
await userEvent.type(input, "第一");
|
||||
expect(onQuery).not.toHaveBeenCalled();
|
||||
await userEvent.type(input, "{Enter}");
|
||||
expect(onQuery).toHaveBeenCalledWith("第一");
|
||||
// Submitting the words already applied re-reads, like the refresh button.
|
||||
// Submitting the currently applied query re-reads without a separate row.
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "{Enter}");
|
||||
expect(onRetry).toHaveBeenCalledTimes(2);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("searches only when 搜索 is pressed, not on every keystroke", async () => {
|
||||
@@ -510,6 +509,12 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
|
||||
/共 519 条,第 2 \/ 6 页/,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText(/共 519 条/)).toHaveLength(1);
|
||||
expect(
|
||||
screen
|
||||
.getByRole("navigation", { name: "地图记忆分页" })
|
||||
.classList.contains("map-pagination"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Network,
|
||||
RefreshCw,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -696,43 +695,17 @@ export function MemoryMap(props: Props) {
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
{library && (
|
||||
<div className="map-list-heading" aria-live="polite">
|
||||
<span>
|
||||
{library.stale
|
||||
? library.page && library.page.total > 0
|
||||
? text(
|
||||
"共 {count} 条 · 正在核对来源…",
|
||||
{ count: plain(library.page.total) },
|
||||
library.page.total,
|
||||
)
|
||||
: text("正在核对来源…")
|
||||
: library.failed
|
||||
? text("读取失败")
|
||||
: library.loading && library.page?.total === 0
|
||||
? text("正在读取…")
|
||||
: library.page
|
||||
? `${text(
|
||||
"共 {count} 条",
|
||||
{ count: plain(library.page.total) },
|
||||
library.page.total,
|
||||
)}${refreshing ? text(" · 正在核对…") : ""}`
|
||||
: text("正在读取…")}
|
||||
</span>
|
||||
{!library.stale && (
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("刷新地图记忆")}
|
||||
title={text("刷新地图记忆")}
|
||||
disabled={library.loading || hidden}
|
||||
onClick={library.onRetry}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{library && (library.loading || library.stale || library.failed) ? (
|
||||
<p className="map-list-status" aria-live="polite">
|
||||
{library.failed
|
||||
? text("读取失败")
|
||||
: library.stale
|
||||
? text("正在核对来源…")
|
||||
: refreshing && library.page?.total !== 0
|
||||
? text("正在核对…")
|
||||
: text("正在读取…")}
|
||||
</p>
|
||||
) : null}
|
||||
{!props.simplified && (
|
||||
<div className="map-groups">
|
||||
<button
|
||||
@@ -873,7 +846,7 @@ export function MemoryMap(props: Props) {
|
||||
!library.loading &&
|
||||
!library.failed &&
|
||||
!library.stale && (
|
||||
<nav aria-label={text("地图记忆分页")} className="map-list-heading">
|
||||
<nav aria-label={text("地图记忆分页")} className="map-pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
@@ -1089,6 +1062,7 @@ export function MemoryMap(props: Props) {
|
||||
<MemoryEvidencePanel
|
||||
open={inspectorOpen && !!(activeId || visibleGroupNode)}
|
||||
suspended={overlaysSuspended}
|
||||
scrollResetKey={activeId ?? visibleGroupNode?.node_id ?? "empty"}
|
||||
onClose={closeInspector}
|
||||
>
|
||||
<header className="map-inspector-heading">
|
||||
|
||||
@@ -26,7 +26,7 @@ it("opens a Spanish composer without saving, processing or enabling anything", a
|
||||
screen.getByRole("textbox", { name: "¿Qué quieres recordar?" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Guardar texto" }) as HTMLButtonElement)
|
||||
(screen.getByRole("button", { name: "Guardar" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
await userEvent.click(
|
||||
@@ -165,12 +165,12 @@ it("saves from an empty library with the linked-app scope by default", async ()
|
||||
expect(document.activeElement).toBe(input);
|
||||
await userEvent.type(input, note.text);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "保存原话" }).hasAttribute("disabled"),
|
||||
screen.getByRole("button", { name: "保存" }).hasAttribute("disabled"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
screen.queryByRole("checkbox", { name: /允许已关联应用读取/ }),
|
||||
).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存原话" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
await waitFor(() =>
|
||||
expect(api.saveMemoryNote).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -208,7 +208,7 @@ it("preserves draft and request identity after an uncertain save", async () => {
|
||||
page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
await userEvent.type(screen.getByRole("textbox"), note.text);
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存原话" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||
expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe(
|
||||
note.text,
|
||||
@@ -219,7 +219,7 @@ it("preserves draft and request identity after an uncertain save", async () => {
|
||||
expect(result?.status).toBe("unknown");
|
||||
expect(result?.reason).toBe("save_result_unconfirmed");
|
||||
expect(result?.status).not.toBe("failed");
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存原话" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
expect(api.saveMemoryNote.mock.calls[0][0].request_id).toBe(
|
||||
api.saveMemoryNote.mock.calls[1][0].request_id,
|
||||
);
|
||||
@@ -235,7 +235,7 @@ it("closes as saved when the POST response is lost but the operation exists", as
|
||||
const callbacks = page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
await userEvent.type(screen.getByRole("textbox"), note.text);
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存原话" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
await waitFor(() =>
|
||||
expect(api.getMemoryNoteOperation).toHaveBeenCalledOnce(),
|
||||
);
|
||||
@@ -292,7 +292,7 @@ it("does not ask for separate consent when an old note is waiting for permission
|
||||
page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
|
||||
expect(await screen.findByText("原话已保存,等待整理")).toBeTruthy();
|
||||
expect(await screen.findByText("正在等待整理")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "模型授权" })).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: "重新整理" }));
|
||||
expect(api.processMemoryNote).toHaveBeenCalledExactlyOnceWith(
|
||||
|
||||
@@ -16,8 +16,8 @@ import { isDefiniteRefusal } from "./write-outcome";
|
||||
|
||||
const states: Record<MemoryNoteReceipt["state"], string> = {
|
||||
waiting_for_model: "原话已保存,等待可用模型",
|
||||
waiting_for_permission: "原话已保存,等待整理",
|
||||
pending: "原话已保存,等待整理",
|
||||
waiting_for_permission: "正在等待整理",
|
||||
pending: "正在等待整理",
|
||||
running: "正在整理",
|
||||
failed: "原话已保存,整理未完成",
|
||||
ready: "整理已完成",
|
||||
@@ -371,7 +371,7 @@ export function MemoryNoteComposer({
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" disabled={busy || !text.trim()}>
|
||||
{copy(busy ? "保存中…" : "保存原话")}
|
||||
{copy(busy ? "保存中…" : "保存")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -25,9 +25,7 @@ it("translates memory filters and detail affordances live without refetching", a
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "Filtrar memorias del mapa" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Actualizar memorias" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Buscar" })).toBeTruthy();
|
||||
const calls = reads.sdkLibrary.mock.calls.length;
|
||||
act(() => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
@@ -36,6 +34,7 @@ it("translates memory filters and detail affordances live without refetching", a
|
||||
);
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "记忆", level: 1 })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "搜索" })).toBeTruthy();
|
||||
expect(reads.sdkLibrary).toHaveBeenCalledTimes(calls);
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
@@ -772,6 +772,31 @@ button.rail-button.active::before {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.map-pagination {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding: 12px 6px 0;
|
||||
border-top: 1px solid var(--bd);
|
||||
color: var(--tx3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.map-pagination > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.map-list-status {
|
||||
flex: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
color: var(--tx3);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.map-filter-notice {
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
|
||||
@@ -24,14 +24,31 @@
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
.r6-settings .r6-language-setting h2 {
|
||||
margin: 0 0 12px;
|
||||
.r6-language-setting-title {
|
||||
margin: 0 0 18px;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
}
|
||||
.r6-settings .r6-language-setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.r6-settings .r6-language-setting-row label {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.r6-settings .r6-language-setting select {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 6px 10px;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dce4eb;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
@@ -42,8 +59,11 @@
|
||||
outline: 2px solid #225f9d;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.r6-settings .r6-language-setting > p {
|
||||
margin: 12px 0 0;
|
||||
.r6-language-setting-description {
|
||||
margin: 16px 0 0;
|
||||
color: #5e6e7a;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.r6-settings .r6-settings-layout {
|
||||
|
||||
@@ -112,6 +112,8 @@
|
||||
.r6-shell .r6-memory .map-search {
|
||||
height: 44px;
|
||||
margin: 13px 0 21px;
|
||||
gap: 7px;
|
||||
padding: 0 4px 0 12px;
|
||||
border: 1px solid #c6d2da;
|
||||
background: rgb(255 255 255 / 50%);
|
||||
border-radius: 8px;
|
||||
@@ -120,7 +122,33 @@
|
||||
min-height: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.r6-shell .r6-memory .map-search-submit {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgb(64 87 103 / 18%);
|
||||
border-radius: 6px;
|
||||
background: #4f6373;
|
||||
color: #fff;
|
||||
font:
|
||||
500 12px / 20px "Noto Sans SC",
|
||||
"PingFang SC",
|
||||
sans-serif;
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 18%);
|
||||
transition:
|
||||
background-color var(--r6-fast),
|
||||
border-color var(--r6-fast);
|
||||
}
|
||||
.r6-shell .r6-memory .map-search-submit:hover:not(:disabled) {
|
||||
background: #435966;
|
||||
border-color: rgb(48 71 87 / 28%);
|
||||
}
|
||||
.r6-shell .r6-memory .map-search-submit:focus-visible {
|
||||
outline: 2px solid #557c93;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.r6-shell .r6-memory .map-memory-list {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -208,6 +236,9 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
.selected-memory-detail {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
color: #384a57;
|
||||
font-family: "Noto Sans SC", "PingFang SC", sans-serif;
|
||||
}
|
||||
@@ -228,6 +259,8 @@
|
||||
}
|
||||
.selected-memory-body {
|
||||
display: block;
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
font-size: 17px;
|
||||
line-height: 29px;
|
||||
letter-spacing: normal;
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "one-memory",
|
||||
"version": "1.0.5",
|
||||
"release": {
|
||||
"build_number": 22,
|
||||
"build_number": 30,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -65,6 +65,13 @@ export interface HealthResponse {
|
||||
*/
|
||||
memory_backfill?: "running" | "done" | "failed";
|
||||
memory_backfill_error?: string | null;
|
||||
/**
|
||||
* Work an earlier run left unfinished (write queue, interrupted imports,
|
||||
* live sync) is picked up after Core is ready. "running" is not degraded:
|
||||
* readiness never waits for it. Absent from cores older than this field.
|
||||
*/
|
||||
recovery?: "pending" | "running" | "done" | "failed";
|
||||
recovery_failed_steps?: string[];
|
||||
}
|
||||
|
||||
export type ImportStatus =
|
||||
|
||||
@@ -1722,6 +1722,7 @@ const baseEnglishMessages = {
|
||||
"memory:原话已保存,等待可用模型":
|
||||
"Original saved; waiting for an available model",
|
||||
"memory:原话已保存,等待整理": "Original saved; waiting for processing",
|
||||
"memory:正在等待整理": "Awaiting processing",
|
||||
"memory:正在整理": "Processing",
|
||||
"memory:原话已保存,整理未完成": "Original saved; processing incomplete",
|
||||
"memory:整理已完成": "Processing complete",
|
||||
|
||||
@@ -288,6 +288,7 @@ export const memoryChineseMessages = {
|
||||
"memory:重新整理": "重新整理",
|
||||
"memory:原话已保存,等待可用模型": "原话已保存,等待可用模型",
|
||||
"memory:原话已保存,等待整理": "原话已保存,等待整理",
|
||||
"memory:正在等待整理": "正在等待整理",
|
||||
"memory:正在整理": "正在整理",
|
||||
"memory:原话已保存,整理未完成": "原话已保存,整理未完成",
|
||||
"memory:整理已完成": "整理已完成",
|
||||
@@ -696,6 +697,7 @@ export const memorySpanishMessages: Record<
|
||||
"memory:重新整理": "Volver a procesar",
|
||||
"memory:原话已保存,等待可用模型": "Texto guardado; esperando un modelo",
|
||||
"memory:原话已保存,等待整理": "Texto guardado; pendiente de procesar",
|
||||
"memory:正在等待整理": "Pendiente de procesar",
|
||||
"memory:正在整理": "Procesando",
|
||||
"memory:原话已保存,整理未完成": "Texto guardado; procesamiento incompleto",
|
||||
"memory:整理已完成": "Procesamiento completado",
|
||||
|
||||
Reference in new issue
Block a user