Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fd3211f9c |
No files matched your search
+5
-6
@@ -38,12 +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.
|
||||
- A memory whose cited evidence row has been released no longer reads as a
|
||||
revoked source. The read policy is now spelled once instead of in four
|
||||
copies, so the memory library, the detail endpoint, call-event references
|
||||
and context assembly agree that missing provenance is a fact about the
|
||||
citation, not about the grant. Real revocations still hide memories; they
|
||||
are recorded on the grant and never on the evidence row.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,7 +10,6 @@ from pydantic import Field, model_validator
|
||||
from one_memory.call_event_store import CallEvent, CallReference
|
||||
from one_memory.database_access import main_database
|
||||
from one_memory.memory_evidence_freshness import register_memory_freshness
|
||||
from one_memory.source_read_access import memory_source_readable
|
||||
|
||||
|
||||
class SourceRecordRef(CallReference):
|
||||
@@ -77,13 +76,17 @@ def _memory(db: sqlite3.Connection, reference: CallReference) -> SourceRecordRef
|
||||
if row["status"] == "deleted" or row["deleted_at"] is not None:
|
||||
return _hidden(reference)
|
||||
|
||||
# Same local revocation/ownership predicate as SourceReadAccess.memories,
|
||||
# taken from there rather than copied: its public methods open their own
|
||||
# connections, so the predicate is the part that can be shared.
|
||||
# Same local revocation/ownership predicate as SourceReadAccess.memories.
|
||||
# Its public methods open their own connections, so cannot be used here.
|
||||
access = db.execute(
|
||||
"SELECT EXISTS (SELECT 1 FROM governed_memory_evidence "
|
||||
"WHERE memory_id=? AND active=1), " + memory_source_readable("?", "?"),
|
||||
(reference.id, reference.id, "local"),
|
||||
"WHERE memory_id=? AND active=1), NOT EXISTS ("
|
||||
"SELECT 1 FROM governed_memory_evidence link "
|
||||
"LEFT JOIN evidence e ON e.evidence_id=link.evidence_id "
|
||||
"LEFT JOIN source_grants g ON g.user_id=e.user_id AND g.source_id=e.source_id "
|
||||
"WHERE link.memory_id=? AND +link.active=1 "
|
||||
"AND (e.evidence_id IS NULL OR e.user_id!='local' OR g.revoked_at IS NOT NULL))",
|
||||
(reference.id, reference.id),
|
||||
).fetchone()
|
||||
if not all(access):
|
||||
return _hidden(reference)
|
||||
|
||||
@@ -25,7 +25,6 @@ from one_memory.memory_evidence_freshness import register_memory_freshness
|
||||
from one_memory.recall import _terms
|
||||
from one_memory.retrieval.models import Channel, RetrievalPath, TimeRange
|
||||
from one_memory.semantic_deadline import SemanticMemoryHit, SemanticOutcome
|
||||
from one_memory.source_read_access import memory_source_readable
|
||||
|
||||
_BODY_LIMIT = 20000
|
||||
_REFERENCE_LIMIT = 128
|
||||
@@ -284,18 +283,28 @@ def _memory_readable(freshness: str) -> str:
|
||||
return _READABLE + f" AND {freshness}"
|
||||
|
||||
|
||||
# Match the local governed read policy before scoring or candidate LIMIT. That
|
||||
# policy is `source_read_access`'s and is spelled once there so it cannot drift
|
||||
# from the endpoint that answers 403; this module only adds its own status
|
||||
# filter on top.
|
||||
_READABLE = f"""
|
||||
# Match the local governed read policy before scoring or candidate LIMIT. A
|
||||
# missing grant is not a revocation; a missing/cross-user active citation is.
|
||||
_READABLE = """
|
||||
m.user_id = 'local' AND m.status = 'confirmed' AND m.deleted_at IS NULL
|
||||
AND {memory_source_readable("m.memory_id", "m.user_id")}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM governed_memory_evidence link
|
||||
LEFT JOIN evidence e ON e.evidence_id = link.evidence_id
|
||||
LEFT JOIN source_grants g ON g.user_id = e.user_id AND g.source_id = e.source_id
|
||||
WHERE link.memory_id = m.memory_id AND +link.active = 1
|
||||
AND (e.evidence_id IS NULL OR e.user_id != m.user_id OR g.revoked_at IS NOT NULL)
|
||||
)
|
||||
"""
|
||||
|
||||
_SDK_MEMBER_READABLE = f"""
|
||||
_SDK_MEMBER_READABLE = """
|
||||
m.user_id = 'local' AND m.status != 'deleted' AND m.deleted_at IS NULL
|
||||
AND {memory_source_readable("m.memory_id", "m.user_id")}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM governed_memory_evidence link
|
||||
LEFT JOIN evidence e ON e.evidence_id = link.evidence_id
|
||||
LEFT JOIN source_grants g ON g.user_id = e.user_id AND g.source_id = e.source_id
|
||||
WHERE link.memory_id = m.memory_id AND +link.active = 1
|
||||
AND (e.evidence_id IS NULL OR e.user_id != m.user_id OR g.revoked_at IS NOT NULL)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -14,44 +14,24 @@ from one_memory.memory_governance import GovernedMemory
|
||||
from one_memory.memory_visibility import mode as visibility_mode
|
||||
from one_memory.memory_visibility import readable_ids
|
||||
|
||||
|
||||
def memory_source_readable(memory_id: str, user_id: str) -> str:
|
||||
"""SQL: no active citation of this memory sits behind a revoked source.
|
||||
|
||||
Takes expressions rather than values because the callers differ: the
|
||||
checks here bind a parameter, `context_memory` reads an already-open row.
|
||||
|
||||
Absence is not revocation, and used to be read as one. A citation whose
|
||||
`evidence` row is gone proves nothing about the grant — the row was
|
||||
released as import staging, not by the user — so it neither hides the
|
||||
memory nor excuses it. `_revoked_evidence` below has always answered it
|
||||
that way; only this predicate disagreed, and it answered 403
|
||||
`memory_source_revoked` for 85 of 2,382 memories on two sources nobody
|
||||
revoked (issue #157). Revocation itself is unaffected: it marks the grant
|
||||
and never deletes evidence, so a revoked source still has a row here to
|
||||
match against.
|
||||
|
||||
`+link.active` keeps SQLite on the (memory_id, evidence_id) primary key.
|
||||
Left on its own, SQLite picks the (active, evidence_id, memory_id) index
|
||||
and walks every active link in the library once per memory, which on a
|
||||
1,226-memory library took 5.4 s for 500 memories and hit the 5 s
|
||||
interrupt, so the memory library and the memory map both answered 503
|
||||
(D78 missed this path).
|
||||
"""
|
||||
return f"""
|
||||
NOT EXISTS (
|
||||
# A memory is readable while every active citation is: still current, owned by
|
||||
# the reader, and not on a revoked source. `+link.active` keeps SQLite on the
|
||||
# (memory_id, evidence_id) primary key. Left on its own, SQLite picks the
|
||||
# (active, evidence_id, memory_id) index and walks every active link in the
|
||||
# library once per memory, which on a 1,226-memory library took 5.4 s for 500
|
||||
# memories and hit the 5 s interrupt, so the memory library and the memory map
|
||||
# both answered 503 (D78 missed this path).
|
||||
_MEMORY_READABLE = """
|
||||
is_memory_evidence_current(?,?,?) AND NOT EXISTS (
|
||||
SELECT 1 FROM governed_memory_evidence link
|
||||
LEFT JOIN evidence e ON e.evidence_id=link.evidence_id
|
||||
LEFT JOIN source_grants g ON g.user_id=e.user_id AND g.source_id=e.source_id
|
||||
WHERE link.memory_id={memory_id} AND +link.active=1
|
||||
AND (e.user_id!={user_id} OR g.revoked_at IS NOT NULL)
|
||||
WHERE link.memory_id=? AND +link.active=1
|
||||
AND (e.evidence_id IS NULL OR e.user_id!=? OR g.revoked_at IS NOT NULL)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
_MEMORY_READABLE = "is_memory_evidence_current(?,?,?) AND " + memory_source_readable("?", "?")
|
||||
|
||||
|
||||
class SourceReadAccess:
|
||||
def __init__(self, database_path: Path) -> None:
|
||||
self.database_path = database_path
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -131,10 +131,7 @@ def test_scope_user_status_and_active_source_isolation(database):
|
||||
revoke(db, "workbuddy")
|
||||
revoke(db, "cursor", user="other")
|
||||
assert ids(read(database, scopes=frozenset({"workspace:one"}))) == ["workspace"]
|
||||
# `dangling` cites an evidence row that is gone. That is released
|
||||
# provenance, not a revocation, so the memory reads like any other.
|
||||
assert sorted(ids(read(database, scopes=frozenset({"all-apps"})))) == [
|
||||
"dangling",
|
||||
assert ids(read(database, scopes=frozenset({"all-apps"}))) == [
|
||||
"global",
|
||||
"inactive",
|
||||
"no-evidence",
|
||||
@@ -378,7 +375,7 @@ def test_lock_wait_is_bounded_by_shared_deadline(database):
|
||||
writer.rollback()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["revoke", "revision", "scope", "delete", "user"])
|
||||
@pytest.mark.parametrize("change", ["revoke", "revision", "scope", "delete", "bad-link", "user"])
|
||||
def test_final_revalidation_drops_changed_items_without_new_text(database, change):
|
||||
with closing(sqlite3.connect(database)) as db, db:
|
||||
put(db, "change", links=("e2",))
|
||||
@@ -388,6 +385,8 @@ def test_final_revalidation_drops_changed_items_without_new_text(database, chang
|
||||
with closing(sqlite3.connect(database)) as db, db:
|
||||
if change == "revoke":
|
||||
revoke(db, "workbuddy")
|
||||
elif change == "bad-link":
|
||||
db.execute("DELETE FROM evidence WHERE evidence_id='e2'")
|
||||
else:
|
||||
assignment = {
|
||||
"revision": "revision=8,content='new text never return'",
|
||||
@@ -403,25 +402,6 @@ def test_final_revalidation_drops_changed_items_without_new_text(database, chang
|
||||
assert validated[0] is original[1]
|
||||
|
||||
|
||||
def test_released_provenance_is_not_a_change_to_the_memory(database):
|
||||
"""Revalidation is about the memory, and its evidence row is not it.
|
||||
|
||||
A citation can be released as import staging; that says nothing about the
|
||||
text, the scopes or the grant, so the item keeps its place in the batch.
|
||||
"""
|
||||
with closing(sqlite3.connect(database)) as db, db:
|
||||
put(db, "released", links=("e2",))
|
||||
original = read(database, scopes=frozenset({"all-apps"})).items
|
||||
assert [item.memory_id for item in original] == ["released"]
|
||||
with closing(sqlite3.connect(database)) as db, db:
|
||||
db.execute("DELETE FROM evidence WHERE evidence_id='e2'")
|
||||
validated = revalidate_memory_context(
|
||||
database, original, scopes=frozenset({"all-apps"}), deadline=time.monotonic() + 2
|
||||
)
|
||||
assert [item.memory_id for item in validated] == ["released"]
|
||||
assert validated[0] is original[0]
|
||||
|
||||
|
||||
def test_final_revalidation_failure_propagates_for_aggregator_not_false_empty(database):
|
||||
with closing(sqlite3.connect(database)) as db, db:
|
||||
put(db, "memory")
|
||||
|
||||
@@ -21,13 +21,14 @@ def _predicates():
|
||||
home = re.search(r'readable = """(.*?)"""', _source(home_summary), re.S).group(1)
|
||||
library = _source(memory_library)
|
||||
library_clauses = re.findall(r'clauses\.append\("""(NOT EXISTS.*?)"""\)', library, re.S)
|
||||
access = re.search(
|
||||
r"_MEMORY_READABLE = \"\"\"(.*?)\"\"\"", _source(source_read_access), re.S
|
||||
).group(1)
|
||||
return {
|
||||
"home": (home, ("local",)),
|
||||
"library": ("m.user_id = ? AND " + " AND ".join(library_clauses), ("local",)),
|
||||
"context": (context_memory._READABLE, ()),
|
||||
# Built rather than lifted out of the module's source: the policy is a
|
||||
# function now, so what has to be planned is what it returns.
|
||||
"source_read_access": (source_read_access._MEMORY_READABLE, ()),
|
||||
"source_read_access": (access, ()),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -134,76 +134,3 @@ def test_the_read_does_not_scale_its_query_count_with_the_page(library):
|
||||
# One batched read per call, carrying the whole page — not one per memory.
|
||||
assert small == [3]
|
||||
assert large == [12]
|
||||
|
||||
|
||||
def test_a_citation_without_its_evidence_row_stays_readable(tmp_path) -> None:
|
||||
"""Missing provenance is not a revoked source.
|
||||
|
||||
Regression: `_MEMORY_READABLE` treated an active citation whose `evidence`
|
||||
row was gone as unreadable and answered 403 `memory_source_revoked`. The
|
||||
memory's own content is in `governed_memories`, and the detail response
|
||||
already degrades a missing citation to `available=False`, so the upstream
|
||||
403 made that degradation unreachable and blamed a source nobody revoked.
|
||||
"""
|
||||
from contextlib import closing
|
||||
|
||||
from one_memory.memory_governance import HyCreate, MemoryOrigin
|
||||
|
||||
database = tmp_path / "one-memory.sqlite3"
|
||||
with closing(sqlite3.connect(database)) as setup:
|
||||
setup.executescript(GRANT_SCHEMA)
|
||||
evidence = EvidenceStore(database)
|
||||
governance = MemoryGovernanceStore(database)
|
||||
record = evidence.put(
|
||||
EvidenceInput(
|
||||
source_id="workbuddy",
|
||||
conversation_id="fixture",
|
||||
message_id="one",
|
||||
role="user",
|
||||
written_by=WrittenBy.PERSON,
|
||||
verbatim="真实对话内容。",
|
||||
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
||||
)
|
||||
).record
|
||||
memory = governance.write_from_hy(
|
||||
HyCreate(
|
||||
draft=MemoryDraft(
|
||||
user_id="local",
|
||||
kind=MemoryKind.CHAT,
|
||||
content="真实对话内容。",
|
||||
scopes=("all-apps",),
|
||||
evidence_ids=(record.evidence_id,),
|
||||
origin=MemoryOrigin.EXTRACTED,
|
||||
),
|
||||
request_id="hy-0",
|
||||
memory_id="hy-memory-0",
|
||||
),
|
||||
user_id="local",
|
||||
derivation=governance.hy_derivation((record.evidence_id,)),
|
||||
)
|
||||
with closing(sqlite3.connect(database)) as db, db:
|
||||
db.execute(
|
||||
"INSERT INTO source_grants(user_id,source_id,root_path,requested_path,"
|
||||
"root_identity,candidate_kind,granted_at,revoked_at,revision) "
|
||||
"VALUES('local','workbuddy','/synthetic','/synthetic','fixture',"
|
||||
"'default','2026-10-01T00:00:00+00:00',NULL,1)"
|
||||
)
|
||||
|
||||
access = SourceReadAccess(database)
|
||||
assert access.memories([memory]) == [memory], "granted and cited: readable"
|
||||
|
||||
# A real revocation still hides it. Checked while the evidence row is
|
||||
# still here, because attribution to a source lives only on that row.
|
||||
with closing(sqlite3.connect(database)) as db, db:
|
||||
db.execute(
|
||||
"UPDATE source_grants SET revoked_at='2026-10-02T00:00:00+00:00' "
|
||||
"WHERE user_id='local' AND source_id='workbuddy'"
|
||||
)
|
||||
assert access.memories([memory]) == [], "revoked source hides the memory"
|
||||
with closing(sqlite3.connect(database)) as db, db:
|
||||
db.execute("UPDATE source_grants SET revoked_at=NULL WHERE user_id='local'")
|
||||
|
||||
# The evidence row disappears — released as import staging, not revoked.
|
||||
with closing(sqlite3.connect(database)) as db, db:
|
||||
db.execute("DELETE FROM evidence WHERE evidence_id=?", (record.evidence_id,))
|
||||
assert access.memories([memory]) == [memory], "missing provenance still reads"
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user