Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
996368ee32 | ||
|
|
df5a65797c | ||
|
|
2ea48e68a6 | ||
|
|
b8fdde7d0b | ||
|
|
5134524743 | ||
|
|
e34101cb0b | ||
|
|
995c7e6c32 | ||
|
|
f1afdb03cd | ||
|
|
4df4ce72bd | ||
|
|
a6d9894975 | ||
|
|
bce58e638d |
No files matched your search
@@ -644,6 +644,9 @@ class SdkRawPage(BaseModel):
|
||||
offset: int = Field(ge=0)
|
||||
limit: int = Field(ge=1, le=500)
|
||||
unit: Literal["raw", "turn"] = "raw"
|
||||
next_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
total_exact: bool = True
|
||||
#: Set when a time filter was applied in Core. For storage `raw`, `total`
|
||||
#: remains the SDK count; for product `turn`, it is the filtered turn count.
|
||||
time_filtered: bool = False
|
||||
@@ -4491,6 +4494,8 @@ def create_app(
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
unit: Literal["raw", "turn"] = Query(default="raw"),
|
||||
paging: Literal["offset", "cursor"] = Query(default="offset"),
|
||||
cursor: str | None = Query(default=None, max_length=160),
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
|
||||
session_id: list[str] | None = Query(default=None), # noqa: B008
|
||||
@@ -4516,9 +4521,52 @@ def create_app(
|
||||
_library_values(statuses or [], MEMORY_STATUSES, "memory_status_unknown")
|
||||
or raw_list_filters()["statuses"]
|
||||
)
|
||||
if paging == "cursor" and unit != "turn":
|
||||
raise HTTPException(status_code=422, detail="raw_cursor_requires_turn_unit")
|
||||
if cursor is not None and paging != "cursor":
|
||||
raise HTTPException(status_code=422, detail="raw_cursor_requires_cursor_paging")
|
||||
if paging == "cursor" and (offset or after is not None or before is not None):
|
||||
raise HTTPException(status_code=422, detail="raw_cursor_filter_unsupported")
|
||||
raw_cursor = (0, 0, 0, 0, "", "")
|
||||
if cursor:
|
||||
parts = cursor.split(":")
|
||||
if (
|
||||
len(parts) != 7
|
||||
or parts[0] != "turn"
|
||||
or any(re.fullmatch(r"[0-9]+", part) is None for part in parts[1:5])
|
||||
or re.fullmatch(r"(?:p?[0-9a-f]{16})?", parts[5]) is None
|
||||
or re.fullmatch(r"[0-9a-f]{16}", parts[6]) is None
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
|
||||
raw_cursor = (
|
||||
int(parts[1]),
|
||||
int(parts[2]),
|
||||
int(parts[3]),
|
||||
int(parts[4]),
|
||||
parts[5],
|
||||
parts[6],
|
||||
)
|
||||
if any(value > 100_000_000 for value in raw_cursor[:4]):
|
||||
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
|
||||
sources = split_csv(source)
|
||||
excluded = split_csv(exclude_sources)
|
||||
sessions = split_csv(session_id)
|
||||
cursor_scope = hashlib.sha256(
|
||||
json.dumps(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"statuses": sorted(chosen_statuses),
|
||||
"sources": sorted(sources),
|
||||
"excluded": sorted(excluded),
|
||||
"sessions": sorted(sessions),
|
||||
"order": order,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
).hexdigest()[:16]
|
||||
if cursor and raw_cursor[5] != cursor_scope:
|
||||
raise HTTPException(status_code=422, detail="raw_cursor_scope_mismatch")
|
||||
try:
|
||||
client = await _sdk_client_handle(app).client()
|
||||
query = {
|
||||
@@ -4527,8 +4575,154 @@ def create_app(
|
||||
"include_sources": sources or None,
|
||||
"exclude_sources": excluded or None,
|
||||
"session_ids": sessions or None,
|
||||
"order": order if unit == "raw" else "desc",
|
||||
"order": order if unit == "raw" or paging == "cursor" else "desc",
|
||||
}
|
||||
if unit == "turn" and paging == "cursor":
|
||||
(
|
||||
raw_offset,
|
||||
turn_offset,
|
||||
emitted,
|
||||
previous_total,
|
||||
expected_raw,
|
||||
_scope,
|
||||
) = raw_cursor
|
||||
value = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
**query,
|
||||
offset=raw_offset,
|
||||
limit=min(limit + 1, 500),
|
||||
)
|
||||
raw_page = HySdkRawPage.from_memory_list(
|
||||
value, limit=min(limit + 1, 500), offset=raw_offset
|
||||
)
|
||||
|
||||
def raw_anchor(record: HySdkRawRecord) -> str:
|
||||
return hashlib.sha256(
|
||||
f"{record.raw_id}\0{record.content}".encode()
|
||||
).hexdigest()[:16]
|
||||
|
||||
if cursor:
|
||||
if raw_page.total < previous_total:
|
||||
raise HTTPException(status_code=409, detail="raw_cursor_stale")
|
||||
observed_total = raw_page.total
|
||||
added = raw_page.total - previous_total
|
||||
if added and order == "desc":
|
||||
raw_offset += added
|
||||
value = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
**query,
|
||||
offset=raw_offset,
|
||||
limit=min(limit + 1, 500),
|
||||
)
|
||||
raw_page = HySdkRawPage.from_memory_list(
|
||||
value,
|
||||
limit=min(limit + 1, 500),
|
||||
offset=raw_offset,
|
||||
)
|
||||
if raw_page.total != observed_total:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="raw_cursor_stale"
|
||||
)
|
||||
|
||||
if expected_raw.startswith("p"):
|
||||
if raw_offset == 0:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="raw_cursor_stale"
|
||||
)
|
||||
previous_value = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
**query,
|
||||
offset=raw_offset - 1,
|
||||
limit=1,
|
||||
)
|
||||
previous_page = HySdkRawPage.from_memory_list(
|
||||
previous_value, limit=1, offset=raw_offset - 1
|
||||
)
|
||||
valid_anchor = (
|
||||
previous_page.total == raw_page.total
|
||||
and bool(previous_page.raw)
|
||||
and raw_anchor(previous_page.raw[0])
|
||||
== expected_raw[1:]
|
||||
)
|
||||
else:
|
||||
valid_anchor = (
|
||||
bool(raw_page.raw)
|
||||
and raw_anchor(raw_page.raw[0]) == expected_raw
|
||||
)
|
||||
if expected_raw and not valid_anchor:
|
||||
raise HTTPException(status_code=409, detail="raw_cursor_stale")
|
||||
selected_turns: list[tuple[HySdkRawRecord, RawDisplayTurn]] = []
|
||||
next_cursor: str | None = None
|
||||
for record_index, record in enumerate(raw_page.raw):
|
||||
turns = raw_display_turns(record)
|
||||
ordered_turns = (
|
||||
tuple(reversed(turns)) if order == "desc" else turns
|
||||
)
|
||||
start = turn_offset if record_index == 0 else 0
|
||||
for index in range(start, len(ordered_turns)):
|
||||
selected_turns.append((record, ordered_turns[index]))
|
||||
if len(selected_turns) < limit:
|
||||
continue
|
||||
absolute_raw = raw_offset + record_index
|
||||
next_raw = absolute_raw
|
||||
next_turn = index + 1
|
||||
if next_turn >= len(ordered_turns):
|
||||
next_raw += 1
|
||||
next_turn = 0
|
||||
if next_raw < raw_page.total:
|
||||
if next_turn:
|
||||
anchor_record = record
|
||||
else:
|
||||
next_index = record_index + 1
|
||||
anchor_record = (
|
||||
raw_page.raw[next_index]
|
||||
if next_index < len(raw_page.raw)
|
||||
else None
|
||||
)
|
||||
anchor = (
|
||||
raw_anchor(anchor_record)
|
||||
if anchor_record is not None
|
||||
else f"p{raw_anchor(record)}"
|
||||
)
|
||||
next_cursor = (
|
||||
f"turn:{next_raw}:{next_turn}:"
|
||||
f"{emitted + len(selected_turns)}:{raw_page.total}:"
|
||||
f"{anchor}:{cursor_scope}"
|
||||
)
|
||||
break
|
||||
if len(selected_turns) >= limit:
|
||||
break
|
||||
if (
|
||||
next_cursor is None
|
||||
and raw_offset + len(raw_page.raw) < raw_page.total
|
||||
):
|
||||
if not raw_page.raw:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="raw_cursor_stale"
|
||||
)
|
||||
next_raw = raw_offset + len(raw_page.raw)
|
||||
previous_anchor = raw_anchor(raw_page.raw[-1])
|
||||
next_cursor = (
|
||||
f"turn:{next_raw}:0:{emitted + len(selected_turns)}:"
|
||||
f"{raw_page.total}:p{previous_anchor}:{cursor_scope}"
|
||||
)
|
||||
return SdkRawPage(
|
||||
user_id=user_id,
|
||||
items=tuple(
|
||||
SdkRawItem.of(record, turn)
|
||||
for record, turn in selected_turns
|
||||
),
|
||||
# Cursor consumers do not use a precise total. This lower
|
||||
# bound preserves the legacy numeric field without forcing
|
||||
# a full scan merely to count variable-size turn batches.
|
||||
total=emitted + len(selected_turns) + (1 if next_cursor else 0),
|
||||
offset=emitted,
|
||||
limit=limit,
|
||||
unit="turn",
|
||||
next_cursor=next_cursor,
|
||||
has_more=next_cursor is not None,
|
||||
total_exact=False,
|
||||
)
|
||||
if unit == "raw":
|
||||
value = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import hashlib
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from collections.abc import AsyncIterator, Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -28,6 +28,7 @@ def discovered_off_loop(
|
||||
suffixes: frozenset[str],
|
||||
max_targets: int,
|
||||
excluded_directory_names: frozenset[str] = frozenset(),
|
||||
directory_predicate: Callable[[Path], bool] | None = None,
|
||||
) -> AsyncIterator[SourceFileDiscoveryEvent]:
|
||||
"""The same walk, for callers that have an event loop to keep free.
|
||||
|
||||
@@ -43,6 +44,7 @@ def discovered_off_loop(
|
||||
suffixes=suffixes,
|
||||
max_targets=max_targets,
|
||||
excluded_directory_names=excluded_directory_names,
|
||||
directory_predicate=directory_predicate,
|
||||
),
|
||||
scope_key=grant.source_id,
|
||||
)
|
||||
@@ -54,6 +56,7 @@ def discover_source_files(
|
||||
suffixes: frozenset[str],
|
||||
max_targets: int,
|
||||
excluded_directory_names: frozenset[str] = frozenset(),
|
||||
directory_predicate: Callable[[Path], bool] | None = None,
|
||||
) -> Iterator[SourceFileDiscoveryEvent]:
|
||||
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
|
||||
|
||||
@@ -71,10 +74,16 @@ def discover_source_files(
|
||||
grant.root,
|
||||
followlinks=False,
|
||||
):
|
||||
current = Path(directory)
|
||||
child_directories[:] = sorted(
|
||||
child
|
||||
for child in child_directories
|
||||
if not (Path(directory) / child).is_symlink() and child not in excluded_directory_names
|
||||
if not (current / child).is_symlink()
|
||||
and child not in excluded_directory_names
|
||||
and (
|
||||
directory_predicate is None
|
||||
or directory_predicate((current / child).relative_to(grant.root))
|
||||
)
|
||||
)
|
||||
for filename in sorted(filenames):
|
||||
if Path(filename).suffix.lower() not in suffixes:
|
||||
|
||||
@@ -591,7 +591,11 @@ class PollingLiveSyncSupervisor:
|
||||
|
||||
async def _poll_source(self, source_id: str) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(self.interval_seconds)
|
||||
# A watched source is normally triggered by its filesystem event.
|
||||
# Its timer is only a missed-event backstop; recursively walking
|
||||
# every multi-gigabyte host root once a minute starved unrelated
|
||||
# Core reads even when no source had changed.
|
||||
await asyncio.sleep(self._poll_interval(source_id))
|
||||
try:
|
||||
await self.trigger(source_id)
|
||||
except asyncio.CancelledError:
|
||||
@@ -608,6 +612,11 @@ class PollingLiveSyncSupervisor:
|
||||
except Exception as again:
|
||||
self._note_internal_error(source_id, again)
|
||||
|
||||
def _poll_interval(self, source_id: str) -> float:
|
||||
if source_id in (self._watched_sources or ()):
|
||||
return max(self.interval_seconds, 15 * 60)
|
||||
return self.interval_seconds
|
||||
|
||||
def _note_worker_still_running(self, source_id: str) -> None:
|
||||
current = self.health(source_id)
|
||||
self._set_health(
|
||||
|
||||
@@ -226,6 +226,9 @@ class JsonlSourceAdapter:
|
||||
self.header_gate = header_gate
|
||||
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
|
||||
self._history_listing_cache: SourceListing | None = None
|
||||
self._probe_listing_cache: tuple[
|
||||
dict[str, SourceCursor], int | None, SourceListing
|
||||
] | None = None
|
||||
|
||||
@property
|
||||
def descriptor(self) -> SourceDescriptor:
|
||||
@@ -281,6 +284,11 @@ class JsonlSourceAdapter:
|
||||
predicate=self.file_predicate,
|
||||
cursors=options.cursors,
|
||||
)
|
||||
self._probe_listing_cache = (
|
||||
dict(options.cursors),
|
||||
options.max_scan_targets,
|
||||
listing,
|
||||
)
|
||||
return bool(listing.changed or listing.failures)
|
||||
|
||||
async def live_baseline(self) -> dict[str, SourceCursor]:
|
||||
@@ -315,9 +323,11 @@ class JsonlSourceAdapter:
|
||||
async for event in self._scan_frozen(options):
|
||||
yield event
|
||||
return
|
||||
listing = await asyncio.to_thread(
|
||||
self._history_listing if options.reuse_listing else self._fresh_listing,
|
||||
options,
|
||||
listing = (
|
||||
await asyncio.to_thread(self._history_listing, options)
|
||||
if options.reuse_listing
|
||||
else self._take_probe_listing(options)
|
||||
or await asyncio.to_thread(self._fresh_listing, options)
|
||||
)
|
||||
for failure in listing.failures:
|
||||
yield ScanFailure(reason_code=discovery_failure_code(failure))
|
||||
@@ -349,6 +359,16 @@ class JsonlSourceAdapter:
|
||||
self._history_listing_cache = listing
|
||||
return listing
|
||||
|
||||
def _take_probe_listing(self, options: ScanOptions) -> "SourceListing | None":
|
||||
cached = self._probe_listing_cache
|
||||
self._probe_listing_cache = None
|
||||
if cached is None:
|
||||
return None
|
||||
cursors, max_scan_targets, listing = cached
|
||||
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
|
||||
return None
|
||||
return listing
|
||||
|
||||
def _fresh_listing(self, options: ScanOptions) -> "SourceListing":
|
||||
return list_changed_files(
|
||||
self.grant,
|
||||
|
||||
@@ -9,6 +9,7 @@ from collections.abc import AsyncIterator, Iterator
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
@@ -175,6 +176,9 @@ class CursorUnifiedSourceAdapter:
|
||||
self.grant = grant
|
||||
self.source_version = source_version
|
||||
self._history_listing_cache: _CursorListing | None = None
|
||||
self._probe_listing_cache: tuple[
|
||||
dict[str, SourceCursor], int | None, _CursorListing
|
||||
] | None = None
|
||||
|
||||
@property
|
||||
def descriptor(self) -> SourceDescriptor:
|
||||
@@ -207,6 +211,20 @@ class CursorUnifiedSourceAdapter:
|
||||
state = await self.install_state()
|
||||
return min(state.session_count, limit) if limit is not None else state.session_count
|
||||
|
||||
async def has_changes(self, options: ScanOptions) -> bool:
|
||||
listing = await asyncio.to_thread(self._fresh_listing, options)
|
||||
self._probe_listing_cache = (
|
||||
dict(options.cursors),
|
||||
options.max_scan_targets,
|
||||
listing,
|
||||
)
|
||||
return bool(
|
||||
listing.transcripts
|
||||
or listing.stores
|
||||
or listing.legacy
|
||||
or listing.failures
|
||||
)
|
||||
|
||||
async def live_baseline(self) -> dict[str, SourceCursor]:
|
||||
"""Capture Cursor's current tail so the realtime lane never backfills history."""
|
||||
listing = await asyncio.to_thread(
|
||||
@@ -222,9 +240,11 @@ class CursorUnifiedSourceAdapter:
|
||||
async for event in self._scan_frozen(options):
|
||||
yield event
|
||||
return
|
||||
listing = await asyncio.to_thread(
|
||||
self._history_listing if options.reuse_listing else self._fresh_listing,
|
||||
options,
|
||||
listing = (
|
||||
await asyncio.to_thread(self._history_listing, options)
|
||||
if options.reuse_listing
|
||||
else self._take_probe_listing(options)
|
||||
or await asyncio.to_thread(self._fresh_listing, options)
|
||||
)
|
||||
for failure in listing.failures:
|
||||
yield ScanFailure(reason_code=discovery_failure_code(failure))
|
||||
@@ -329,6 +349,16 @@ class CursorUnifiedSourceAdapter:
|
||||
self._history_listing_cache = listing
|
||||
return listing
|
||||
|
||||
def _take_probe_listing(self, options: ScanOptions) -> "_CursorListing | None":
|
||||
cached = self._probe_listing_cache
|
||||
self._probe_listing_cache = None
|
||||
if cached is None:
|
||||
return None
|
||||
cursors, max_scan_targets, listing = cached
|
||||
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
|
||||
return None
|
||||
return listing
|
||||
|
||||
def _fresh_listing(self, options: ScanOptions) -> "_CursorListing":
|
||||
return _list_cursor_files(
|
||||
self.grant,
|
||||
@@ -376,6 +406,7 @@ class CursorUnifiedSourceAdapter:
|
||||
self.grant,
|
||||
suffixes=frozenset({".db"}),
|
||||
max_targets=options.max_scan_targets or 100_000,
|
||||
directory_predicate=_cursor_data_directory,
|
||||
):
|
||||
if isinstance(discovered, SourceFileDiscoveryFailure):
|
||||
yield ScanFailure(reason_code=discovery_failure_code(discovered))
|
||||
@@ -477,7 +508,10 @@ def _list_cursor_files(
|
||||
legacy: list[AuthorizedSourceFile] = []
|
||||
failures: list[SourceFileDiscoveryFailure] = []
|
||||
for item in discover_source_files(
|
||||
grant, suffixes=frozenset({".jsonl", ".db", ".vscdb"}), max_targets=max_targets
|
||||
grant,
|
||||
suffixes=frozenset({".jsonl", ".db", ".vscdb"}),
|
||||
max_targets=max_targets,
|
||||
directory_predicate=_cursor_data_directory,
|
||||
):
|
||||
if isinstance(item, SourceFileDiscoveryFailure):
|
||||
failures.append(item)
|
||||
@@ -586,6 +620,25 @@ def _is_cursor_data_file(source_file: AuthorizedSourceFile) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _cursor_data_directory(relative_path: Path) -> bool:
|
||||
"""Enter only Cursor directories that can contain conversation authority.
|
||||
|
||||
The granted root also contains extensions, skills, caches, logs and agent
|
||||
artifacts. Recursively walking all of them every live-sync poll made the
|
||||
reader monopolize Core even though none can pass the file predicates.
|
||||
"""
|
||||
parts = relative_path.parts
|
||||
if not parts:
|
||||
return False
|
||||
if parts[0] in {"chats", "acp-sessions"}:
|
||||
return True
|
||||
if parts[0] == "projects":
|
||||
return len(parts) <= 2 or parts[2] == "agent-transcripts"
|
||||
if parts[0] == "User":
|
||||
return len(parts) == 1 or parts[1] in {"globalStorage", "workspaceStorage"}
|
||||
return False
|
||||
|
||||
|
||||
def _is_transcript_file(source_file: AuthorizedSourceFile) -> bool:
|
||||
parts = source_file.relative_path.parts
|
||||
return source_file.relative_path.suffix == ".jsonl" and "agent-transcripts" in parts
|
||||
@@ -634,6 +687,7 @@ def _select_transcript_files(
|
||||
grant,
|
||||
suffixes=frozenset({".jsonl"}),
|
||||
max_targets=100_000,
|
||||
directory_predicate=_cursor_data_directory,
|
||||
):
|
||||
if not isinstance(item, AuthorizedSourceFile) or not _is_transcript_file(item):
|
||||
continue
|
||||
@@ -654,6 +708,7 @@ def _subagent_conversation_ids(grant: AuthorizedSourceRoot) -> set[str]:
|
||||
grant,
|
||||
suffixes=frozenset({".jsonl"}),
|
||||
max_targets=100_000,
|
||||
directory_predicate=_cursor_data_directory,
|
||||
)
|
||||
if isinstance(item, AuthorizedSourceFile)
|
||||
and _is_transcript_file(item)
|
||||
|
||||
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
|
||||
_operation: ContextVar[tuple["ModelProbeOperations", str] | None] = ContextVar(
|
||||
"model_probe_operation", default=None
|
||||
)
|
||||
_dispatch_started: ContextVar[bool] = ContextVar("model_probe_dispatch_started", default=False)
|
||||
|
||||
|
||||
class ProbeCode(StrEnum):
|
||||
@@ -113,16 +114,26 @@ class ModelProber:
|
||||
if existing is not None:
|
||||
return existing
|
||||
token = _operation.set((operations, request.operation_id))
|
||||
dispatch_token = _dispatch_started.set(False)
|
||||
try:
|
||||
await self._probe(profile_id)
|
||||
except ProbeOperationConflict:
|
||||
await asyncio.to_thread(
|
||||
operations.fail, request.operation_id, "model_probe_profile_changed"
|
||||
)
|
||||
except Exception:
|
||||
# Unknown dispatch/storage outcomes stay reserved, including across restart.
|
||||
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
|
||||
except Exception as exc:
|
||||
if _dispatch_started.get():
|
||||
# Once a request may have reached the provider, preserve the
|
||||
# reservation so a retry cannot duplicate a possibly billed call.
|
||||
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
|
||||
logging.getLogger(__name__).warning(
|
||||
"model probe failed before dispatch: %s", type(exc).__name__
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
operations.fail, request.operation_id, "model_probe_execution_failed"
|
||||
)
|
||||
finally:
|
||||
_dispatch_started.reset(dispatch_token)
|
||||
_operation.reset(token)
|
||||
return await asyncio.to_thread(operations.lookup, request.operation_id)
|
||||
|
||||
@@ -317,6 +328,8 @@ class ModelProber:
|
||||
listing_optional: bool = False,
|
||||
) -> ProbeStage:
|
||||
await self._check_operation()
|
||||
if _operation.get() is not None:
|
||||
_dispatch_started.set(True)
|
||||
started = monotonic()
|
||||
try:
|
||||
response = await client.request(
|
||||
|
||||
@@ -293,6 +293,11 @@ def test_health_states_that_watching_is_layered_on_the_timer(tmp_path: Path) ->
|
||||
|
||||
assert live.health(PI_DESCRIPTOR.source_id).mode == "watch_and_polling"
|
||||
assert live.health("unknown-source").mode == "watch_and_polling"
|
||||
live.set_watched_sources((PI_DESCRIPTOR.source_id,))
|
||||
assert live._poll_interval(PI_DESCRIPTOR.source_id) == max(
|
||||
live.interval_seconds, 15 * 60
|
||||
)
|
||||
assert live._poll_interval("unknown-source") == live.interval_seconds
|
||||
await imports.shutdown()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -91,6 +91,51 @@ def test_reserved_before_transport_and_atomic_result_reopens_without_resending(e
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_pre_dispatch_failure_marks_operation_failed_and_unfences(env, monkeypatch):
|
||||
store, secrets, profile, ops, calls, respond = env
|
||||
operation = str(uuid4())
|
||||
|
||||
def fail_require(*args, **kwargs):
|
||||
raise RuntimeError("profile load failed")
|
||||
|
||||
prober = ModelProber(store=store, secrets=secrets, transport=httpx.MockTransport(respond))
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(store, "require", fail_require)
|
||||
receipt = asyncio.run(
|
||||
prober.probe(
|
||||
profile.profile_id,
|
||||
operation_id=operation,
|
||||
expected_profile_revision=profile.revision,
|
||||
)
|
||||
)
|
||||
|
||||
assert receipt.state == "failed"
|
||||
assert receipt.code == "model_probe_execution_failed"
|
||||
assert receipt.result is None
|
||||
assert calls == []
|
||||
assert ops.lookup(operation) == receipt
|
||||
assert (
|
||||
asyncio.run(
|
||||
prober.probe(
|
||||
profile.profile_id,
|
||||
operation_id=operation,
|
||||
expected_profile_revision=profile.revision,
|
||||
)
|
||||
)
|
||||
== receipt
|
||||
)
|
||||
|
||||
completed = asyncio.run(
|
||||
prober.probe(
|
||||
profile.profile_id,
|
||||
operation_id=str(uuid4()),
|
||||
expected_profile_revision=profile.revision,
|
||||
)
|
||||
)
|
||||
assert completed.state == "completed"
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
def test_same_id_and_same_profile_concurrency_refused_before_second_call(env):
|
||||
store, secrets, profile, ops, calls, respond = env
|
||||
|
||||
|
||||
@@ -77,6 +77,36 @@ def test_workbuddy_record_parsing_never_blocks_the_event_loop(tmp_path) -> None:
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_jsonl_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
|
||||
root = tmp_path / ".workbuddy" / "projects"
|
||||
write_jsonl(
|
||||
root / "project" / "session.jsonl",
|
||||
[{"role": "user", "sessionId": "session", "content": "new conversation"}],
|
||||
)
|
||||
adapter = create_workbuddy_adapter(grant("workbuddy", root))
|
||||
from one_memory.imports.sources import common
|
||||
|
||||
calls = 0
|
||||
original = common.list_changed_files
|
||||
|
||||
def counted(*args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(common, "list_changed_files", counted)
|
||||
|
||||
async def probe_then_scan():
|
||||
options = ScanOptions()
|
||||
assert await adapter.has_changes(options)
|
||||
return [event async for event in adapter.scan(options)]
|
||||
|
||||
records = evidence(asyncio.run(probe_then_scan()))
|
||||
|
||||
assert [item.evidence.verbatim for item in records] == ["new conversation"]
|
||||
assert calls == 1
|
||||
|
||||
|
||||
def test_cursor_store_user_info_envelope_is_not_attributed_to_the_person(tmp_path) -> None:
|
||||
"""A Cursor store blob can be the host's session context, not the user's words.
|
||||
|
||||
@@ -1352,6 +1382,73 @@ def test_cursor_transcripts_with_the_same_name_in_two_projects_are_both_kept(
|
||||
assert len({item.evidence.source_ordinal for item in records}) == 2
|
||||
|
||||
|
||||
def test_cursor_discovery_does_not_enter_non_conversation_trees(tmp_path) -> None:
|
||||
root = tmp_path / ".cursor"
|
||||
write_jsonl(
|
||||
root / "projects" / "project" / "agent-transcripts" / "kept" / "kept.jsonl",
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"message": {"content": [{"type": "text", "text": "kept conversation"}]},
|
||||
}
|
||||
],
|
||||
)
|
||||
write_jsonl(
|
||||
root
|
||||
/ "extensions"
|
||||
/ "large-cache"
|
||||
/ "agent-transcripts"
|
||||
/ "ignored"
|
||||
/ "ignored.jsonl",
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"message": {"content": [{"type": "text", "text": "must not be scanned"}]},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
records = evidence(asyncio.run(scan(CursorUnifiedSourceAdapter(grant=grant("cursor", root)))))
|
||||
|
||||
assert [item.evidence.verbatim for item in records] == ["kept conversation"]
|
||||
|
||||
|
||||
def test_cursor_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
|
||||
root = tmp_path / ".cursor"
|
||||
write_jsonl(
|
||||
root / "projects" / "project" / "agent-transcripts" / "new" / "new.jsonl",
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"message": {"content": [{"type": "text", "text": "new conversation"}]},
|
||||
}
|
||||
],
|
||||
)
|
||||
adapter = CursorUnifiedSourceAdapter(grant=grant("cursor", root))
|
||||
from one_memory.imports.adapters import ScanOptions
|
||||
from one_memory.imports.sources import cursor_unified
|
||||
|
||||
calls = 0
|
||||
original = cursor_unified._list_cursor_files
|
||||
|
||||
def counted(*args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(cursor_unified, "_list_cursor_files", counted)
|
||||
|
||||
async def probe_then_scan():
|
||||
options = ScanOptions()
|
||||
assert await adapter.has_changes(options)
|
||||
return [event async for event in adapter.scan(options)]
|
||||
|
||||
records = evidence(asyncio.run(probe_then_scan()))
|
||||
|
||||
assert [item.evidence.verbatim for item in records] == ["new conversation"]
|
||||
assert calls == 1
|
||||
|
||||
|
||||
def test_cursor_bounded_scans_reuse_the_authorized_file_listing(tmp_path, monkeypatch) -> None:
|
||||
root = tmp_path / ".cursor"
|
||||
transcript = root / "projects" / "project" / "agent-transcripts" / "session" / "session.jsonl"
|
||||
|
||||
@@ -214,7 +214,15 @@ class RecordingSdk:
|
||||
)
|
||||
and (not kwargs.get("session_ids") or row["session_id"] in kwargs["session_ids"])
|
||||
]
|
||||
return {"vdb": {"memories": rows, "total": len(rows)}}
|
||||
total = len(rows)
|
||||
offset = kwargs.get("offset", 0)
|
||||
limit = kwargs.get("limit", 100)
|
||||
return {
|
||||
"vdb": {
|
||||
"memories": rows[offset : offset + limit],
|
||||
"total": total,
|
||||
}
|
||||
}
|
||||
rows = [_chat(user_id, *row[:2], *row[3:]) for row in CHAT_ROWS.get(user_id, [])]
|
||||
if layers:
|
||||
rows = [row for row in rows if row["layer"] in layers]
|
||||
@@ -712,6 +720,75 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
|
||||
["我喜欢绿茶", "也记住了。"],
|
||||
["我叫费天翔", "好的,记住了。"],
|
||||
]
|
||||
cursor = None
|
||||
first_cursor = None
|
||||
cursor_turns = []
|
||||
cursor_calls = len(sdk.all("list_memories"))
|
||||
while True:
|
||||
params = {"unit": "turn", "paging": "cursor", "limit": 1}
|
||||
if cursor is not None:
|
||||
params["cursor"] = cursor
|
||||
response = await client.get("/api/v2/raw", params=params)
|
||||
assert response.status_code == 200, response.text
|
||||
cursor_page = response.json()
|
||||
assert cursor_page["total_exact"] is False
|
||||
assert len(cursor_page["items"]) == 1
|
||||
cursor_turns.append(cursor_page["items"][0]["messages"][0]["content"])
|
||||
cursor = cursor_page["next_cursor"]
|
||||
if first_cursor is None:
|
||||
first_cursor = cursor
|
||||
if cursor is None:
|
||||
assert cursor_page["has_more"] is False
|
||||
break
|
||||
assert cursor_page["has_more"] is True
|
||||
assert cursor_turns == ["我喜欢绿茶", "我叫费天翔", "你好"]
|
||||
assert len(sdk.all("list_memories")) == cursor_calls + 3
|
||||
inserted = {
|
||||
"memory_id": "raw-new",
|
||||
"content": "[user]: 新消息",
|
||||
"status": "active",
|
||||
"session_id": "s-new",
|
||||
"source": "cursor",
|
||||
"memory_at": 1_790_200_000,
|
||||
"gmt_created": 1_790_200_100,
|
||||
}
|
||||
RAW_ROWS["local"].insert(0, inserted)
|
||||
try:
|
||||
continued = await client.get(
|
||||
"/api/v2/raw",
|
||||
params={
|
||||
"unit": "turn",
|
||||
"paging": "cursor",
|
||||
"limit": 1,
|
||||
"cursor": first_cursor,
|
||||
},
|
||||
)
|
||||
assert continued.status_code == 200, continued.text
|
||||
assert continued.json()["items"][0]["messages"][0]["content"] == "我叫费天翔"
|
||||
finally:
|
||||
RAW_ROWS["local"].remove(inserted)
|
||||
assert (
|
||||
await client.get(
|
||||
"/api/v2/raw",
|
||||
params={
|
||||
"unit": "turn",
|
||||
"paging": "cursor",
|
||||
"cursor": first_cursor,
|
||||
"source": "hermes",
|
||||
},
|
||||
)
|
||||
).status_code == 422
|
||||
assert (
|
||||
await client.get(
|
||||
"/api/v2/raw",
|
||||
params={
|
||||
"unit": "turn",
|
||||
"paging": "cursor",
|
||||
"cursor": "turn:²:0:0:2::0000000000000000",
|
||||
},
|
||||
)
|
||||
).status_code == 422
|
||||
|
||||
calls_before_refresh = len(sdk.all("list_memories"))
|
||||
await client.get("/api/v2/raw", params={"unit": "turn"})
|
||||
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
|
||||
|
||||
@@ -366,7 +366,7 @@ it("refuses a ui trace from an untrusted window", async () => {
|
||||
).rejects.toThrow(/untrusted_window/);
|
||||
});
|
||||
|
||||
it("marks a problem and exports a redacted local bundle", async () => {
|
||||
it("marks a problem and exports the complete local log bundle", async () => {
|
||||
const value = await fixture();
|
||||
const mark = await value.handlers.get(
|
||||
"one-memory:mark-diagnostic-incident",
|
||||
@@ -375,6 +375,14 @@ it("marks a problem and exports a redacted local bundle", async () => {
|
||||
incident_id: expect.any(String),
|
||||
session_id: expect.any(String),
|
||||
});
|
||||
await writeFile(
|
||||
path.join(value.dataDir, "logs", "core.log"),
|
||||
"2026-10-14 10:31:00 WARNING database wait\n",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(value.dataDir, "logs", "service-error.log"),
|
||||
"worker failed\n",
|
||||
);
|
||||
const exported = await value.handlers.get("one-memory:export-diagnostics")?.(
|
||||
{},
|
||||
);
|
||||
@@ -398,6 +406,16 @@ it("marks a problem and exports a redacted local bundle", async () => {
|
||||
),
|
||||
);
|
||||
expect(summary).toMatchObject({ build: "3800" });
|
||||
const entries = execFileSync(
|
||||
"/usr/bin/unzip",
|
||||
["-Z1", path.join(value.dataDir, zip)],
|
||||
{ encoding: "utf8" },
|
||||
)
|
||||
.trim()
|
||||
.split("\n");
|
||||
expect(entries).toContain("logs/core.log");
|
||||
expect(entries).toContain("logs/service-error.log");
|
||||
expect(entries).toContain("logs/diagnostic-desktop.jsonl");
|
||||
value.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -196,6 +196,7 @@ export function resolveDiagnosticBuildIdentity(options: {
|
||||
|
||||
export async function exportDesktopDiagnostics(
|
||||
options: DiagnosticExportOptions,
|
||||
exportOptions: { includeRawLogs?: boolean } = {},
|
||||
) {
|
||||
const build = options.buildNumber ?? options.productVersion;
|
||||
const target = path.join(
|
||||
@@ -209,6 +210,7 @@ export async function exportDesktopDiagnostics(
|
||||
dataDir: options.dataDir,
|
||||
target,
|
||||
productVersion: build,
|
||||
includeRawLogs: exportOptions.includeRawLogs === true,
|
||||
...(options.commit === undefined ? {} : { commit: options.commit }),
|
||||
...(options.coreSha256 === undefined
|
||||
? {}
|
||||
@@ -644,7 +646,7 @@ export function installDiagnosticsIpc(
|
||||
options.ipc.handle(EXPORT, async (event, ...args) => {
|
||||
if (args.length) throw new Error("diagnostic_arguments_rejected");
|
||||
options.requireTrustedWindow(event);
|
||||
return exportDesktopDiagnostics(options);
|
||||
return exportDesktopDiagnostics(options, { includeRawLogs: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -226,6 +226,7 @@ describe("HealthPanel", () => {
|
||||
});
|
||||
vi.spyOn(window, "prompt").mockReturnValue("蓝莓没出来");
|
||||
render(<HealthPanel imports={imports()} />);
|
||||
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "记录问题时间点" }));
|
||||
expect(await screen.findByText(/问题编号已复制/)).toBeTruthy();
|
||||
|
||||
@@ -298,6 +298,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
|
||||
<Download size={16} aria-hidden="true" />
|
||||
{t("nativeExportLogs")}
|
||||
</R6Button>
|
||||
<p>{t("把本机运行日志打包成一个文件,发给开发排查。")}</p>
|
||||
</div>
|
||||
|
||||
<section className="health-block" aria-label={t("healthLocal")}>
|
||||
|
||||
@@ -110,12 +110,15 @@ type Props = {
|
||||
settledSource?: string;
|
||||
settledQuery?: string;
|
||||
requestedOffset?: number;
|
||||
cursorPaging?: boolean;
|
||||
hasMore?: boolean;
|
||||
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
||||
unattributedSource?: string;
|
||||
onQuery(value: string): void;
|
||||
onSource(value: string): void;
|
||||
resetPage(): void;
|
||||
onPage(offset: number): void;
|
||||
onMore?(): void;
|
||||
onRetry(): void;
|
||||
};
|
||||
};
|
||||
@@ -1131,62 +1134,81 @@ export function MemoryMap(props: Props) {
|
||||
aria-busy={pageTransitioning || undefined}
|
||||
className="map-pagination"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("上一页记忆")}
|
||||
title={text("上一页记忆")}
|
||||
disabled={pageTransitioning || library.page.offset === 0}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page) {
|
||||
const offset = library.page.offset - library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<span>
|
||||
{text(
|
||||
"共 {count} 条,第 {page} / {pages} 页",
|
||||
{
|
||||
count: count(library.page.total),
|
||||
page:
|
||||
library.page.total === 0
|
||||
? 0
|
||||
: Math.floor(library.page.offset / library.page.limit) +
|
||||
1,
|
||||
pages: Math.ceil(library.page.total / library.page.limit),
|
||||
},
|
||||
library.page.total,
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("下一页记忆")}
|
||||
title={text("下一页记忆")}
|
||||
disabled={
|
||||
pageTransitioning ||
|
||||
library.page.offset + library.page.limit >= library.page.total
|
||||
}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page) {
|
||||
const offset = library.page.offset + library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{library.cursorPaging ? (
|
||||
library.hasMore ? (
|
||||
<R6Button
|
||||
disabled={pageTransitioning}
|
||||
onClick={() => library.onMore?.()}
|
||||
>
|
||||
{pageTransitioning ? text("正在读取…") : text("加载更多")}
|
||||
</R6Button>
|
||||
) : (
|
||||
<span>{text("已加载全部")}</span>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("上一页记忆")}
|
||||
title={text("上一页记忆")}
|
||||
disabled={pageTransitioning || library.page.offset === 0}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page) {
|
||||
const offset = library.page.offset - library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<span>
|
||||
{text(
|
||||
"共 {count} 条,第 {page} / {pages} 页",
|
||||
{
|
||||
count: count(library.page.total),
|
||||
page:
|
||||
library.page.total === 0
|
||||
? 0
|
||||
: Math.floor(
|
||||
library.page.offset / library.page.limit,
|
||||
) + 1,
|
||||
pages: Math.ceil(
|
||||
library.page.total / library.page.limit,
|
||||
),
|
||||
},
|
||||
library.page.total,
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("下一页记忆")}
|
||||
title={text("下一页记忆")}
|
||||
disabled={
|
||||
pageTransitioning ||
|
||||
library.page.offset + library.page.limit >=
|
||||
library.page.total
|
||||
}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page) {
|
||||
const offset = library.page.offset + library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
{!library && loaded && !filtered.length && (search || groupKey) && (
|
||||
|
||||
@@ -300,13 +300,131 @@ it("shows the default nightly evolve controls in ultra", async () => {
|
||||
"aria-checked",
|
||||
),
|
||||
).toBe("true");
|
||||
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
|
||||
"03:00",
|
||||
);
|
||||
const timePicker = screen.getByRole("button", {
|
||||
name: "执行时间,03:00",
|
||||
});
|
||||
expect(timePicker.tagName).toBe("BUTTON");
|
||||
expect(timePicker.textContent).toContain("03:00");
|
||||
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the time picker from the whole control and saves a complete time", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(setEvolveSchedule).mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "04:30",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
const trigger = await screen.findByRole("button", {
|
||||
name: "执行时间,03:00",
|
||||
});
|
||||
await user.click(trigger);
|
||||
|
||||
const picker = screen.getByRole("dialog", { name: "执行时间" });
|
||||
await user.selectOptions(
|
||||
within(picker).getByRole("listbox", { name: "执行时间 HH" }),
|
||||
"04",
|
||||
);
|
||||
await user.selectOptions(
|
||||
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
|
||||
"30",
|
||||
);
|
||||
expect(setEvolveSchedule).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
|
||||
await waitFor(() =>
|
||||
expect(setEvolveSchedule).toHaveBeenCalledExactlyOnceWith({
|
||||
time: "04:30",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "执行时间,04:30" })).toBe(
|
||||
document.activeElement,
|
||||
),
|
||||
);
|
||||
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps Tab inside the time picker and restores focus on Escape", async () => {
|
||||
prepare("ultra");
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
const trigger = await screen.findByRole("button", {
|
||||
name: "执行时间,03:00",
|
||||
});
|
||||
await user.click(trigger);
|
||||
const picker = screen.getByRole("dialog", { name: "执行时间" });
|
||||
const hours = within(picker).getByRole("listbox", {
|
||||
name: "执行时间 HH",
|
||||
});
|
||||
const save = within(picker).getByRole("button", { name: "保存修改" });
|
||||
expect(document.activeElement).toBe(hours);
|
||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||
expect(document.activeElement).toBe(save);
|
||||
await user.keyboard("{Tab}");
|
||||
expect(document.activeElement).toBe(hours);
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it("keeps the selected time open for retry when saving fails", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(setEvolveSchedule).mockRejectedValue(new Error("offline"));
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "执行时间,03:00" }),
|
||||
);
|
||||
const picker = screen.getByRole("dialog", { name: "执行时间" });
|
||||
const minutes = within(picker).getByRole("listbox", {
|
||||
name: "执行时间 MM",
|
||||
});
|
||||
await user.selectOptions(minutes, "30");
|
||||
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("自动整合设置未能保存,请重试。"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("dialog", { name: "执行时间" })).toBeTruthy();
|
||||
expect((minutes as HTMLSelectElement).value).toBe("30");
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
within(picker).getByRole("button", { name: "保存修改" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the time dialog and focus stable while saving is pending", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(setEvolveSchedule).mockReturnValue(new Promise(() => {}));
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "执行时间,03:00" }),
|
||||
);
|
||||
const picker = screen.getByRole("dialog", { name: "执行时间" });
|
||||
await user.selectOptions(
|
||||
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
|
||||
"30",
|
||||
);
|
||||
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
|
||||
await waitFor(() => expect(setEvolveSchedule).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(document.activeElement).toBe(picker);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.getByRole("dialog", { name: "执行时间" })).toBe(picker);
|
||||
expect(document.activeElement).toBe(picker);
|
||||
});
|
||||
|
||||
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(setEvolveSchedule).mockResolvedValue({
|
||||
@@ -322,7 +440,7 @@ it("hides the time field when automatic evolve is off and keeps the saved time v
|
||||
await user.click(automatic);
|
||||
|
||||
await screen.findByText(/重新开启后仍按 03:00 运行/);
|
||||
expect(screen.queryByLabelText("执行时间")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /执行时间/ })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6Button, R6Switch } from "./R6Controls";
|
||||
import { R6StateRecovery } from "./R6StateRecovery";
|
||||
import { TimePicker } from "./TimePicker";
|
||||
|
||||
const MODE_COPY: Record<
|
||||
MemoryPipelineMode,
|
||||
@@ -332,8 +333,10 @@ export function MemoryModeSettings({
|
||||
setError("");
|
||||
try {
|
||||
setScheduleState(await setEvolveSchedule(update));
|
||||
return true;
|
||||
} catch {
|
||||
setError("自动整合设置未能保存,请重试。");
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -455,14 +458,12 @@ export function MemoryModeSettings({
|
||||
</label>
|
||||
<p>{text("使用这台设备的本地时间。")}</p>
|
||||
</div>
|
||||
<input
|
||||
<TimePicker
|
||||
id={`${id}-evolve-time`}
|
||||
type="time"
|
||||
label={text("执行时间")}
|
||||
value={schedule.time}
|
||||
disabled={busy || maintenance}
|
||||
onChange={(event) =>
|
||||
void changeSchedule({ time: event.target.value })
|
||||
}
|
||||
onChange={(time) => changeSchedule({ time })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -320,9 +320,9 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||
reads.raw.mockImplementation(async (options = {}) => {
|
||||
const offset = "offset" in options ? (options.offset ?? 0) : 0;
|
||||
const cursor = "cursor" in options ? options.cursor : null;
|
||||
const source = "source" in options ? options.source : undefined;
|
||||
const rawId = `${source ?? "all"}-${offset}`;
|
||||
const rawId = `${source ?? "all"}-${cursor ? "more" : "first"}`;
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
@@ -333,14 +333,17 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
source: source ?? "cursor",
|
||||
session_id: `session-${rawId}`,
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000 - offset,
|
||||
gmt_created: 1_761_000_000,
|
||||
custom: {},
|
||||
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
|
||||
},
|
||||
],
|
||||
total: source ? 1 : 21,
|
||||
total: cursor ? 2 : 1,
|
||||
limit: 20,
|
||||
offset,
|
||||
offset: cursor ? 1 : 0,
|
||||
next_cursor: !source && !cursor ? "turn:1:0:1" : null,
|
||||
has_more: !source && !cursor,
|
||||
total_exact: false,
|
||||
};
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
@@ -359,30 +362,43 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findAllByText("原始会话 all-0")).toHaveLength(2);
|
||||
await user.click(screen.getByRole("button", { name: "下一页记忆" }));
|
||||
expect(await screen.findAllByText("原始会话 all-20")).toHaveLength(2);
|
||||
expect(await screen.findAllByText("原始会话 all-first")).toHaveLength(2);
|
||||
await user.click(screen.getByRole("button", { name: "加载更多" }));
|
||||
expect(await screen.findAllByText("原始会话 all-more")).toHaveLength(1);
|
||||
expect(screen.getAllByText("原始会话 all-first")).toHaveLength(2);
|
||||
expect(reads.raw).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ source: undefined, offset: 20, limit: 20 }),
|
||||
expect.objectContaining({
|
||||
source: undefined,
|
||||
cursor: "turn:1:0:1",
|
||||
paging: "cursor",
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "来源" }));
|
||||
await user.click(screen.getByRole("option", { name: "Cursor" }));
|
||||
expect(await screen.findAllByText("原始会话 cursor-0")).toHaveLength(2);
|
||||
expect(await screen.findAllByText("原始会话 cursor-first")).toHaveLength(2);
|
||||
expect(reads.raw).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ source: "cursor", offset: 0, limit: 20 }),
|
||||
expect.objectContaining({
|
||||
source: "cursor",
|
||||
cursor: null,
|
||||
paging: "cursor",
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "来源" }));
|
||||
await user.click(screen.getByRole("option", { name: "手动添加" }));
|
||||
expect(
|
||||
await screen.findAllByText("原始会话 one-memory-manual-0"),
|
||||
await screen.findAllByText("原始会话 one-memory-manual-first"),
|
||||
).toHaveLength(2);
|
||||
expect(reads.raw).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "one-memory-manual",
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
paging: "cursor",
|
||||
cursor: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -123,6 +123,7 @@ export function MemorySpace({
|
||||
const selectedRawRef = useRef<RawMapRecord | null>(null);
|
||||
selectedRawRef.current = selectedRaw;
|
||||
const rawRecords = useRef(new Map<string, RawMapRecord>());
|
||||
const rawRecordsScope = useRef("");
|
||||
const [detailLoad, setDetailLoad] = useState<{
|
||||
memoryId: string;
|
||||
status: "loading" | "failed";
|
||||
@@ -209,6 +210,7 @@ export function MemorySpace({
|
||||
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
||||
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
||||
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
||||
const [sdkRawCursor, setSdkRawCursor] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
||||
null,
|
||||
);
|
||||
@@ -226,6 +228,10 @@ export function MemorySpace({
|
||||
evolve_available: pipelineMode === "ultra",
|
||||
}
|
||||
: null;
|
||||
const refreshSdkMap = useCallback(() => {
|
||||
if (lite) setSdkRawCursor(null);
|
||||
bumpSdkMap();
|
||||
}, [bumpSdkMap, lite]);
|
||||
const requestedQuery = sdkLibraryQuery.trim();
|
||||
const sdkPageSettled =
|
||||
sdkLibrary.settledMode === pipelineMode &&
|
||||
@@ -276,7 +282,10 @@ export function MemorySpace({
|
||||
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const rememberRaw = (items: RawMapRecord[]) => {
|
||||
const next = new Map<string, RawMapRecord>();
|
||||
const next =
|
||||
sdkRawCursor && rawRecordsScope.current === windowKey
|
||||
? new Map(rawRecords.current)
|
||||
: new Map<string, RawMapRecord>();
|
||||
if (selectedRawRef.current)
|
||||
next.set(
|
||||
rawRecordId(selectedRawRef.current),
|
||||
@@ -284,6 +293,7 @@ export function MemorySpace({
|
||||
);
|
||||
for (const item of items) next.set(rawRecordId(item), item);
|
||||
rawRecords.current = next;
|
||||
rawRecordsScope.current = windowKey;
|
||||
};
|
||||
const [page, graph] = lite
|
||||
? await Promise.all([
|
||||
@@ -297,8 +307,10 @@ export function MemorySpace({
|
||||
})
|
||||
: getSdkRaw({
|
||||
source,
|
||||
offset: sdkLibraryOffset,
|
||||
offset: 0,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
paging: "cursor",
|
||||
cursor: sdkRawCursor,
|
||||
signal: request.signal,
|
||||
}).then((rawPage) => {
|
||||
rememberRaw(rawPage.items);
|
||||
@@ -346,17 +358,64 @@ export function MemorySpace({
|
||||
};
|
||||
// The window expired: the server searched again and answered page 1.
|
||||
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
|
||||
setSdkLibrary({
|
||||
page,
|
||||
loading: false,
|
||||
failed: false,
|
||||
settledSource: sdkLibrarySource,
|
||||
settledQuery: sdkLibraryQuery,
|
||||
settledMode: pipelineMode,
|
||||
setSdkLibrary((current) => {
|
||||
const currentPage = current.page;
|
||||
const append =
|
||||
lite &&
|
||||
sdkRawCursor !== null &&
|
||||
currentPage !== null &&
|
||||
current.settledSource === sdkLibrarySource &&
|
||||
current.settledQuery === sdkLibraryQuery &&
|
||||
current.settledMode === pipelineMode;
|
||||
if (!append)
|
||||
return {
|
||||
page,
|
||||
loading: false,
|
||||
failed: false,
|
||||
settledSource: sdkLibrarySource,
|
||||
settledQuery: sdkLibraryQuery,
|
||||
settledMode: pipelineMode,
|
||||
};
|
||||
const items = [...currentPage.items];
|
||||
const known = new Set(items.map((item) => item.memory_id));
|
||||
for (const item of page.items)
|
||||
if (!known.has(item.memory_id)) {
|
||||
known.add(item.memory_id);
|
||||
items.push(item);
|
||||
}
|
||||
return {
|
||||
page: {
|
||||
...page,
|
||||
items,
|
||||
total: items.length + (page.has_more ? 1 : 0),
|
||||
offset: 0,
|
||||
source_ids_by_memory: {
|
||||
...currentPage.source_ids_by_memory,
|
||||
...page.source_ids_by_memory,
|
||||
},
|
||||
source_time_by_memory: {
|
||||
...currentPage.source_time_by_memory,
|
||||
...page.source_time_by_memory,
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
failed: false,
|
||||
settledSource: sdkLibrarySource,
|
||||
settledQuery: sdkLibraryQuery,
|
||||
settledMode: pipelineMode,
|
||||
};
|
||||
});
|
||||
setSdkGraph({ scope: windowKey, ...graph });
|
||||
} catch {
|
||||
} catch (reason) {
|
||||
if (cancelled || request.signal.aborted) return;
|
||||
if (
|
||||
lite &&
|
||||
sdkRawCursor !== null &&
|
||||
apiErrorCode(reason) === "raw_cursor_stale"
|
||||
) {
|
||||
setSdkRawCursor(null);
|
||||
return;
|
||||
}
|
||||
setSdkLibrary((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
@@ -365,10 +424,13 @@ export function MemorySpace({
|
||||
}
|
||||
};
|
||||
void read(true);
|
||||
const timer = setInterval(() => void read(false), 30_000);
|
||||
const timer =
|
||||
lite && sdkRawCursor !== null
|
||||
? undefined
|
||||
: setInterval(() => void read(false), 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
controller?.abort();
|
||||
};
|
||||
}, [
|
||||
@@ -377,6 +439,7 @@ export function MemorySpace({
|
||||
sdkLibraryOffset,
|
||||
sdkLibraryQuery,
|
||||
sdkLibrarySource,
|
||||
sdkRawCursor,
|
||||
lite,
|
||||
pipelineMode,
|
||||
]);
|
||||
@@ -525,6 +588,7 @@ export function MemorySpace({
|
||||
rawRecords.current.clear();
|
||||
sdkSearchWindow.current = { key: "", id: null };
|
||||
setSdkLibraryOffset(0);
|
||||
setSdkRawCursor(null);
|
||||
setSdkLibraryQuery("");
|
||||
setSdkGraph(null);
|
||||
setSdkLibrary((current) => ({
|
||||
@@ -804,7 +868,7 @@ export function MemorySpace({
|
||||
);
|
||||
trace.done("succeeded");
|
||||
closeDetail();
|
||||
bumpSdkMap();
|
||||
refreshSdkMap();
|
||||
} catch (reason) {
|
||||
trace.done(writeTraceStatus(reason), {
|
||||
reason: "memory_action_failed",
|
||||
@@ -833,7 +897,7 @@ export function MemorySpace({
|
||||
trace.done("succeeded");
|
||||
setDetail(sdkDetail(saved));
|
||||
setDraft(sdkEditableText(saved));
|
||||
bumpSdkMap();
|
||||
refreshSdkMap();
|
||||
} catch (reason) {
|
||||
trace.done(writeTraceStatus(reason), {
|
||||
reason: isDefiniteRefusal(reason)
|
||||
@@ -898,10 +962,12 @@ export function MemorySpace({
|
||||
? t("memory:记忆暂时无法读取")
|
||||
: lite
|
||||
? sdkPage
|
||||
? `${t("memory:原始对话")} · ${tn(
|
||||
"sdk.raw.found",
|
||||
sdkPage.total,
|
||||
)}`
|
||||
? sdkPage.total_exact === false
|
||||
? t("memory:原始对话")
|
||||
: `${t("memory:原始对话")} · ${tn(
|
||||
"sdk.raw.found",
|
||||
sdkPage.total,
|
||||
)}`
|
||||
: sdkLibrary.failed
|
||||
? null
|
||||
: t("memory:正在读取…")
|
||||
@@ -934,7 +1000,7 @@ export function MemorySpace({
|
||||
onSaved={() => {
|
||||
// A finished addition is new page content: re-read now rather than
|
||||
// leave it for the next poll.
|
||||
bumpSdkMap();
|
||||
refreshSdkMap();
|
||||
}}
|
||||
onOpenMemory={(id) => {
|
||||
if (!simplified) setMode("library");
|
||||
@@ -1201,12 +1267,21 @@ export function MemorySpace({
|
||||
settledSource: sdkLibrary.settledSource,
|
||||
settledQuery: sdkLibrary.settledQuery,
|
||||
requestedOffset: sdkLibraryOffset,
|
||||
cursorPaging: lite && !sdkLibraryQuery.trim(),
|
||||
hasMore: !!sdkPage?.has_more && !!sdkPage?.next_cursor,
|
||||
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
||||
onQuery: setSdkLibraryQuery,
|
||||
onSource: setSdkLibrarySource,
|
||||
resetPage: () => setSdkLibraryOffset(0),
|
||||
resetPage: () => {
|
||||
setSdkLibraryOffset(0);
|
||||
setSdkRawCursor(null);
|
||||
},
|
||||
onPage: (offset) => setSdkLibraryOffset(offset),
|
||||
onRetry: bumpSdkMap,
|
||||
onMore: () => {
|
||||
if (sdkPage?.next_cursor)
|
||||
setSdkRawCursor(sdkPage.next_cursor);
|
||||
},
|
||||
onRetry: refreshSdkMap,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -100,6 +100,18 @@ vi.mock("./OnboardingModelController", () => ({
|
||||
>
|
||||
模型启用回执已确认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onProfilePersisted("8be6d465-4cf2-4c72-93f7-15f3c012bc80");
|
||||
onModelActiveConfirmed(
|
||||
generationToken,
|
||||
"8be6d465-4cf2-4c72-93f7-15f3c012bc80",
|
||||
);
|
||||
}}
|
||||
>
|
||||
第二个模型启用回执已确认
|
||||
</button>
|
||||
<button type="button" onClick={onSkip ?? onBack}>
|
||||
跳过模型进入应用
|
||||
</button>
|
||||
@@ -445,6 +457,155 @@ describe("OnboardingFlow S1.3", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts Lite once while the import submission is still in flight", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveImport!: (
|
||||
value: Awaited<
|
||||
ReturnType<ImportSourcesController["connectForOnboarding"]>
|
||||
>,
|
||||
) => void;
|
||||
const controller = imports({
|
||||
connectForOnboarding: vi.fn(
|
||||
() =>
|
||||
new Promise<
|
||||
Awaited<ReturnType<ImportSourcesController["connectForOnboarding"]>>
|
||||
>((resolve) => {
|
||||
resolveImport = resolve;
|
||||
}),
|
||||
),
|
||||
});
|
||||
const finish = vi.fn();
|
||||
setMemoryMode.mockResolvedValueOnce({ mode: "lite" });
|
||||
window.oneMemory = {} as NonNullable<typeof window.oneMemory>;
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={controller}
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await choose(user);
|
||||
await approve(user);
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "选择记忆模式" }),
|
||||
);
|
||||
await user.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
const enter = screen.getByRole("button", { name: "使用 Lite 进入" });
|
||||
expect(enter).toHaveProperty("disabled", false);
|
||||
|
||||
await user.click(enter);
|
||||
await user.click(enter);
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
expect(setMemoryMode).toHaveBeenCalledExactlyOnceWith("lite");
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
|
||||
await act(async () =>
|
||||
resolveImport({
|
||||
accepted_source_ids: ["claude"],
|
||||
failed_sources: {},
|
||||
jobs: [],
|
||||
}),
|
||||
);
|
||||
expect(finish).toHaveBeenCalledOnce();
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows distinct saving and completion feedback after one Lite click", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveMode!: (value: { mode: "lite" }) => void;
|
||||
let resolveCompletion!: (value: OnboardingState) => void;
|
||||
setMemoryMode.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveMode = resolve;
|
||||
}),
|
||||
);
|
||||
completeOnboarding.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveCompletion = resolve;
|
||||
}),
|
||||
);
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "正在保存模式…" }),
|
||||
).toHaveProperty("disabled", true);
|
||||
|
||||
await act(async () => resolveMode({ mode: "lite" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "正在完成首次设置…" }),
|
||||
).toHaveProperty("disabled", true),
|
||||
);
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () =>
|
||||
resolveCompletion({
|
||||
...onboarding,
|
||||
status: "completed",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
});
|
||||
|
||||
it("fences duplicate Pro activation receipts while completion is pending", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveCompletion!: (value: OnboardingState) => void;
|
||||
completeOnboarding.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveCompletion = resolve;
|
||||
}),
|
||||
);
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
const receipt = screen.getByRole("button", {
|
||||
name: "模型启用回执已确认",
|
||||
});
|
||||
fireEvent.click(receipt);
|
||||
fireEvent.click(receipt);
|
||||
|
||||
await waitFor(() => expect(completeOnboarding).toHaveBeenCalledOnce());
|
||||
expect(setMemoryMode).toHaveBeenCalledExactlyOnceWith("pro");
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () =>
|
||||
resolveCompletion({
|
||||
...onboarding,
|
||||
status: "completed",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
});
|
||||
|
||||
it("D47 entering the same-page model step does not finish setup or grant processing", async () => {
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
@@ -862,6 +1023,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
|
||||
it("serializes an in-flight Pro PUT before a later Lite choice so Lite wins", async () => {
|
||||
let resolvePro!: (value: { mode: "pro" }) => void;
|
||||
let resolveLite!: (value: { mode: "lite" }) => void;
|
||||
setMemoryMode
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
@@ -869,7 +1031,12 @@ describe("OnboardingFlow S1.3", () => {
|
||||
resolvePro = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ mode: "lite" });
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveLite = resolve;
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
@@ -900,10 +1067,118 @@ describe("OnboardingFlow S1.3", () => {
|
||||
"pro",
|
||||
"lite",
|
||||
]);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "正在保存模式…" }),
|
||||
).toHaveProperty("disabled", true);
|
||||
await act(async () => resolveLite({ mode: "lite" }));
|
||||
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("accepts a new Pro generation while an older Pro write is pending", async () => {
|
||||
let resolveFirst!: (value: { mode: "pro" }) => void;
|
||||
let resolveSecond!: (value: { mode: "pro" }) => void;
|
||||
setMemoryMode
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(1));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
expect(setMemoryMode).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => resolveFirst({ mode: "pro" }));
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => resolveSecond({ mode: "pro" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("accepts a new profile receipt within the same Pro generation", async () => {
|
||||
let resolveFirst!: (value: { mode: "pro" }) => void;
|
||||
let resolveSecond!: (value: { mode: "pro" }) => void;
|
||||
setMemoryMode
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(1));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "第二个模型启用回执已确认" }),
|
||||
);
|
||||
expect(setMemoryMode).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => resolveFirst({ mode: "pro" }));
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => resolveSecond({ mode: "pro" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("drops an in-flight Pro response after returning to mode without confirming or completing", async () => {
|
||||
let resolvePro!: (value: { mode: "pro" }) => void;
|
||||
setMemoryMode.mockImplementationOnce(
|
||||
|
||||
@@ -112,9 +112,8 @@ export function OnboardingFlow({
|
||||
const [locationCheck, setLocationCheck] = useState<LocationCheck>("checking");
|
||||
const [selected, setSelected] = useState<SourceResponse[]>([]);
|
||||
const [custom, setCustom] = useState<SourceResponse | null>(null);
|
||||
const [busy, setBusy] = useState<
|
||||
"checking" | "submitting" | "leaving" | null
|
||||
>(null);
|
||||
const [busy, setBusy] = useState<"checking" | "submitting" | null>(null);
|
||||
const [completionBusy, setCompletionBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||
const [exitDestination, setExitDestination] =
|
||||
@@ -138,7 +137,10 @@ export function OnboardingFlow({
|
||||
);
|
||||
const [modeDestination, setModeDestination] =
|
||||
useState<OnboardingDestination>("sources");
|
||||
const [modeSaving, setModeSaving] = useState(false);
|
||||
const [modePhase, setModePhase] = useState<"idle" | "saving" | "completing">(
|
||||
"idle",
|
||||
);
|
||||
const modeSaving = modePhase !== "idle";
|
||||
const [modeError, setModeError] = useState<string | null>(null);
|
||||
const [modeFocusToken, setModeFocusToken] = useState(
|
||||
resumedStep === "mode" ? 1 : 0,
|
||||
@@ -181,7 +183,10 @@ export function OnboardingFlow({
|
||||
)
|
||||
.join("、"),
|
||||
});
|
||||
const flight = useRef(false);
|
||||
const sourceFlight = useRef(false);
|
||||
const modeOperation = useRef(0);
|
||||
const proConfirmationOwner = useRef<string | null>(null);
|
||||
const completionFlight = useRef(false);
|
||||
const connectionAttempt = useRef<string | null>(null);
|
||||
const connectionQueue = useRef(Promise.resolve());
|
||||
const connectionQueueDepth = useRef(0);
|
||||
@@ -368,7 +373,10 @@ export function OnboardingFlow({
|
||||
selected.some((chosen) => chosen.source_id === item.source_id),
|
||||
);
|
||||
const locked =
|
||||
busy !== null || exitDestination !== null || state.status === "importing";
|
||||
busy !== null ||
|
||||
completionBusy ||
|
||||
exitDestination !== null ||
|
||||
state.status === "importing";
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
@@ -456,7 +464,7 @@ export function OnboardingFlow({
|
||||
locationCheck !== "ready" ||
|
||||
locked ||
|
||||
reviewingImport ||
|
||||
flight.current
|
||||
sourceFlight.current
|
||||
)
|
||||
return;
|
||||
if (!scopeMatches(selected, sources)) return;
|
||||
@@ -493,7 +501,11 @@ export function OnboardingFlow({
|
||||
}, []);
|
||||
|
||||
const leaveModelStep = useCallback(
|
||||
(nextIntent: OnboardingMemoryMode, nextStep: "review" | "mode") => {
|
||||
(
|
||||
nextIntent: OnboardingMemoryMode,
|
||||
nextStep: "review" | "mode",
|
||||
resetModePhase = true,
|
||||
) => {
|
||||
// This fence is synchronous: hidden model work must observe invalidation
|
||||
// before React renders the replacement step.
|
||||
modelGeneration.current += 1;
|
||||
@@ -502,7 +514,10 @@ export function OnboardingFlow({
|
||||
setModeChoice(nextIntent);
|
||||
setSetupStep(nextStep);
|
||||
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
|
||||
setModeSaving(false);
|
||||
if (resetModePhase) {
|
||||
modeOperation.current += 1;
|
||||
setModePhase("idle");
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -611,12 +626,15 @@ export function OnboardingFlow({
|
||||
|
||||
const leave = useCallback(
|
||||
async (destination: OnboardingDestination) => {
|
||||
if (flight.current || finished.current) return;
|
||||
flight.current = true;
|
||||
setBusy("leaving");
|
||||
await persistFinish(destination);
|
||||
flight.current = false;
|
||||
if (alive.current) setBusy(null);
|
||||
if (completionFlight.current || finished.current) return;
|
||||
completionFlight.current = true;
|
||||
setCompletionBusy(true);
|
||||
try {
|
||||
await persistFinish(destination);
|
||||
} finally {
|
||||
completionFlight.current = false;
|
||||
if (alive.current) setCompletionBusy(false);
|
||||
}
|
||||
},
|
||||
[persistFinish],
|
||||
);
|
||||
@@ -640,14 +658,11 @@ export function OnboardingFlow({
|
||||
);
|
||||
|
||||
const saveModeChoice = useCallback(async () => {
|
||||
if (
|
||||
!modeChoice ||
|
||||
modeSaving ||
|
||||
finished.current ||
|
||||
(modeChoice === "lite" && flight.current)
|
||||
)
|
||||
return;
|
||||
setModeSaving(true);
|
||||
if (!modeChoice || modeSaving || finished.current || completionBusy) return;
|
||||
const operation = ++modeOperation.current;
|
||||
const ownsModeOperation = () =>
|
||||
alive.current && !finished.current && modeOperation.current === operation;
|
||||
setModePhase("saving");
|
||||
setModeError(null);
|
||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||
try {
|
||||
@@ -655,8 +670,7 @@ export function OnboardingFlow({
|
||||
intentRef.current = "pro";
|
||||
setupStepRef.current = "mode";
|
||||
const selectionStillCurrent = () =>
|
||||
alive.current &&
|
||||
!finished.current &&
|
||||
ownsModeOperation() &&
|
||||
intentRef.current === "pro" &&
|
||||
setupStepRef.current === "mode";
|
||||
const savedProgress = await queueProgressWrite(
|
||||
@@ -673,10 +687,9 @@ export function OnboardingFlow({
|
||||
enterModelStep();
|
||||
return;
|
||||
}
|
||||
leaveModelStep("lite", "mode");
|
||||
leaveModelStep("lite", "mode", false);
|
||||
const liteStillCurrent = () =>
|
||||
alive.current &&
|
||||
!finished.current &&
|
||||
ownsModeOperation() &&
|
||||
intentRef.current === "lite" &&
|
||||
setupStepRef.current === "mode";
|
||||
await queueProgressWrite(
|
||||
@@ -688,19 +701,20 @@ export function OnboardingFlow({
|
||||
}),
|
||||
liteStillCurrent,
|
||||
);
|
||||
if (!alive.current) return;
|
||||
const changed = await queueModeWrite("lite");
|
||||
if (!alive.current) return;
|
||||
if (!liteStillCurrent()) return;
|
||||
const changed = await queueModeWrite("lite", liteStillCurrent);
|
||||
if (!liteStillCurrent()) return;
|
||||
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||
setModePhase("completing");
|
||||
await leave(modeDestination);
|
||||
} catch (reason) {
|
||||
if (isCompletedProgressConflict(reason)) {
|
||||
finishOnce(modeDestination);
|
||||
} else if (alive.current) {
|
||||
} else if (ownsModeOperation() && !isGenerationInvalid(reason)) {
|
||||
setModeError("模式未能保存。你的选择已保留,请重试。");
|
||||
}
|
||||
} finally {
|
||||
if (alive.current) setModeSaving(false);
|
||||
if (ownsModeOperation()) setModePhase("idle");
|
||||
}
|
||||
}, [
|
||||
enterModelStep,
|
||||
@@ -709,6 +723,7 @@ export function OnboardingFlow({
|
||||
modeChoice,
|
||||
modeDestination,
|
||||
modeSaving,
|
||||
completionBusy,
|
||||
finishOnce,
|
||||
queueModeWrite,
|
||||
queueProgressWrite,
|
||||
@@ -731,6 +746,7 @@ export function OnboardingFlow({
|
||||
|
||||
const confirmProMode = useCallback(
|
||||
async (token: number, ownerProfileId: string) => {
|
||||
const confirmationOwner = `${token}:${ownerProfileId}`;
|
||||
const ownsModelStep = () =>
|
||||
alive.current &&
|
||||
!finished.current &&
|
||||
@@ -738,16 +754,25 @@ export function OnboardingFlow({
|
||||
intentRef.current === "pro" &&
|
||||
setupStepRef.current === "model" &&
|
||||
modelProfileId.current === ownerProfileId;
|
||||
if (!ownsModelStep()) return;
|
||||
setModeSaving(true);
|
||||
if (
|
||||
!ownsModelStep() ||
|
||||
proConfirmationOwner.current === confirmationOwner ||
|
||||
completionBusy
|
||||
)
|
||||
return;
|
||||
proConfirmationOwner.current = confirmationOwner;
|
||||
const operation = ++modeOperation.current;
|
||||
const ownsConfirmation = () =>
|
||||
ownsModelStep() && modeOperation.current === operation;
|
||||
setModePhase("saving");
|
||||
setModeError(null);
|
||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||
try {
|
||||
const plan = await planMemoryMode("pro");
|
||||
if (!ownsModelStep()) return;
|
||||
if (!ownsConfirmation()) return;
|
||||
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
|
||||
const changed = await queueModeWrite("pro", ownsModelStep);
|
||||
if (!ownsModelStep()) return;
|
||||
const changed = await queueModeWrite("pro", ownsConfirmation);
|
||||
if (!ownsConfirmation()) return;
|
||||
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||
await queueProgressWrite(
|
||||
() =>
|
||||
@@ -756,31 +781,35 @@ export function OnboardingFlow({
|
||||
setup_step: "model",
|
||||
model_profile_id: ownerProfileId,
|
||||
}),
|
||||
ownsModelStep,
|
||||
ownsConfirmation,
|
||||
);
|
||||
if (!ownsModelStep()) return;
|
||||
await persistFinish(modeDestination);
|
||||
if (!ownsConfirmation()) return;
|
||||
setModePhase("completing");
|
||||
await leave(modeDestination);
|
||||
} catch (reason) {
|
||||
if (isCompletedProgressConflict(reason)) {
|
||||
finishOnce(modeDestination);
|
||||
} else if (alive.current && !isGenerationInvalid(reason))
|
||||
} else if (ownsConfirmation() && !isGenerationInvalid(reason))
|
||||
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
|
||||
} finally {
|
||||
if (alive.current) setModeSaving(false);
|
||||
if (proConfirmationOwner.current === confirmationOwner)
|
||||
proConfirmationOwner.current = null;
|
||||
if (ownsConfirmation()) setModePhase("idle");
|
||||
}
|
||||
},
|
||||
[
|
||||
completionBusy,
|
||||
finishOnce,
|
||||
leave,
|
||||
modeDestination,
|
||||
persistFinish,
|
||||
queueModeWrite,
|
||||
queueProgressWrite,
|
||||
],
|
||||
);
|
||||
|
||||
async function checkLocations() {
|
||||
if (flight.current || locked) return;
|
||||
flight.current = true;
|
||||
if (sourceFlight.current || locked) return;
|
||||
sourceFlight.current = true;
|
||||
scopeVersion.current += 1;
|
||||
setBusy("checking");
|
||||
setLocationCheck("checking");
|
||||
@@ -805,7 +834,7 @@ export function OnboardingFlow({
|
||||
setLocationCheck("failed");
|
||||
setError("位置检测未完成,尚未读取历史。可以重新检测,或先进入工作台。");
|
||||
} finally {
|
||||
flight.current = false;
|
||||
sourceFlight.current = false;
|
||||
if (alive.current) setBusy(null);
|
||||
}
|
||||
}
|
||||
@@ -813,7 +842,7 @@ export function OnboardingFlow({
|
||||
|
||||
function toggleSource(source: SourceResponse, checked: boolean) {
|
||||
if (
|
||||
flight.current ||
|
||||
sourceFlight.current ||
|
||||
locked ||
|
||||
locationCheck !== "ready" ||
|
||||
!isAppSource(source) ||
|
||||
@@ -836,7 +865,7 @@ export function OnboardingFlow({
|
||||
}
|
||||
|
||||
function selectAllSources() {
|
||||
if (flight.current || locked || locationCheck !== "ready") return;
|
||||
if (sourceFlight.current || locked || locationCheck !== "ready") return;
|
||||
scopeVersion.current += 1;
|
||||
setError(null);
|
||||
setItemErrors({});
|
||||
@@ -849,7 +878,7 @@ export function OnboardingFlow({
|
||||
|
||||
async function startImport() {
|
||||
if (
|
||||
flight.current ||
|
||||
sourceFlight.current ||
|
||||
locked ||
|
||||
locationCheck !== "ready" ||
|
||||
!selected.length
|
||||
@@ -863,7 +892,7 @@ export function OnboardingFlow({
|
||||
}
|
||||
|
||||
async function submitImport(approved: SourceResponse[]) {
|
||||
flight.current = true;
|
||||
sourceFlight.current = true;
|
||||
if (window.oneMemory) setStartingImport(approved);
|
||||
const version = scopeVersion.current;
|
||||
const retryFailed = Object.keys(itemErrors).length > 0;
|
||||
@@ -917,7 +946,7 @@ export function OnboardingFlow({
|
||||
}
|
||||
} finally {
|
||||
connectSent.current = false;
|
||||
flight.current = false;
|
||||
sourceFlight.current = false;
|
||||
if (alive.current) setBusy(null);
|
||||
}
|
||||
}
|
||||
@@ -987,10 +1016,10 @@ export function OnboardingFlow({
|
||||
jobs={reviewJobs}
|
||||
failedSources={accepted?.failed_sources}
|
||||
sourceSnapshot={imports.sourceSnapshot}
|
||||
busy={busy === "leaving"}
|
||||
busy={completionBusy}
|
||||
error={error && text(error)}
|
||||
onContinue={(destination) => {
|
||||
if (busy === "leaving") return;
|
||||
if (completionBusy) return;
|
||||
if (completionFailed) {
|
||||
void leave(destination);
|
||||
return;
|
||||
@@ -1031,7 +1060,7 @@ export function OnboardingFlow({
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={modeSaving || busy === "leaving"}
|
||||
disabled={modeSaving || completionBusy}
|
||||
onClick={() => {
|
||||
setModeError(null);
|
||||
intentRef.current = modeChoice;
|
||||
@@ -1056,21 +1085,19 @@ export function OnboardingFlow({
|
||||
className="onboarding-r6-submit"
|
||||
variant="primary"
|
||||
pending={modeSaving}
|
||||
disabled={
|
||||
!modeChoice ||
|
||||
busy === "leaving" ||
|
||||
(modeChoice === "lite" && flight.current)
|
||||
}
|
||||
disabled={!modeChoice || completionBusy}
|
||||
onClick={() => void saveModeChoice()}
|
||||
>
|
||||
{text(
|
||||
modeSaving
|
||||
? "正在保存…"
|
||||
: modeChoice === "lite"
|
||||
? "使用 Lite 进入"
|
||||
: modeChoice === "pro"
|
||||
? "继续配置模型"
|
||||
: "继续",
|
||||
modePhase === "saving"
|
||||
? "正在保存模式…"
|
||||
: modePhase === "completing"
|
||||
? "正在完成首次设置…"
|
||||
: modeChoice === "lite"
|
||||
? "使用 Lite 进入"
|
||||
: modeChoice === "pro"
|
||||
? "继续配置模型"
|
||||
: "继续",
|
||||
)}
|
||||
</R6Button>
|
||||
</>
|
||||
@@ -1104,7 +1131,7 @@ export function OnboardingFlow({
|
||||
},
|
||||
]}
|
||||
selected={modeChoice}
|
||||
disabled={modeSaving || busy === "leaving"}
|
||||
disabled={modeSaving || completionBusy}
|
||||
recommended={{ mode: "pro", badge: text("推荐") }}
|
||||
onChange={(mode) => {
|
||||
modeChoiceTouched.current = true;
|
||||
@@ -1188,7 +1215,7 @@ export function OnboardingFlow({
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={busy !== null}
|
||||
disabled={busy !== null || completionBusy}
|
||||
onClick={() => finishOnce(exitDestination)}
|
||||
>
|
||||
{text(
|
||||
@@ -1197,7 +1224,7 @@ export function OnboardingFlow({
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy !== null}
|
||||
disabled={busy !== null || completionBusy}
|
||||
onClick={() => void leave(exitDestination)}
|
||||
>
|
||||
{text("重试保存并进入")}
|
||||
|
||||
@@ -169,6 +169,7 @@ it("shows the help and diagnostics group by default and hides it when opted out"
|
||||
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
|
||||
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
|
||||
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "关于" })).toBeTruthy();
|
||||
expect(screen.getByText("1.0.4")).toBeTruthy();
|
||||
expect(screen.getByText("8")).toBeTruthy();
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { Check, ChevronDown, Clock3 } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6Button } from "./R6Controls";
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, value) =>
|
||||
value.toString().padStart(2, "0"),
|
||||
);
|
||||
const MINUTES = Array.from({ length: 60 }, (_, value) =>
|
||||
value.toString().padStart(2, "0"),
|
||||
);
|
||||
|
||||
function splitTime(value: string): [string, string] {
|
||||
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
|
||||
return match ? [match[1], match[2]] : ["00", "00"];
|
||||
}
|
||||
|
||||
export function TimePicker({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange(value: string): Promise<boolean>;
|
||||
}) {
|
||||
const { locale, text } = useOnboardingTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [placement, setPlacement] = useState<"above" | "below">("above");
|
||||
const [hour, setHour] = useState(() => splitTime(value)[0]);
|
||||
const [minute, setMinute] = useState(() => splitTime(value)[1]);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const hourSelect = useRef<HTMLSelectElement>(null);
|
||||
const minuteSelect = useRef<HTMLSelectElement>(null);
|
||||
const restoreTriggerFocus = useRef(false);
|
||||
const restoreSaveFocus = useRef(false);
|
||||
const savingRef = useRef(false);
|
||||
savingRef.current = saving;
|
||||
const panelId = `${id}-picker`;
|
||||
|
||||
const close = useCallback((restoreFocus = true) => {
|
||||
setOpen(false);
|
||||
restoreTriggerFocus.current = restoreFocus;
|
||||
}, []);
|
||||
|
||||
function show() {
|
||||
const [nextHour, nextMinute] = splitTime(value);
|
||||
setHour(nextHour);
|
||||
setMinute(nextMinute);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeOutside = (event: MouseEvent) => {
|
||||
if (savingRef.current) return;
|
||||
const target = event.target as Element | null;
|
||||
const label = target?.closest("label");
|
||||
if (label instanceof HTMLLabelElement && label.htmlFor === id) return;
|
||||
if (!root.current?.contains(target)) close(false);
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || savingRef.current) return;
|
||||
event.preventDefault();
|
||||
close();
|
||||
};
|
||||
document.addEventListener("mousedown", closeOutside);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
hourSelect.current?.focus();
|
||||
hourSelect.current?.selectedOptions[0]?.scrollIntoView?.({
|
||||
block: "nearest",
|
||||
});
|
||||
minuteSelect.current?.selectedOptions[0]?.scrollIntoView?.({
|
||||
block: "nearest",
|
||||
});
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", closeOutside);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [close, id, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled && open && !saving) close(false);
|
||||
if (!disabled && !open && restoreTriggerFocus.current) {
|
||||
restoreTriggerFocus.current = false;
|
||||
trigger.current?.focus();
|
||||
}
|
||||
if (!disabled && open && restoreSaveFocus.current) {
|
||||
restoreSaveFocus.current = false;
|
||||
panel.current
|
||||
?.querySelector<HTMLButtonElement>("[data-time-picker-save]")
|
||||
?.focus();
|
||||
}
|
||||
if (saving && open) panel.current?.focus();
|
||||
}, [close, disabled, open, saving]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const place = () => {
|
||||
const anchor = trigger.current?.getBoundingClientRect();
|
||||
const picker = panel.current?.getBoundingClientRect();
|
||||
if (!anchor || !picker) return;
|
||||
const scroller = trigger.current?.closest(
|
||||
".app-content, .onboarding-frame",
|
||||
);
|
||||
const bounds = scroller?.getBoundingClientRect();
|
||||
const topEdge = (bounds ? Math.max(0, bounds.top) : 0) + 12;
|
||||
const bottomEdge =
|
||||
(bounds
|
||||
? Math.min(window.innerHeight, bounds.bottom)
|
||||
: window.innerHeight) - 12;
|
||||
const above = anchor.top - topEdge;
|
||||
const below = bottomEdge - anchor.bottom;
|
||||
setPlacement(
|
||||
above >= picker.height || above >= below ? "above" : "below",
|
||||
);
|
||||
};
|
||||
place();
|
||||
window.addEventListener("resize", place);
|
||||
window.addEventListener("scroll", place, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", place);
|
||||
window.removeEventListener("scroll", place, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
async function save() {
|
||||
const next = `${hour}:${minute}`;
|
||||
if (next === value) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const saved = await onChange(next);
|
||||
setSaving(false);
|
||||
if (saved) close();
|
||||
else restoreSaveFocus.current = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="r6-time-picker" ref={root}>
|
||||
<button
|
||||
ref={trigger}
|
||||
id={id}
|
||||
type="button"
|
||||
className="r6-time-picker-trigger"
|
||||
aria-label={
|
||||
locale.startsWith("zh") ? `${label},${value}` : `${label}: ${value}`
|
||||
}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
disabled={disabled}
|
||||
onClick={() => (open ? close() : show())}
|
||||
>
|
||||
<span>{value}</span>
|
||||
<span className="r6-time-picker-icons" aria-hidden="true">
|
||||
<Clock3 size={15} />
|
||||
<ChevronDown size={14} />
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<section
|
||||
ref={panel}
|
||||
id={panelId}
|
||||
className="r6-time-picker-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
tabIndex={-1}
|
||||
data-placement={placement}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Tab") return;
|
||||
const controls = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLElement>(
|
||||
"select:not(:disabled), button:not(:disabled)",
|
||||
),
|
||||
);
|
||||
const first = controls[0];
|
||||
const last = controls.at(-1);
|
||||
if (!first || !last) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus();
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="r6-time-picker-columns">
|
||||
<label className="r6-time-picker-column">
|
||||
<span aria-hidden="true">HH</span>
|
||||
<select
|
||||
ref={hourSelect}
|
||||
size={6}
|
||||
aria-label={`${label} HH`}
|
||||
value={hour}
|
||||
disabled={saving}
|
||||
onChange={(event) => setHour(event.target.value)}
|
||||
>
|
||||
{HOURS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="r6-time-picker-separator" aria-hidden="true">
|
||||
:
|
||||
</span>
|
||||
<label className="r6-time-picker-column">
|
||||
<span aria-hidden="true">MM</span>
|
||||
<select
|
||||
ref={minuteSelect}
|
||||
size={6}
|
||||
aria-label={`${label} MM`}
|
||||
value={minute}
|
||||
disabled={saving}
|
||||
onChange={(event) => setMinute(event.target.value)}
|
||||
>
|
||||
{MINUTES.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<footer>
|
||||
<R6Button disabled={saving} onClick={() => close()}>
|
||||
{text("取消")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
pending={saving}
|
||||
data-time-picker-save
|
||||
onClick={() => void save()}
|
||||
>
|
||||
<Check size={15} aria-hidden="true" />
|
||||
{text("保存修改")}
|
||||
</R6Button>
|
||||
</footer>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,8 @@ it("lists raw conversations for the selected application", async () => {
|
||||
offset: 20,
|
||||
limit: 20,
|
||||
unit: "turn",
|
||||
paging: undefined,
|
||||
cursor: undefined,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -987,6 +987,8 @@ export async function getSdkRaw({
|
||||
offset = 0,
|
||||
limit = 100,
|
||||
unit = "turn",
|
||||
paging,
|
||||
cursor,
|
||||
signal,
|
||||
}: {
|
||||
source?: string;
|
||||
@@ -994,10 +996,20 @@ export async function getSdkRaw({
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
unit?: "raw" | "turn";
|
||||
paging?: "offset" | "cursor";
|
||||
cursor?: string | null;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<SdkRawPage> {
|
||||
const response = await client.get<SdkRawPage>("/api/v2/raw", {
|
||||
params: { source, session_id: sessionId, offset, limit, unit },
|
||||
params: {
|
||||
source,
|
||||
session_id: sessionId,
|
||||
offset,
|
||||
limit,
|
||||
unit,
|
||||
paging,
|
||||
cursor: cursor || undefined,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
return response.data;
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.r6-home-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 346px;
|
||||
padding: 26px;
|
||||
@@ -150,6 +152,7 @@
|
||||
padding: 8px 0;
|
||||
}
|
||||
.r6-home-activity {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #d5dfe8;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e1e8ed;
|
||||
}
|
||||
.r6-settings .r6-evolve-time label {
|
||||
.r6-settings .r6-evolve-time > div > label {
|
||||
color: #304757;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -304,20 +304,146 @@
|
||||
.r6-evolve-time p {
|
||||
margin-top: 1px;
|
||||
}
|
||||
.r6-evolve-settings input[type="time"] {
|
||||
.r6-time-picker {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
}
|
||||
.r6-time-picker-trigger {
|
||||
box-sizing: border-box;
|
||||
width: 180px;
|
||||
min-height: 44px;
|
||||
padding: 0 10px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 12px 0 14px;
|
||||
border: 1px solid #c6d2da;
|
||||
border-radius: 8px;
|
||||
color: #304757;
|
||||
background: #fff;
|
||||
font:
|
||||
500 14px / 22px "Noto Sans SC",
|
||||
"PingFang SC",
|
||||
sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 140ms ease-out,
|
||||
background-color 140ms ease-out,
|
||||
box-shadow 140ms ease-out;
|
||||
}
|
||||
.r6-evolve-settings input[type="time"]:focus-visible {
|
||||
.r6-time-picker-trigger:hover:not(:disabled) {
|
||||
border-color: #9eafbb;
|
||||
background: #f8fafb;
|
||||
}
|
||||
.r6-time-picker-trigger[aria-expanded="true"] {
|
||||
border-color: #7892a6;
|
||||
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
|
||||
}
|
||||
.r6-time-picker-trigger:focus-visible {
|
||||
outline: 2px solid var(--r6-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.r6-time-picker-trigger:disabled {
|
||||
color: #71808b;
|
||||
background: #edf0f3;
|
||||
cursor: default;
|
||||
}
|
||||
.r6-time-picker-trigger:active:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
.r6-time-picker-icons {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #607888;
|
||||
}
|
||||
.r6-time-picker-trigger[aria-expanded="true"]
|
||||
.r6-time-picker-icons
|
||||
svg:last-child {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.r6-time-picker-panel {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
z-index: 1200;
|
||||
right: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
width: min(280px, calc(100vw - 32px));
|
||||
padding: 14px;
|
||||
border: 1px solid #c6d2da;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 36px rgb(48 71 87 / 16%);
|
||||
}
|
||||
.r6-time-picker-panel[data-placement="below"] {
|
||||
top: calc(100% + 8px);
|
||||
bottom: auto;
|
||||
}
|
||||
.r6-time-picker-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
}
|
||||
.r6-time-picker-column {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #5e6e7a;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.r6-time-picker-columns select {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 174px;
|
||||
padding: 4px;
|
||||
border: 1px solid #d5dfe8;
|
||||
border-radius: 7px;
|
||||
color: #304757;
|
||||
background: #f8fafb;
|
||||
font:
|
||||
500 14px / 24px "Noto Sans SC",
|
||||
"PingFang SC",
|
||||
sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #b6c3cc transparent;
|
||||
}
|
||||
.r6-time-picker-columns select:focus-visible {
|
||||
outline: 2px solid var(--r6-focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.r6-time-picker-columns option {
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.r6-time-picker-columns option:checked {
|
||||
color: #304757;
|
||||
background: #dfe9ef;
|
||||
}
|
||||
.r6-time-picker-separator {
|
||||
align-self: center;
|
||||
margin-top: 22px;
|
||||
color: #7892a6;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.r6-time-picker-panel footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e1e8ed;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.r6-time-picker-panel footer .r6-button {
|
||||
min-width: 68px;
|
||||
}
|
||||
.r6-state-recovery {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1059,9 +1185,14 @@
|
||||
.r6-evolve-time {
|
||||
gap: 8px;
|
||||
}
|
||||
.r6-evolve-settings input[type="time"] {
|
||||
.r6-time-picker,
|
||||
.r6-time-picker-trigger {
|
||||
width: min(220px, 100%);
|
||||
}
|
||||
.r6-time-picker-panel {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
.r6-state-recovery {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -72,6 +72,11 @@ function pageFor(
|
||||
total: number,
|
||||
offset: number,
|
||||
limit: number,
|
||||
cursor?: {
|
||||
next_cursor?: string | null;
|
||||
has_more?: boolean;
|
||||
total_exact?: boolean;
|
||||
},
|
||||
): MemoryLibraryPage {
|
||||
const sourceIds: Record<string, string[]> = {};
|
||||
const sourceTime: Record<
|
||||
@@ -97,6 +102,7 @@ function pageFor(
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
...cursor,
|
||||
source_ids_by_memory: sourceIds,
|
||||
source_time_by_memory: sourceTime,
|
||||
search_window: null,
|
||||
@@ -107,7 +113,11 @@ function pageFor(
|
||||
}
|
||||
|
||||
export function rawLibraryPage(page: SdkRawPage): MemoryLibraryPage {
|
||||
return pageFor(page.items, page.total, page.offset, page.limit);
|
||||
return pageFor(page.items, page.total, page.offset, page.limit, {
|
||||
next_cursor: page.next_cursor,
|
||||
has_more: page.has_more,
|
||||
total_exact: page.total_exact,
|
||||
});
|
||||
}
|
||||
|
||||
export function rawSearchPage(items: SdkRawContextItem[]): MemoryLibraryPage {
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
新增受限标量标签,仍应按私人数据处理,不能直接对外发送。`debug` 会让 HY 的
|
||||
`hy_memory.log` 记录 DEBUG 行,其中可能包含完整 prompt/response 和获授权的对话正文。
|
||||
|
||||
排查时只通过 App 的“导出日志”提供文件:导出过程会按安全白名单重新生成诊断事件,不会打包
|
||||
原始日志。不要直接发送 `diagnostic-*.jsonl`、整个 `~/.one-memory` 数据目录或 debug 级别的
|
||||
`hy_memory.log`;后者如确需分享,必须由开发人员另行确认并脱敏。
|
||||
App 的“导出日志”会同时打包安全投影和 `~/.one-memory/logs/` 下的完整运行日志,供可信开发人员
|
||||
还原本机故障。原始日志可能包含会话正文和本机路径,发送前必须确认接收方;不要公开分享,也不要
|
||||
发送整个 `~/.one-memory` 数据目录。SDK 的 debug 级 `hy_memory.log` 不在该 ZIP 中;如确需分享,
|
||||
必须由开发人员另行确认并脱敏。只需要脱敏事件时使用 `memhub logs export`。
|
||||
|
||||
## 1. Log level 设什么
|
||||
|
||||
@@ -41,8 +42,8 @@ debug 可能把完整模型 prompt/response 写入本机调试日志,不得长
|
||||
|
||||
## 2. 日志在哪里
|
||||
|
||||
App 内推荐直接使用“运行诊断 → 导出日志”。开发人员远程指导时,优先使用
|
||||
`memhub logs`,它会再次过滤不安全字段。原始结构化日志只用于本机现场查看,不得作为附件发送。
|
||||
App 内推荐直接使用“运行诊断 → 导出日志”,它会生成包含完整运行日志的 ZIP。开发人员远程指导
|
||||
且不需要原始日志时,优先使用 `memhub logs`,它会过滤不安全字段。不要手工打包整个数据目录。
|
||||
Core 日志默认位于:
|
||||
|
||||
```text
|
||||
|
||||
@@ -45,13 +45,18 @@ host-bridge、desktop 和 cli。单文件 4 MiB,保留 3 份轮转;额外清
|
||||
读取器只枚举上述固定文件名的 regular file,不跟随符号链接,不接受任意路径。日常本地查询
|
||||
遇到损坏行时跳过并计数,不让一行损坏遮蔽其余证据。
|
||||
|
||||
导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
|
||||
CLI 导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
|
||||
新的 `diagnostics/events.jsonl`;details 不只检查 key,还按字段约束 value 类型,并拒绝自由文本、
|
||||
路径和凭证形态的字符串。绝不复制原始 `diagnostic-*.jsonl`、普通运行日志或崩溃报告。
|
||||
导出包还可以包含固定 schema 的系统摘要和问题时间点,但不接受调用方传入的任意对象展开。
|
||||
路径和凭证形态的字符串。CLI 不复制原始日志或崩溃报告。
|
||||
|
||||
Desktop 设置页的“导出日志”用于开发人员排查本机运行故障。除上述安全投影和状态摘要外,它还会
|
||||
把 One Memory Home 的 `logs/` 目录中全部普通文件及轮转文件放入 ZIP 的 `logs/` 目录。它不跟随
|
||||
符号链接、不递归读取其他目录,也不包含数据库、SDK 数据目录或系统崩溃报告。原始日志可能包含
|
||||
会话正文、本机路径或其他私人信息,ZIP 的 README 必须明确提示只发送给可信的开发人员。
|
||||
|
||||
任何事件、marker、安全投影或日志目录读取失败时,导出必须 fail-closed:删除临时文件、不发布
|
||||
诊断包,更不能回退为打包原始日志。发布使用已设好权限的临时文件原子创建最终目标,已存在的
|
||||
目标不得覆盖,发布后也不存在再 chmod 失败留下伪成功文件的窗口。
|
||||
诊断包。发布使用已设好权限的临时文件原子创建最终目标,已存在的目标不得覆盖,发布后也不存在
|
||||
再 chmod 失败留下伪成功文件的窗口。
|
||||
|
||||
## 覆盖与节流
|
||||
|
||||
|
||||
@@ -510,6 +510,10 @@ export interface MemoryLibraryPage {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
/** Cursor paging: opaque continuation for a page whose exact total is not read. */
|
||||
next_cursor?: string | null;
|
||||
has_more?: boolean;
|
||||
total_exact?: boolean;
|
||||
/** Active evidence sources for each returned memory, from the same authorized page snapshot. */
|
||||
source_ids_by_memory?: Record<string, string[]>;
|
||||
/** When each returned memory's cited conversation happened (D96). `import`
|
||||
@@ -709,6 +713,9 @@ export interface SdkRawPage {
|
||||
limit: number;
|
||||
offset: number;
|
||||
unit?: "raw" | "turn";
|
||||
next_cursor?: string | null;
|
||||
has_more?: boolean;
|
||||
total_exact?: boolean;
|
||||
/** A time range was applied to this page in Core (the SDK list has none). */
|
||||
time_filtered?: boolean;
|
||||
}
|
||||
|
||||
@@ -877,7 +877,7 @@ const baseEnglishMessages = {
|
||||
"Keep syncing, processing and providing memories without opening the app.",
|
||||
导出日志: "Export logs",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Package local diagnostic logs into a file to share with support.",
|
||||
"Package all local logs. They may contain conversation text or local paths; share only with trusted developers.",
|
||||
"确定退出 One Memory 界面吗?": "Quit the MemHub window?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"The background service will continue syncing, processing and providing memories.",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const settingsSpanishMessages = {
|
||||
"Sincroniza, organiza y ofrece memorias sin abrir la interfaz.",
|
||||
导出日志: "Exportar registros",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Empaqueta los registros locales en un archivo para su diagnóstico.",
|
||||
"Empaqueta todos los registros locales. Pueden contener conversaciones o rutas privadas; compártelos solo con personal de desarrollo de confianza.",
|
||||
"确定退出 One Memory 界面吗?": "¿Cerrar la interfaz de MemHub?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"El servicio en segundo plano seguirá sincronizando, organizando y ofreciendo memorias.",
|
||||
@@ -362,6 +362,8 @@ export const settingsSpanishMessages = {
|
||||
const chineseOverrides: Partial<
|
||||
Record<keyof typeof settingsSpanishMessages, string>
|
||||
> = {
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"打包完整本机日志,可能包含会话正文和本机路径;只发送给可信的开发人员。",
|
||||
"确定退出 One Memory 界面吗?": "确定退出 MemHub 界面吗?",
|
||||
appSettingsTitle: "{name} 应用设置",
|
||||
appMemoryScope: "让 {name} 查询已授权范围内的记忆。",
|
||||
|
||||
@@ -1065,7 +1065,7 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
"Erinnerungen weiter synchronisieren, verarbeiten und bereitstellen, ohne die App zu öffnen.",
|
||||
导出日志: "Protokolle exportieren",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Packen Sie lokale Diagnoseprotokolle in eine Datei, um sie an den Support zu senden.",
|
||||
"Packt alle lokalen Protokolle. Sie können Gesprächsinhalte oder lokale Pfade enthalten; nur mit vertrauenswürdigen Entwicklern teilen.",
|
||||
"确定退出 One Memory 界面吗?": "MemHub-Fenster beenden?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"Der Hintergrunddienst synchronisiert, verarbeitet und liefert Erinnerungen weiter.",
|
||||
|
||||
@@ -1078,7 +1078,7 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
"Continuez à synchroniser, traiter et fournir les mémoires sans ouvrir l’application.",
|
||||
导出日志: "Exporter les journaux",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Regroupez les journaux de diagnostic locaux dans un fichier à transmettre à l’assistance.",
|
||||
"Regroupe tous les journaux locaux. Ils peuvent contenir des conversations ou des chemins locaux ; partagez-les uniquement avec des développeurs de confiance.",
|
||||
"确定退出 One Memory 界面吗?": "Quitter la fenêtre MemHub ?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"Le service en arrière-plan continuera de synchroniser, traiter et fournir les mémoires.",
|
||||
|
||||
@@ -1048,7 +1048,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
"Terus sinkronkan, proses, dan sediakan memori tanpa membuka aplikasi.",
|
||||
导出日志: "Ekspor log",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Kemas log diagnostik lokal menjadi satu file untuk dibagikan ke tim dukungan.",
|
||||
"Kemas semua log lokal. Log dapat memuat isi percakapan atau jalur lokal; bagikan hanya kepada pengembang tepercaya.",
|
||||
"确定退出 One Memory 界面吗?": "Tutup jendela MemHub?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"Layanan latar belakang akan terus menyinkronkan, memproses, dan menyediakan memori.",
|
||||
|
||||
@@ -1045,7 +1045,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
"アプリを開かなくても、記憶の同期、処理、提供を続けます。",
|
||||
导出日志: "ログを書き出す",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"ローカルの診断ログを 1 つのファイルにまとめ、サポートに共有できます。",
|
||||
"ローカルログをすべてまとめます。会話本文やローカルパスが含まれる場合があるため、信頼できる開発者にのみ共有してください。",
|
||||
"确定退出 One Memory 界面吗?": "MemHub のウインドウを終了しますか?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"バックグラウンドサービスは記憶の同期、処理、提供を続けます。",
|
||||
|
||||
@@ -1021,7 +1021,7 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
"앱을 열지 않아도 기억을 계속 동기화, 정리하고 제공합니다.",
|
||||
导出日志: "로그 내보내기",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"로컬 진단 로그를 파일 하나로 묶어 지원팀에 보냅니다.",
|
||||
"모든 로컬 로그를 묶습니다. 대화 내용이나 로컬 경로가 포함될 수 있으므로 신뢰할 수 있는 개발자에게만 공유하세요.",
|
||||
"确定退出 One Memory 界面吗?": "MemHub 창을 종료하시겠습니까?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"백그라운드 서비스는 계속 기억을 동기화, 정리하고 제공합니다.",
|
||||
|
||||
@@ -1069,7 +1069,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
"Continue sincronizando, processando e fornecendo memórias sem abrir o app.",
|
||||
导出日志: "Exportar logs",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Empacote os logs de diagnóstico locais em um arquivo para compartilhar com o suporte.",
|
||||
"Empacota todos os logs locais. Eles podem conter conversas ou caminhos locais; compartilhe apenas com desenvolvedores de confiança.",
|
||||
"确定退出 One Memory 界面吗?": "Fechar a janela do MemHub?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"O serviço em segundo plano continuará sincronizando, processando e fornecendo memórias.",
|
||||
|
||||
@@ -1053,7 +1053,7 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
"Синхронизация, обработка и предоставление памяти без открытия приложения.",
|
||||
导出日志: "Экспорт журналов",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Упакуйте локальные журналы диагностики в файл, чтобы отправить в поддержку.",
|
||||
"Упаковывает все локальные журналы. Они могут содержать текст диалогов или локальные пути; отправляйте их только доверенным разработчикам.",
|
||||
"确定退出 One Memory 界面吗?": "Закрыть окно MemHub?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"Фоновая служба продолжит синхронизацию, обработку и предоставление памяти.",
|
||||
|
||||
@@ -951,7 +951,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
"不需開啟視窗,也能持續同步、整理和提供記憶。",
|
||||
导出日志: "匯出日誌",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"把本機執行日誌打包成一個檔案,提供給開發人員排查。",
|
||||
"打包完整本機日誌,可能包含對話正文和本機路徑;只傳送給可信的開發人員。",
|
||||
"确定退出 One Memory 界面吗?": "要關閉 MemHub 視窗嗎?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"背景服務會繼續同步、整理和提供記憶。",
|
||||
|
||||
@@ -735,6 +735,76 @@ describe("diagnostic log", () => {
|
||||
expect(all).not.toContain("/Users/");
|
||||
});
|
||||
|
||||
it("optionally includes every regular file from the local logs directory", async () => {
|
||||
const dataDir = await root();
|
||||
const outputDir = await root();
|
||||
const logs = path.join(dataDir, "logs");
|
||||
await mkdir(logs, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(logs, "diagnostic-core.jsonl"),
|
||||
`${JSON.stringify({
|
||||
schema_version: 1,
|
||||
timestamp: "2026-10-05T01:00:00.000Z",
|
||||
level: "error",
|
||||
service: "core",
|
||||
component: "core.database",
|
||||
event: "database.error",
|
||||
outcome: "failed",
|
||||
session_id: "11111111-1111-4111-8111-111111111111",
|
||||
error_code: "database_locked",
|
||||
})}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(logs, "core.log"),
|
||||
"raw private conversation and full local path\n",
|
||||
);
|
||||
await writeFile(path.join(logs, "service-error.log.1"), "rotated error\n");
|
||||
await writeFile(path.join(logs, ".hidden log"), "hidden diagnostic\n");
|
||||
await writeFile(path.join(logs, "服务 日志.log"), "unicode diagnostic\n");
|
||||
await writeFile(path.join(logs, "empty.log"), "");
|
||||
await writeFile(
|
||||
path.join(logs, "large.log"),
|
||||
Buffer.alloc(256 * 1024, 120),
|
||||
);
|
||||
const outside = path.join(await root(), "outside.log");
|
||||
await writeFile(outside, "must not be followed\n");
|
||||
await symlink(outside, path.join(logs, "linked.log"));
|
||||
const target = path.join(outputDir, "MemHub.zip");
|
||||
|
||||
const result = await exportDiagnosticZip({
|
||||
dataDir,
|
||||
target,
|
||||
productVersion: "3800",
|
||||
includeRawLogs: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ filename: "MemHub.zip", file_count: 10 });
|
||||
const files = unzipSync(await readFile(target));
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
"README.txt",
|
||||
"diagnostics/events.jsonl",
|
||||
"logs/.hidden log",
|
||||
"logs/core.log",
|
||||
"logs/diagnostic-core.jsonl",
|
||||
"logs/empty.log",
|
||||
"logs/large.log",
|
||||
"logs/service-error.log.1",
|
||||
"logs/服务 日志.log",
|
||||
"summary.json",
|
||||
]);
|
||||
expect(strFromU8(files["logs/core.log"] ?? new Uint8Array())).toContain(
|
||||
"raw private conversation",
|
||||
);
|
||||
expect(strFromU8(files["README.txt"] ?? new Uint8Array())).toContain(
|
||||
"原始日志可能包含会话正文",
|
||||
);
|
||||
expect(files["logs/linked.log"]).toBeUndefined();
|
||||
expect(files["logs/empty.log"]).toHaveLength(0);
|
||||
expect(files["logs/large.log"]).toHaveLength(256 * 1024);
|
||||
if (process.platform !== "win32")
|
||||
expect((await lstat(target)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("fails closed without publishing a ZIP when a diagnostic line is invalid", async () => {
|
||||
const dataDir = await root();
|
||||
await mkdir(path.join(dataDir, "logs"), { recursive: true });
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import {
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
constants,
|
||||
createWriteStream,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
import {
|
||||
appendFile,
|
||||
chmod,
|
||||
type FileHandle,
|
||||
link,
|
||||
lstat,
|
||||
mkdir,
|
||||
@@ -25,7 +28,7 @@ import {
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { strToU8, zipSync } from "fflate";
|
||||
import { strToU8, Zip, ZipPassThrough, zipSync } from "fflate";
|
||||
|
||||
export const DIAGNOSTIC_SCHEMA_VERSION = 1 as const;
|
||||
export const DIAGNOSTIC_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
||||
@@ -627,6 +630,7 @@ export async function exportDiagnosticZip(options: {
|
||||
now?: Date;
|
||||
markers?: DiagnosticMarker[];
|
||||
summary?: Record<string, unknown>;
|
||||
includeRawLogs?: boolean;
|
||||
}): Promise<{ filename: string; file_count: number }> {
|
||||
const now = options.now ?? new Date();
|
||||
const home = options.home ?? os.homedir();
|
||||
@@ -672,7 +676,14 @@ export async function exportDiagnosticZip(options: {
|
||||
"MemHub diagnostics",
|
||||
"",
|
||||
"包含:最近 7 天通过安全白名单重新投影的诊断事件,以及不含正文的状态摘要。",
|
||||
"不包含:原始日志、崩溃报告、对话原文、记忆内容、API Key、token 或宿主凭证。",
|
||||
...(options.includeRawLogs
|
||||
? [
|
||||
"同时包含:One Memory Home 的 logs/ 目录中全部普通日志文件及轮转文件。",
|
||||
"注意:原始日志可能包含会话正文、本机路径或其他私人信息,只应发送给可信的开发人员。",
|
||||
]
|
||||
: [
|
||||
"不包含:原始日志、崩溃报告、对话原文、记忆内容、API Key、token 或宿主凭证。",
|
||||
]),
|
||||
...(markers.length
|
||||
? [
|
||||
"",
|
||||
@@ -689,20 +700,30 @@ export async function exportDiagnosticZip(options: {
|
||||
const directory = path.dirname(options.target);
|
||||
await mkdir(directory, { recursive: true });
|
||||
const temporary = path.join(directory, `.memhub-logs-${randomUUID()}.tmp`);
|
||||
const rawLogs = options.includeRawLogs
|
||||
? await openRawLogSnapshots(options.dataDir)
|
||||
: [];
|
||||
try {
|
||||
await writeFile(temporary, zipSync(archive, { level: 6 }), {
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
if (options.includeRawLogs)
|
||||
await writeStreamingDiagnosticZip(temporary, archive, rawLogs);
|
||||
else
|
||||
await writeFile(temporary, zipSync(archive, { level: 6 }), {
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
await chmod(temporary, 0o600);
|
||||
await publishTemporaryFile(temporary, options.target);
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
await Promise.all(
|
||||
rawLogs.map((file) => file.handle.close().catch(() => undefined)),
|
||||
);
|
||||
}
|
||||
return {
|
||||
filename: path.basename(options.target),
|
||||
file_count: Object.keys(archive).length,
|
||||
file_count: Object.keys(archive).length + rawLogs.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -902,6 +923,151 @@ async function readRegularFileNoFollow(
|
||||
}
|
||||
}
|
||||
|
||||
interface RawLogSnapshot {
|
||||
name: string;
|
||||
handle: FileHandle;
|
||||
size: number;
|
||||
}
|
||||
|
||||
async function openRawLogSnapshots(dataDir: string): Promise<RawLogSnapshot[]> {
|
||||
const directory = diagnosticDirectory(dataDir);
|
||||
let directoryInfo: Awaited<ReturnType<typeof lstat>>;
|
||||
try {
|
||||
directoryInfo = await lstat(directory);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink())
|
||||
fail("diagnostic_directory_unsafe");
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const snapshots: RawLogSnapshot[] = [];
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if (
|
||||
entry.name === "." ||
|
||||
entry.name === ".." ||
|
||||
[...entry.name].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return (
|
||||
character === "/" || character === "\\" || code < 32 || code === 127
|
||||
);
|
||||
})
|
||||
)
|
||||
fail("diagnostic_log_name_unsafe");
|
||||
const target = path.join(directory, entry.name);
|
||||
const before = await lstat(target).catch(() => null);
|
||||
if (!before?.isFile() || before.isSymbolicLink()) continue;
|
||||
const handle = await open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
let retained = false;
|
||||
try {
|
||||
const opened = await handle.stat();
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino
|
||||
)
|
||||
fail("diagnostic_file_unsafe");
|
||||
snapshots.push({ name: entry.name, handle, size: opened.size });
|
||||
retained = true;
|
||||
} finally {
|
||||
if (!retained) await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
const after = await lstat(directory);
|
||||
if (
|
||||
!after.isDirectory() ||
|
||||
after.isSymbolicLink() ||
|
||||
after.dev !== directoryInfo.dev ||
|
||||
after.ino !== directoryInfo.ino
|
||||
)
|
||||
fail("diagnostic_directory_unsafe");
|
||||
return snapshots.sort((left, right) => left.name.localeCompare(right.name));
|
||||
} catch (error) {
|
||||
await Promise.all(
|
||||
snapshots.map((file) => file.handle.close().catch(() => undefined)),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeStreamingDiagnosticZip(
|
||||
target: string,
|
||||
archive: Record<string, Uint8Array>,
|
||||
rawLogs: RawLogSnapshot[],
|
||||
): Promise<void> {
|
||||
const output = createWriteStream(target, { flags: "wx", mode: 0o600 });
|
||||
let outputError: Error | null = null;
|
||||
let backpressure = Promise.resolve();
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
output.once("finish", resolve);
|
||||
output.once("error", (error) => {
|
||||
outputError = error;
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
// The producer may still be reading a source file when the output stream
|
||||
// fails. Attach a rejection handler immediately; the awaited promise below
|
||||
// still carries the same error into the export's fail-closed cleanup.
|
||||
void finished.catch(() => undefined);
|
||||
const zip = new Zip((error, chunk, final) => {
|
||||
if (error) {
|
||||
outputError = error;
|
||||
output.destroy(error);
|
||||
return;
|
||||
}
|
||||
if (chunk.length > 0 && !output.write(chunk))
|
||||
backpressure = once(output, "drain").then(() => undefined);
|
||||
if (final) output.end();
|
||||
});
|
||||
const ensureWritable = async () => {
|
||||
await backpressure;
|
||||
if (outputError) throw outputError;
|
||||
};
|
||||
try {
|
||||
for (const [name, content] of Object.entries(archive)) {
|
||||
await ensureWritable();
|
||||
const input = new ZipPassThrough(name);
|
||||
zip.add(input);
|
||||
input.push(content, true);
|
||||
}
|
||||
for (const file of rawLogs) {
|
||||
await ensureWritable();
|
||||
const input = new ZipPassThrough(`logs/${file.name}`);
|
||||
zip.add(input);
|
||||
let position = 0;
|
||||
while (position < file.size) {
|
||||
await ensureWritable();
|
||||
const buffer = Buffer.allocUnsafe(
|
||||
Math.min(64 * 1024, file.size - position),
|
||||
);
|
||||
const { bytesRead } = await file.handle.read(
|
||||
buffer,
|
||||
0,
|
||||
buffer.length,
|
||||
position,
|
||||
);
|
||||
if (bytesRead < 1) fail("diagnostic_file_changed");
|
||||
position += bytesRead;
|
||||
input.push(buffer.subarray(0, bytesRead), position === file.size);
|
||||
}
|
||||
if (file.size === 0) input.push(new Uint8Array(), true);
|
||||
}
|
||||
await ensureWritable();
|
||||
zip.end();
|
||||
await finished;
|
||||
} catch (error) {
|
||||
zip.terminate();
|
||||
output.destroy(error instanceof Error ? error : undefined);
|
||||
await finished.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function diagnosticLogFiles(dataDir: string): Promise<string[]> {
|
||||
const directory = diagnosticDirectory(dataDir);
|
||||
let directoryInfo: Awaited<ReturnType<typeof lstat>>;
|
||||
|
||||
Reference in new issue
Block a user