Compare commits

...
Author SHA1 Message Date
kristinhe 0fd3211f9c fix(memhub): stop rejecting a source list Core can grant
The applications list was empty on every launch while every endpoint
answered 200 (issue #139). `useSources()` is cache-only, so the list is
whatever `getSources()` committed, and its transport guard spelled the root
kinds by hand — only `default` and `environment_override`. Core has a third,
`user_selected`, which is what a source gets when the person picks its root.
One such row failed the guard for the whole list, and the mount-time read is a
single `Promise.all`, so `commitSources` never ran and
`sourceSnapshot.confirmedAt` was never written.

The kinds are now listed once, in the contract, and both the web guard and the
CLI validator read that list instead of repeating it. A parity test pins Core's
`RootCandidateKind` to it, because a union is erased at build time and nothing
else would notice the next divergence.

An unrecognised kind is still rejected. A source Core grants a root to is not.
2026-10-24 08:15:29 +08:00
8 changed files with 106 additions and 25 deletions

No files matched your search

+5
View File
@@ -38,6 +38,11 @@ Hy Memory and MemHub are versioned and released independently.
parked pre-invocation work, and drop only the obsolete
`hy_node_permissions` proof table. Evidence, governed memories, model
profiles, SDK stores, and source/host grants are preserved.
- Fixed the applications list going permanently blank when a source's root was
chosen by the person rather than detected. The web transport guard listed
the root kinds by hand and rejected the whole list over the `user_selected`
kind Core already grants; the kinds now come from the shared contract, so the
two cannot drift again, and a parity test pins Core's enumeration to it.
## Hy Memory 2.0.5 — 2026-10-22
+15 -18
View File
@@ -1,15 +1,17 @@
import type {
GovernedMemory,
HomeSummary,
ImportIssue,
ImportJob,
LiveSyncHealth,
MemoryDetail,
MemoryEvidenceExcerpt,
MemoryLibraryPage,
MemoryProcessingSummary,
ModelProfile,
SourceResponse,
import {
CANDIDATE_KINDS,
type CandidateKind,
type GovernedMemory,
type HomeSummary,
type ImportIssue,
type ImportJob,
type LiveSyncHealth,
type MemoryDetail,
type MemoryEvidenceExcerpt,
type MemoryLibraryPage,
type MemoryProcessingSummary,
type ModelProfile,
type SourceResponse,
} from "@one-memory/contracts";
/** The only memory states a response may carry, and the only ones a filter may ask for. */
export const MEMORY_STATUSES = ["confirmed", "deleted", "superseded"] as const;
@@ -74,11 +76,6 @@ const SYNC_COMPLETENESS = [
"additions_only",
"one_time_snapshot",
] as const;
const CANDIDATE_KINDS = [
"default",
"environment_override",
"user_selected",
] as const;
const LOCATION_STATES = [
"found",
"not_found",
@@ -100,7 +97,7 @@ export interface ValidatedMemoryTask {
export interface SourceConnectionCandidate {
source_id: string;
display_name: string;
candidate_kind: (typeof CANDIDATE_KINDS)[number];
candidate_kind: CandidateKind;
candidate_root_id: string;
candidate_revision?: number;
requires_override_confirmation: boolean;
+2 -1
View File
@@ -287,6 +287,7 @@
| `test_source_authorization_state.py` | `unit` | Why a source cannot be read, named separately from whether it can. |
| `test_source_candidate_api.py` | `integration` | Candidate location changes must not implicitly grant or import anything. |
| `test_source_candidate_connections.py` | `contract` | 覆盖 empty candidate revisions preserve default fingerprint、persisted candidate requires exact revision、connection resolves candidate in transaction and prior replay never regrants 等 7 个场景。 |
| `test_source_candidate_kind_parity.py` | `contract` | 来源根目录类型在三处必须同时存在:python 枚举、TS 契约、前端运行时校验。 |
| `test_source_candidates.py` | `contract` | 覆盖 preview is metadata only and cancel changes nothing、select persists only candidate and restarts with revision、compare and swap and aba cannot reuse old preview 等 9 个场景。 |
| `test_source_connection_intents.py` | `integration` | No user data: connection retries must retain the original intent and job. |
| `test_source_discovery.py` | `contract` | 覆盖 discovery is sorted bounded and suffix scoped、discovery does not follow directory or file symlinks 场景。 |
@@ -328,4 +329,4 @@
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
共 323 个测试文件。
共 324 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 323
EXPECTED_TEST_FILES_SHA256 = "10a2327bd047a281511980f5b558c1ea64d857f0fff550e4bd96cb53800205ba"
EXPECTED_TEST_FILE_COUNT = 324
EXPECTED_TEST_FILES_SHA256 = "3fcc7a3336de56aa8fa6b0dddd6101441a837095c89dd5f31d2a1b66ec7e3764"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -0,0 +1,38 @@
"""来源根目录类型在三处必须同时存在:python 枚举、TS 契约、前端运行时校验。
起因(issue #139):「我的应用」永久空白,所有接口 200,但前端 sources 恒为空。
根因是 web 侧 `isSourceResponse` 手写了一份类型清单,只列了 default 与
environment_override;Core 后来加了 user_selected(用户自己选的目录,
`sources/connect` 带 source_roots 时就是它)。一行这样的记录就会让整张列表
被拒,而承载它的 prefetch 不允许抛错上报 —— 于是「应用列表暂时不可读取」,
没有任何一处会红。
TS 联合类型漏一个成员不会让构建失败,这里补上那道守卫;前端改成从契约取清单
之后,剩下的风险只有 python 与 TS 之间再漂移。
"""
import re
from pathlib import Path
from one_memory.imports.sources.registry import RootCandidateKind
REPO_ROOT = Path(__file__).resolve().parents[3]
CONTRACT = REPO_ROOT / "packages" / "contracts" / "src" / "index.ts"
def _typescript_kinds() -> set[str]:
source = CONTRACT.read_text(encoding="utf-8")
match = re.search(
r"export const CANDIDATE_KINDS = \[(.*?)\] as const;", source, re.DOTALL
)
assert match, f"没在 {CONTRACT} 里找到 CANDIDATE_KINDS"
return set(re.findall(r'"([a-z_]+)"', match.group(1)))
def test_python_and_the_contract_name_the_same_kinds() -> None:
python_kinds = {kind.value for kind in RootCandidateKind}
assert python_kinds == _typescript_kinds(), (
"来源根目录类型在 python 与 TS 契约之间不一致:"
f"python={sorted(python_kinds)} ts={sorted(_typescript_kinds())}。"
f"请同步 {CONTRACT.relative_to(REPO_ROOT)} 的 CANDIDATE_KINDS。"
)
@@ -227,6 +227,27 @@ describe("source list transport validation", () => {
await expect(getSources()).resolves.toEqual([source]);
});
// Every kind Core can grant, not just the ones this guard happened to know.
// A source whose root the person picked themselves is `user_selected`; the
// hand-written list here skipped it, so one such row rejected the whole
// list and every app disappeared while all endpoints answered 200.
it.each(["default", "environment_override", "user_selected"])(
"accepts the %s candidate kind",
async (candidate_kind) => {
fake.get.mockResolvedValue({ data: [{ ...source, candidate_kind }] });
await expect(getSources()).resolves.toEqual([
{ ...source, candidate_kind },
]);
},
);
it("rejects a candidate kind Core cannot grant", async () => {
fake.get.mockResolvedValue({
data: [{ ...source, candidate_kind: "invented" }],
});
await expect(getSources()).rejects.toThrow("invalid source list");
});
it.each([
["negative messages", { imported_messages: -1 }],
["NaN messages", { imported_messages: Number.NaN }],
+8 -2
View File
@@ -6,6 +6,8 @@ import type {
MatterRun,
} from "@one-memory/contracts";
import {
CANDIDATE_KINDS,
type CandidateKind,
CORE_SERVICE,
type ConnectSourcesResponse,
type ConversationBrowserPage,
@@ -1544,8 +1546,12 @@ export function isSourceResponse(value: unknown): value is SourceResponse {
typeof candidate.candidate_path === "string" &&
measuredCount(candidate.imported_messages) &&
measuredCount(candidate.imported_conversations) &&
(candidate.candidate_kind === "default" ||
candidate.candidate_kind === "environment_override") &&
// Taken from the contract rather than spelled out here: this list used to
// be written by hand, Core gained `user_selected`, and one row of that kind
// failed the guard for the whole list. Nothing renders, every endpoint
// answers 200, and the prefetch that carried the rejection cannot report
// it (issue #139).
CANDIDATE_KINDS.includes(candidate.candidate_kind as CandidateKind) &&
// Guarded rather than defaulted: an unrecognised value must not quietly
// read as "additions_only" and tell the user we track less than we do,
// nor as "full_dynamic" and promise more.
+15 -2
View File
@@ -147,6 +147,19 @@ export type SourceAuthorizationState =
| "source_root_unavailable"
| "candidate_changed";
/** The kinds of root a source can be granted for — Core's `RootCandidateKind`.
*
* A value, not only a type, because the web layer has to check what Core sent
* at runtime. Issue #139: the transport guard listed the kinds by hand, Core
* added `user_selected`, and one such row rejected the entire source list.
*/
export const CANDIDATE_KINDS = [
"default",
"environment_override",
"user_selected",
] as const;
export type CandidateKind = (typeof CANDIDATE_KINDS)[number];
export interface SourceResponse {
source_id: string;
display_name: string;
@@ -157,7 +170,7 @@ export interface SourceResponse {
detected: boolean | null;
root_label: string | null;
candidate_path: string;
candidate_kind: "default" | "environment_override" | "user_selected";
candidate_kind: CandidateKind;
candidate_revision?: number;
candidate_change_allowed?: boolean;
candidate_change_reason?: string | null;
@@ -190,7 +203,7 @@ export interface SourceGrantReceipt {
source_id: string;
root_id: string;
root_label: string;
candidate_kind: "default" | "environment_override" | "user_selected";
candidate_kind: CandidateKind;
granted_at: string;
revoked_at: string | null;
revision: number;