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 parked pre-invocation work, and drop only the obsolete
`hy_node_permissions` proof table. Evidence, governed memories, model `hy_node_permissions` proof table. Evidence, governed memories, model
profiles, SDK stores, and source/host grants are preserved. 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 ## Hy Memory 2.0.5 — 2026-10-22
+15 -18
View File
@@ -1,15 +1,17 @@
import type { import {
GovernedMemory, CANDIDATE_KINDS,
HomeSummary, type CandidateKind,
ImportIssue, type GovernedMemory,
ImportJob, type HomeSummary,
LiveSyncHealth, type ImportIssue,
MemoryDetail, type ImportJob,
MemoryEvidenceExcerpt, type LiveSyncHealth,
MemoryLibraryPage, type MemoryDetail,
MemoryProcessingSummary, type MemoryEvidenceExcerpt,
ModelProfile, type MemoryLibraryPage,
SourceResponse, type MemoryProcessingSummary,
type ModelProfile,
type SourceResponse,
} from "@one-memory/contracts"; } from "@one-memory/contracts";
/** The only memory states a response may carry, and the only ones a filter may ask for. */ /** 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; export const MEMORY_STATUSES = ["confirmed", "deleted", "superseded"] as const;
@@ -74,11 +76,6 @@ const SYNC_COMPLETENESS = [
"additions_only", "additions_only",
"one_time_snapshot", "one_time_snapshot",
] as const; ] as const;
const CANDIDATE_KINDS = [
"default",
"environment_override",
"user_selected",
] as const;
const LOCATION_STATES = [ const LOCATION_STATES = [
"found", "found",
"not_found", "not_found",
@@ -100,7 +97,7 @@ export interface ValidatedMemoryTask {
export interface SourceConnectionCandidate { export interface SourceConnectionCandidate {
source_id: string; source_id: string;
display_name: string; display_name: string;
candidate_kind: (typeof CANDIDATE_KINDS)[number]; candidate_kind: CandidateKind;
candidate_root_id: string; candidate_root_id: string;
candidate_revision?: number; candidate_revision?: number;
requires_override_confirmation: boolean; 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_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_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_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_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_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 场景。 | | `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_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. | | `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 import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live") PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 323 EXPECTED_TEST_FILE_COUNT = 324
EXPECTED_TEST_FILES_SHA256 = "10a2327bd047a281511980f5b558c1ea64d857f0fff550e4bd96cb53800205ba" EXPECTED_TEST_FILES_SHA256 = "3fcc7a3336de56aa8fa6b0dddd6101441a837095c89dd5f31d2a1b66ec7e3764"
# Pure transformations, validation models, parity checks and state machines. # Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract. # 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]); 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([ it.each([
["negative messages", { imported_messages: -1 }], ["negative messages", { imported_messages: -1 }],
["NaN messages", { imported_messages: Number.NaN }], ["NaN messages", { imported_messages: Number.NaN }],
+8 -2
View File
@@ -6,6 +6,8 @@ import type {
MatterRun, MatterRun,
} from "@one-memory/contracts"; } from "@one-memory/contracts";
import { import {
CANDIDATE_KINDS,
type CandidateKind,
CORE_SERVICE, CORE_SERVICE,
type ConnectSourcesResponse, type ConnectSourcesResponse,
type ConversationBrowserPage, type ConversationBrowserPage,
@@ -1544,8 +1546,12 @@ export function isSourceResponse(value: unknown): value is SourceResponse {
typeof candidate.candidate_path === "string" && typeof candidate.candidate_path === "string" &&
measuredCount(candidate.imported_messages) && measuredCount(candidate.imported_messages) &&
measuredCount(candidate.imported_conversations) && measuredCount(candidate.imported_conversations) &&
(candidate.candidate_kind === "default" || // Taken from the contract rather than spelled out here: this list used to
candidate.candidate_kind === "environment_override") && // 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 // Guarded rather than defaulted: an unrecognised value must not quietly
// read as "additions_only" and tell the user we track less than we do, // read as "additions_only" and tell the user we track less than we do,
// nor as "full_dynamic" and promise more. // nor as "full_dynamic" and promise more.
+15 -2
View File
@@ -147,6 +147,19 @@ export type SourceAuthorizationState =
| "source_root_unavailable" | "source_root_unavailable"
| "candidate_changed"; | "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 { export interface SourceResponse {
source_id: string; source_id: string;
display_name: string; display_name: string;
@@ -157,7 +170,7 @@ export interface SourceResponse {
detected: boolean | null; detected: boolean | null;
root_label: string | null; root_label: string | null;
candidate_path: string; candidate_path: string;
candidate_kind: "default" | "environment_override" | "user_selected"; candidate_kind: CandidateKind;
candidate_revision?: number; candidate_revision?: number;
candidate_change_allowed?: boolean; candidate_change_allowed?: boolean;
candidate_change_reason?: string | null; candidate_change_reason?: string | null;
@@ -190,7 +203,7 @@ export interface SourceGrantReceipt {
source_id: string; source_id: string;
root_id: string; root_id: string;
root_label: string; root_label: string;
candidate_kind: "default" | "environment_override" | "user_selected"; candidate_kind: CandidateKind;
granted_at: string; granted_at: string;
revoked_at: string | null; revoked_at: string | null;
revision: number; revision: number;