diff --git a/CHANGELOG.md b/CHANGELOG.md index bf9556a..8b73b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Hy Memory and MemHub are versioned and released independently. +## Unreleased + +- Removed MemHub's task-scoped HyPermission store wrappers and global + model-content consent switch. The trusted embedded Hy Memory SDK now uses + its native local stores directly. +- Existing installations ignore the retired config flag, readmit previously + 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. + ## Hy Memory 2.0.2 — 2026-10-17 - Published diff --git a/hy_memory/agent/llm_provider.py b/hy_memory/agent/llm_provider.py index c12e995..aa28d90 100644 --- a/hy_memory/agent/llm_provider.py +++ b/hy_memory/agent/llm_provider.py @@ -496,11 +496,21 @@ class LLMProvider: clients = self._clients.setdefault(loop, {}) client = clients.get(client_key) if client is None: + client_options: Dict[str, Any] = { + "api_key": base_api_key, + "base_url": base_url, + "timeout": self._llm_config.timeout, + "max_retries": 0, + } + transport_factory = getattr(self.config, "http_transport_factory", None) + if callable(transport_factory): + import httpx + + client_options["http_client"] = httpx.AsyncClient( + transport=transport_factory() + ) client = AsyncOpenAI( - api_key=base_api_key, - base_url=base_url, - timeout=self._llm_config.timeout, - max_retries=0, + **client_options, ) clients[client_key] = client diff --git a/hy_memory/config.py b/hy_memory/config.py index 3d58f4e..ce36910 100644 --- a/hy_memory/config.py +++ b/hy_memory/config.py @@ -6,7 +6,7 @@ Agent Memory - 配置管理 import os from dataclasses import dataclass, field -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any, Callable, List from pathlib import Path @@ -1252,6 +1252,13 @@ class MemoryConfig: enable_agent: bool = False debug: bool = False metrics_enabled: bool = True + # Runtime-only HTTP transport factory. MemHub uses this for request-cost + # budgeting without restricting the embedded SDK's local store access. + http_transport_factory: Optional[Callable[[], Any]] = field( + default=None, + repr=False, + compare=False, + ) def __post_init__(self): # enable_graph 跟随 mode 走:mode=ultra 时自动启用,无需独立环境变量 diff --git a/hy_memory/core/embed_service.py b/hy_memory/core/embed_service.py index 711fd2d..efe7955 100644 --- a/hy_memory/core/embed_service.py +++ b/hy_memory/core/embed_service.py @@ -356,11 +356,21 @@ class EmbedService: key = self._client_cache_key(base_url, api_key, timeout) client = self._client_cache.get(key) if client is None: + client_options = { + "api_key": api_key, + "base_url": base_url or None, + "timeout": timeout, + "max_retries": 0, + } + transport_factory = getattr(self.config, "http_transport_factory", None) + if callable(transport_factory): + import httpx + + client_options["http_client"] = httpx.AsyncClient( + transport=transport_factory() + ) client = AsyncOpenAI( - api_key=api_key, - base_url=base_url or None, - timeout=timeout, - max_retries=0, + **client_options, ) self._client_cache[key] = client return client diff --git a/memhub/MEMORY.md b/memhub/MEMORY.md index 7aafed9..f23fdc0 100644 --- a/memhub/MEMORY.md +++ b/memhub/MEMORY.md @@ -305,9 +305,9 @@ Runtime 应长期复用 client,不为每次请求重新初始化数据库和 ```text Evidence - -> ModelProcessingPermissionLease - -> HyPermissionTransport - -> HyMemoryBridge.ingest / ingest_extracted / search_ids + -> HyMemoryBridge + -> embedded Hy Memory SDK + -> native Vector / Coding / Graph stores -> write result verification -> Governance -> Recall Broker / UI @@ -316,8 +316,11 @@ Evidence 不要在新业务模块里直接创建 `HyMemoryClient`,否则会绕过: - 来源读取授权; -- 模型内容处理授权; -- 发送前与响应后的撤权复核; +- 共享 Runtime、模型配置、API Key 安全存储与请求预算; - Evidence 引用与作者归属; - 写后回读验证; - Governance 状态、版本、更正和删除级联。 + +Hy Memory SDK 是 MemHub 的内置可信组件,可自由访问和管理本机的全部 +Hy Memory 数据。来源授权仍决定哪些宿主数据可以进入本机;宿主 Recall Grant +仍决定哪些外部 AI 应用可以读取治理后的记忆。这两类边界不限制内置 SDK 的本地 CRUD。 diff --git a/memhub/apps/cli/scripts/verify-package-matrix.mjs b/memhub/apps/cli/scripts/verify-package-matrix.mjs index aa3066d..d68c72d 100644 --- a/memhub/apps/cli/scripts/verify-package-matrix.mjs +++ b/memhub/apps/cli/scripts/verify-package-matrix.mjs @@ -32,7 +32,6 @@ const REQUIRED_COMMAND_SURFACE = [ "calls", "import-file", "notes", - "permission", "extraction", "graph", "models", @@ -77,7 +76,7 @@ const REQUIRED_MARKERS = [ /** * Sub-commands the UI-write parity work added. A package can declare the - * parent command (`permission`, `models`) while still missing the new verb, so + * parent command (`models`) while still missing the new verb, so * checking command names alone would pass a stale bundle. The marker is the * exact literal the catalog usage string contains. */ @@ -88,9 +87,6 @@ const REQUIRED_SUBCOMMANDS = [ { command: "import-file", marker: "memhub import-file discard" }, { command: "notes", marker: "memhub notes add" }, { command: "notes", marker: "memhub notes process" }, - { command: "permission", marker: "memhub permission status" }, - { command: "permission", marker: "memhub permission allow" }, - { command: "permission", marker: "memhub permission deny" }, { command: "extraction", marker: "memhub extraction set" }, { command: "graph", marker: "memhub graph width" }, { command: "models", marker: "memhub models edit" }, @@ -153,8 +149,6 @@ const STOPPED_DESTRUCTIVE_PROBES = [ "--confirm", ], }, - { name: "permission_allow", args: ["permission", "allow"] }, - { name: "permission_deny", args: ["permission", "deny"] }, { name: "extraction_set_apply", args: ["extraction", "set", "--window", "6m", "--confirm"], diff --git a/memhub/apps/cli/src/cli.ts b/memhub/apps/cli/src/cli.ts index fd75869..de67f80 100644 --- a/memhub/apps/cli/src/cli.ts +++ b/memhub/apps/cli/src/cli.ts @@ -190,13 +190,6 @@ import { renderMemoryDetailText, renderMemoryPageText, } from "./memory-report.js"; -import { - type ModelContentPermissionDocument, - modelContentPermissionExitCode, - readModelContentPermission, - renderModelContentPermissionText, - setModelContentPermission, -} from "./model-content-permission-flow.js"; import { executeModelProbe, inspectModelProbePlan, @@ -217,12 +210,10 @@ import { executeModelProfileActivate, executeModelProfileEdit, type ModelWriteDocument, - type ModelWritePlan, modelWriteExitCode, planModelProfileActivate, planModelProfileEdit, renderModelWriteText, - validateModelWritePlan, } from "./model-write-flows.js"; import { claimPlanAttempt } from "./operation-journal.js"; import { @@ -2462,45 +2453,6 @@ async function runCommand( ); return uiWriteExitCode(document); } - if (named === "permission") { - const parsed = parsePermissionCommand(arguments_.slice(1)); - if (!parsed) return usage(); - const config = resolveRuntimeCliConfig(environment, process.argv[1]); - const runtime = await inspectRuntimeStatus({ - dataDir: config.dataDir, - probeIdentity, - requireServiceReceipt: true, - }); - const attached = - runtime.state === "running" - ? await attachClients(config.dataDir) - : { reader: null, operations: null, failure: runtime.code }; - if ( - runtime.state !== "running" || - (parsed === "status" ? !attached.reader : !attached.operations) - ) { - const document: ModelContentPermissionDocument = { - schema_version: 1, - product: PRODUCT, - state: "unknown", - code: - runtime.state !== "running" - ? runtime.code - : (attached.failure ?? "runtime_attach_failed"), - }; - emitModelContentPermissionDocument(document, asJson); - return runtime.state === "stopped" ? 3 : 1; - } - const document = - parsed === "status" - ? await readModelContentPermission(attached.reader!) - : await setModelContentPermission( - attached.operations!, - parsed === "allow", - ); - emitModelContentPermissionDocument(document, asJson); - return modelContentPermissionExitCode(document); - } if (named === "notes" || named === "extraction" || named === "graph") { const parsed = parseExtendedWriteCommand( named as "notes" | "extraction" | "graph", @@ -5124,18 +5076,6 @@ function parseHeadlessCommand( : null; } -function parsePermissionCommand( - rest: readonly string[], -): "status" | "allow" | "deny" | null { - if (rest.filter((item) => item === "--json").length > 1) return null; - const tokens = rest.filter((item) => item !== "--json"); - const action = tokens[0]; - return tokens.length === 1 && - (action === "status" || action === "allow" || action === "deny") - ? action - : null; -} - function unwrapPlan(value: unknown): unknown { if ( value && @@ -5323,17 +5263,6 @@ function emitModelReportDocument( ); } -function emitModelContentPermissionDocument( - document: ModelContentPermissionDocument, - asJson: boolean, -): void { - process.stdout.write( - asJson - ? `${JSON.stringify(document)}\n` - : renderModelContentPermissionText(document), - ); -} - function emitSourceSyncDocument( document: SourceSyncFlowDocument, asJson: boolean, diff --git a/memhub/apps/cli/src/command-catalog.ts b/memhub/apps/cli/src/command-catalog.ts index 57827e3..f2888ff 100644 --- a/memhub/apps/cli/src/command-catalog.ts +++ b/memhub/apps/cli/src/command-catalog.ts @@ -493,14 +493,6 @@ export const COMMANDS: readonly CommandDescriptor[] = [ ], exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED], }, - { - name: "permission", - summary: "读取或直接设置全局模型内容外发许可。", - usage: - "memhub permission status [--json]\n memhub permission allow [--json]\n memhub permission deny [--json]", - options: [JSON_OPTION], - exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED], - }, { name: "extraction", summary: "设置提取窗口;默认只出计划,--confirm 后才写入。", diff --git a/memhub/apps/cli/src/command-contract.test.ts b/memhub/apps/cli/src/command-contract.test.ts index 2c49b7e..fcdfca9 100644 --- a/memhub/apps/cli/src/command-contract.test.ts +++ b/memhub/apps/cli/src/command-contract.test.ts @@ -81,7 +81,6 @@ const COMMANDS = [ "calls", "import-file", "notes", - "permission", "extraction", "graph", "host", @@ -259,7 +258,14 @@ describe("CLI-22 shared output contract", () => { describe("argument validation", () => { it("fails unknown commands without printing data or running anything", async () => { - for (const argv of [["unknown"], ["unknown", "--help"], ["--typo"]]) { + for (const argv of [ + ["unknown"], + ["unknown", "--help"], + ["--typo"], + ["permission", "status"], + ["permission", "allow"], + ["permission", "deny"], + ]) { const streams = captureStreams(); try { expect(await run(argv, {})).toBe(2); diff --git a/memhub/apps/cli/src/core-client.ts b/memhub/apps/cli/src/core-client.ts index 8fdcc46..26542c0 100644 --- a/memhub/apps/cli/src/core-client.ts +++ b/memhub/apps/cli/src/core-client.ts @@ -8,7 +8,6 @@ import axios from "axios"; */ const READ_ONLY_PATHS = [ "/api/v1/call-events", - "/api/v1/model-content-permission", "/api/v1/home-summary", "/api/v1/import-jobs", "/api/v1/live-sync", diff --git a/memhub/apps/cli/src/core-operations.ts b/memhub/apps/cli/src/core-operations.ts index ee8da82..9424e01 100644 --- a/memhub/apps/cli/src/core-operations.ts +++ b/memhub/apps/cli/src/core-operations.ts @@ -30,7 +30,6 @@ const IMPORT_FILE = const MEMORY_NOTES_PATH = "/api/v1/memory-notes"; const MEMORY_NOTE_PROCESS = /^\/api\/v1\/memory-notes\/[A-Za-z0-9][\w.:-]{0,127}\/process$/; -const MODEL_CONTENT_PERMISSION_PATH = "/api/v1/model-content-permission"; const EXTRACTION_SETTINGS_PATH = "/api/v1/extraction-settings"; const GRAPH_WIDTH_PATH = "/api/v1/graph-width"; const MANUAL_EXTRACT_PATH = "/api/v1/memory-processing/manual-extract"; @@ -134,7 +133,6 @@ export function createCoreOperations(attachment: { !IMPORT_FILE_COMMIT.test(path) && path !== MEMORY_NOTES_PATH && !MEMORY_NOTE_PROCESS.test(path) && - path !== MODEL_CONTENT_PERMISSION_PATH && path !== GRAPH_WIDTH_PATH && path !== MANUAL_EXTRACT_PATH && path !== ONBOARDING_COMPLETE_PATH && diff --git a/memhub/apps/cli/src/init-interactive.test.ts b/memhub/apps/cli/src/init-interactive.test.ts index fafbe5d..b5a6942 100644 --- a/memhub/apps/cli/src/init-interactive.test.ts +++ b/memhub/apps/cli/src/init-interactive.test.ts @@ -259,7 +259,7 @@ describe("A2 dual-mode init", () => { expect(streams.value()).toContain("处理:已开启"); }); - it("continues the TTY wizard through model probe and content permission", async () => { + it("continues the TTY wizard through model setup and probe", async () => { const profileId = "22222222-2222-4222-8222-222222222222"; const profile = { profile_id: profileId, @@ -361,12 +361,6 @@ describe("A2 dual-mode init", () => { }; } if (url.endsWith("/activate")) return { status: 200, data: profile }; - if (url === "/api/v1/model-content-permission") { - return { - status: 200, - data: { allowed: body.allowed }, - }; - } if (url === "/api/v1/onboarding/complete") { return { status: 200, @@ -383,7 +377,7 @@ describe("A2 dual-mode init", () => { }, ); const prompt = extendedPrompter( - ["", "", "", "", "", "", "", "", "n"], + ["", "", "", "", "", "", "", "n"], "sk-never-print-this", ); const streams = captureStdout(); @@ -402,7 +396,6 @@ describe("A2 dual-mode init", () => { streams.restore(); } expect(streams.value()).toContain("已接入来源:codex"); - expect(prompt.written.join("\n")).toContain("模型内容外发:已允许"); expect(streams.value()).toContain("首次设置完成。"); expect(`${streams.value()}\n${prompt.written.join("\n")}`).not.toContain( "sk-never-print-this", @@ -412,10 +405,10 @@ describe("A2 dual-mode init", () => { expect.objectContaining({ api_key: "sk-never-print-this" }), undefined, ); - expect(transport.post).toHaveBeenCalledWith( + expect(transport.post).not.toHaveBeenCalledWith( "/api/v1/model-content-permission", - { allowed: true }, - undefined, + expect.anything(), + expect.anything(), ); expect(transport.post).toHaveBeenCalledWith( "/api/v1/onboarding/complete", diff --git a/memhub/apps/cli/src/interactive-model-setup.test.ts b/memhub/apps/cli/src/interactive-model-setup.test.ts index 4c6d6bf..18668a4 100644 --- a/memhub/apps/cli/src/interactive-model-setup.test.ts +++ b/memhub/apps/cli/src/interactive-model-setup.test.ts @@ -132,12 +132,6 @@ function clients(options: { failedProbes?: number } = {}) { if (path.endsWith("/activate")) { return { state: "committed" as const, data: profile(true) }; } - if (path === "/api/v1/model-content-permission") { - return { - state: "committed" as const, - data: { allowed: body.allowed }, - }; - } return { state: "rejected" as const, code: "unexpected_path", @@ -160,8 +154,8 @@ function clients(options: { failedProbes?: number } = {}) { } describe("interactive model setup", () => { - it("uses App defaults, keeps the key out of output, and allows model content", async () => { - const prompt = prompter(["", "", "", "", "", ""]); + it("uses App defaults and keeps the key out of output", async () => { + const prompt = prompter(["", "", ""]); const api = clients(); const result = await runInteractiveModelSetup(prompt.value, api, { @@ -170,10 +164,7 @@ describe("interactive model setup", () => { installHosts: false, }); - expect(result).toMatchObject({ - state: "configured", - model_content_allowed: true, - }); + expect(result).toMatchObject({ state: "configured" }); expect(api.post).toHaveBeenCalledWith( "/api/v1/model-profiles", expect.objectContaining({ @@ -184,9 +175,10 @@ describe("interactive model setup", () => { embedding_provider_kind: "local_onnx", }), ); - expect(api.post).toHaveBeenCalledWith("/api/v1/model-content-permission", { - allowed: true, - }); + expect(api.post).not.toHaveBeenCalledWith( + "/api/v1/model-content-permission", + expect.anything(), + ); expect(prompt.written.join("\n")).not.toContain("sk-do-not-print"); }); @@ -205,7 +197,7 @@ describe("interactive model setup", () => { }); it("removes an unverified profile before skipping after a failed probe", async () => { - const prompt = prompter(["", "", "", "n", "0"]); + const prompt = prompter(["", "", "", "0"]); const api = clients(); api.post.mockImplementation( async (path: string, body: Record) => { @@ -280,7 +272,7 @@ describe("interactive model setup", () => { }); it("cleans a failed probe and lets the user re-enter the model settings", async () => { - const prompt = prompter(["", "", "", "", "", "", "", "", "", "", ""]); + const prompt = prompter(["", "", "", "", "", "", ""]); const api = clients({ failedProbes: 1 }); const result = await runInteractiveModelSetup(prompt.value, api, { @@ -289,10 +281,7 @@ describe("interactive model setup", () => { installHosts: false, }); - expect(result).toMatchObject({ - state: "configured", - model_content_allowed: true, - }); + expect(result).toMatchObject({ state: "configured" }); expect(api.remove).toHaveBeenCalledTimes(1); expect( api.post.mock.calls.filter(([path]) => path === "/api/v1/model-profiles"), @@ -305,7 +294,7 @@ describe("interactive model setup", () => { const prompt = prompter(["", "", ""]); prompt.value.ask = async () => { asked += 1; - if (asked === 4) throw new Error("interrupted"); + if (asked === 3) throw new Error("interrupted"); return ""; }; const api = clients(); @@ -322,7 +311,7 @@ describe("interactive model setup", () => { }); it("installs selected detected hosts through preview and fingerprint apply", async () => { - const prompt = prompter(["", "", "", "n", "", ""]); + const prompt = prompter(["", "", "", "", ""]); const api = clients(); const read = vi .fn() @@ -360,7 +349,6 @@ describe("interactive model setup", () => { expect(result).toMatchObject({ state: "configured", - model_content_allowed: false, installed_hosts: ["codex"], }); expect(read).toHaveBeenNthCalledWith( @@ -373,7 +361,7 @@ describe("interactive model setup", () => { }); it("lists and installs only hosts that are actually present", async () => { - const prompt = prompter(["", "", "", "n", "", ""]); + const prompt = prompter(["", "", "", "", ""]); const api = clients(); const read = vi .fn() @@ -420,7 +408,7 @@ describe("interactive model setup", () => { }); it("completes the wizard and keeps onboarding reachable when one host install fails", async () => { - const prompt = prompter(["", "", "", "n", "", ""]); + const prompt = prompter(["", "", "", "", ""]); const api = clients(); const read = vi .fn() @@ -469,7 +457,6 @@ describe("interactive model setup", () => { // calling the install a failure. expect(result).toMatchObject({ state: "configured", - model_content_allowed: false, installed_hosts: ["codex"], }); const summary = prompt.written.join("\n"); @@ -479,7 +466,7 @@ describe("interactive model setup", () => { }); it("tells the user to install App-only hosts in the App instead of calling them failed", async () => { - const prompt = prompter(["", "", "", "n", "", ""]); + const prompt = prompter(["", "", "", "", ""]); const api = clients(); const read = vi .fn() @@ -506,7 +493,6 @@ describe("interactive model setup", () => { // only the App can install, so nothing counts as "installed". expect(result).toMatchObject({ state: "configured", - model_content_allowed: false, installed_hosts: [], }); const summary = prompt.written.join("\n"); diff --git a/memhub/apps/cli/src/interactive-model-setup.ts b/memhub/apps/cli/src/interactive-model-setup.ts index d831bc8..900bcd3 100644 --- a/memhub/apps/cli/src/interactive-model-setup.ts +++ b/memhub/apps/cli/src/interactive-model-setup.ts @@ -11,7 +11,6 @@ import { } from "./headless-flows.js"; import { approvalFingerprint, listHosts, readHost } from "./host-flow.js"; import { parseSelection, type WizardPrompter } from "./init-wizard.js"; -import { setModelContentPermission } from "./model-content-permission-flow.js"; import { executeModelProbe, planModelProbe } from "./model-probe-flow.js"; import { executeModelProfileActivate, @@ -122,7 +121,6 @@ export type InteractiveModelSetupState = | { state: "configured"; profile: ModelProfile; - model_content_allowed: boolean; installed_hosts: string[]; } | { state: "skipped" } @@ -289,15 +287,11 @@ async function installHosts( function writeSummary( prompter: WizardPrompter, profile: ModelProfile, - modelContentAllowed: boolean, installedHosts: readonly string[], failedHosts: readonly { hostId: string; label: string }[], ): void { prompter.write("配置摘要:"); prompter.write(` 模型:${profile.name} / ${profile.model}`); - prompter.write( - ` 模型内容外发:${modelContentAllowed ? "已允许" : "已禁止"}`, - ); prompter.write( ` 应用接入:${installedHosts.length > 0 ? installedHosts.join("、") : "已跳过"}`, ); @@ -353,14 +347,6 @@ export async function runInteractiveModelSetup( prompter.write("未填写 API Key。输入 back 返回,或重新选择模型服务。"); continue; } - const permissionAnswer = ( - await prompter.ask("允许将对话内容发送给模型以整理记忆?[Y/n]: ") - ) - .trim() - .toLowerCase(); - const modelContentAllowed = !["n", "no", "否", "0"].includes( - permissionAnswer, - ); const hostApi = options.hostApi ?? { list: listHosts, read: readHost }; const selectedHosts = options.installHosts ? await chooseDetectedHosts(prompter, options.hostHome, hostApi) @@ -452,13 +438,6 @@ export async function runInteractiveModelSetup( return { state: "failed", code: "model_activate_failed" }; } - const permission = await setModelContentPermission( - clients.operations, - modelContentAllowed, - ); - if (permission.state !== "ok") { - return { state: "failed", code: "model_content_permission_set_failed" }; - } const { installed, failed } = await installHosts( options.dataDir, selectedHosts, @@ -467,7 +446,6 @@ export async function runInteractiveModelSetup( writeSummary( prompter, profile, - modelContentAllowed, installed, failed.map((hostId) => ({ hostId, @@ -477,7 +455,6 @@ export async function runInteractiveModelSetup( return { state: "configured", profile, - model_content_allowed: modelContentAllowed, installed_hosts: installed, }; } diff --git a/memhub/apps/cli/src/job-flow.test.ts b/memhub/apps/cli/src/job-flow.test.ts index 15fdbb7..3f9e0ba 100644 --- a/memhub/apps/cli/src/job-flow.test.ts +++ b/memhub/apps/cli/src/job-flow.test.ts @@ -180,5 +180,8 @@ describe("import-job write allow-list", () => { await expect( operations.post("/api/v1/import-jobs/job-1/delete", {}), ).rejects.toThrow("core_operation_path_not_allowed"); + await expect( + operations.post("/api/v1/model-content-permission", { allowed: true }), + ).rejects.toThrow("core_operation_path_not_allowed"); }); }); diff --git a/memhub/apps/cli/src/model-content-permission-cli.test.ts b/memhub/apps/cli/src/model-content-permission-cli.test.ts deleted file mode 100644 index 69a1196..0000000 --- a/memhub/apps/cli/src/model-content-permission-cli.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { run } from "./cli.js"; - -const transport = vi.hoisted(() => ({ - get: vi.fn(), - post: vi.fn(), - put: vi.fn(), - patch: vi.fn(), - delete: vi.fn(), -})); -vi.mock("axios", () => ({ - default: { create: vi.fn(() => transport), get: vi.fn() }, -})); - -const runtime = vi.hoisted(() => ({ - inspectRuntimeStatus: vi.fn(), - discoverRuntimeAttachment: vi.fn(), -})); -vi.mock("@one-memory/runtime-control", async (importOriginal) => ({ - ...(await importOriginal()), - ...runtime, -})); - -beforeEach(() => { - vi.clearAllMocks(); - runtime.inspectRuntimeStatus.mockResolvedValue({ - state: "running", - code: "runtime_verified", - instanceId: "instance-1", - host: "127.0.0.1", - port: 8765, - version: "1.2.3", - }); - runtime.discoverRuntimeAttachment.mockResolvedValue({ - instanceId: "instance-1", - apiToken: "synthetic-runtime-token", - host: "127.0.0.1", - port: 8765, - servicePid: 4242, - health: { - status: "ok", - service: "one-memory-core", - version: "1.2.3", - instance_id: "instance-1", - }, - }); -}); - -describe("model content permission CLI", () => { - it("documents only final-state permission and manual extraction commands", async () => { - const permission = await capturedRun(["help", "permission"]); - expect(permission.code).toBe(0); - expect(permission.stdout).toContain("memhub permission allow"); - expect(permission.stdout).toContain("memhub permission deny"); - expect(permission.stdout).not.toMatch( - /\b(preview|confirm|process|revoke)\b/, - ); - expect(permission.stdout).not.toContain("revision"); - expect(permission.stdout).not.toContain("receipt"); - - const processing = await capturedRun(["help", "processing"]); - expect(processing.code).toBe(0); - expect(processing.stdout).toContain("memhub processing extract"); - expect(processing.stdout).not.toMatch(/\b(plan|apply|receipt)\b/); - expect(processing.stdout).not.toContain("backlog"); - }); - - it("reads the final state", async () => { - transport.get.mockResolvedValue({ status: 200, data: { allowed: true } }); - - const result = await capturedRun(["permission", "status", "--json"]); - - expect(result.code).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ - state: "ok", - allowed: true, - }); - expect(transport.get).toHaveBeenCalledWith( - "/api/v1/model-content-permission", - undefined, - ); - }); - - it.each([ - ["allow", true], - ["deny", false], - ] as const)("sets %s directly with only allowed", async (verb, allowed) => { - transport.post.mockResolvedValue({ status: 200, data: { allowed } }); - - const result = await capturedRun(["permission", verb, "--json"]); - - expect(result.code).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ state: "ok", allowed }); - expect(transport.post).toHaveBeenCalledWith( - "/api/v1/model-content-permission", - { allowed }, - undefined, - ); - }); - - it.each(["preview", "confirm", "process", "revoke"])( - "rejects removed %s commands before runtime access", - async (verb) => { - const result = await capturedRun(["permission", verb, "--json"]); - - expect(result.code).toBe(2); - expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled(); - expect(transport.get).not.toHaveBeenCalled(); - expect(transport.post).not.toHaveBeenCalled(); - }, - ); - - it.each(["plan", "apply", "receipt"])( - "rejects removed processing %s commands before runtime access", - async (verb) => { - const result = await capturedRun(["processing", verb, "--json"]); - - expect(result.code).toBe(2); - expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled(); - expect(transport.get).not.toHaveBeenCalled(); - expect(transport.post).not.toHaveBeenCalled(); - }, - ); -}); - -async function capturedRun(arguments_: string[]) { - let stdout = ""; - let stderr = ""; - const stdoutSpy = vi - .spyOn(process.stdout, "write") - .mockImplementation((chunk) => { - stdout += String(chunk); - return true; - }); - const stderrSpy = vi - .spyOn(process.stderr, "write") - .mockImplementation((chunk) => { - stderr += String(chunk); - return true; - }); - try { - return { - code: await run(arguments_, { - ONE_MEMORY_DATA_DIR: "/synthetic/data", - }), - stdout, - stderr, - }; - } finally { - stdoutSpy.mockRestore(); - stderrSpy.mockRestore(); - } -} diff --git a/memhub/apps/cli/src/model-content-permission-flow.test.ts b/memhub/apps/cli/src/model-content-permission-flow.test.ts deleted file mode 100644 index 458e792..0000000 --- a/memhub/apps/cli/src/model-content-permission-flow.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { CoreReader } from "./core-client.js"; -import type { CoreOperations } from "./core-operations.js"; -import { - readModelContentPermission, - setModelContentPermission, -} from "./model-content-permission-flow.js"; - -describe("model content permission flow", () => { - it("reads the single final-state boolean", async () => { - const reader = vi.fn().mockResolvedValue({ - state: "ok", - data: { allowed: true }, - }); - - await expect( - readModelContentPermission(reader as CoreReader), - ).resolves.toMatchObject({ state: "ok", allowed: true }); - expect(reader).toHaveBeenCalledWith("/api/v1/model-content-permission"); - }); - - it.each([ - ["allow", true], - ["deny", false], - ] as const)( - "sets %s directly without revision or receipt fields", - async (_, allowed) => { - const post = vi.fn().mockResolvedValue({ - state: "committed", - data: { allowed }, - }); - - await expect( - setModelContentPermission({ post } as CoreOperations, allowed), - ).resolves.toMatchObject({ state: "ok", allowed }); - expect(post).toHaveBeenCalledWith("/api/v1/model-content-permission", { - allowed, - }); - }, - ); - - it("does not accept a malformed or mismatched success response", async () => { - const malformed = vi.fn().mockResolvedValue({ - state: "committed", - data: { allowed: true, revision: 1 }, - }); - await expect( - setModelContentPermission({ post: malformed } as CoreOperations, true), - ).resolves.toMatchObject({ - state: "unknown", - code: "model_content_permission_response_invalid", - }); - - const mismatched = vi.fn().mockResolvedValue({ - state: "committed", - data: { allowed: false }, - }); - await expect( - setModelContentPermission({ post: mismatched } as CoreOperations, true), - ).resolves.toMatchObject({ - state: "unknown", - code: "model_content_permission_state_mismatch", - }); - }); -}); diff --git a/memhub/apps/cli/src/model-content-permission-flow.ts b/memhub/apps/cli/src/model-content-permission-flow.ts deleted file mode 100644 index 39a146e..0000000 --- a/memhub/apps/cli/src/model-content-permission-flow.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { CoreReader } from "./core-client.js"; -import type { CoreOperations } from "./core-operations.js"; -import { PRODUCT } from "./version.js"; - -export type ModelContentPermissionDocument = - | { - schema_version: 1; - product: string; - state: "ok"; - allowed: boolean; - } - | { - schema_version: 1; - product: string; - state: "rejected" | "unknown"; - code: string; - }; - -export async function readModelContentPermission( - reader: CoreReader, -): Promise { - const outcome = await reader("/api/v1/model-content-permission"); - if (outcome.state !== "ok") { - return failure("unknown", outcome.code); - } - return permissionState( - outcome.data, - "model_content_permission_response_invalid", - ); -} - -export async function setModelContentPermission( - operations: CoreOperations, - allowed: boolean, -): Promise { - const outcome = await operations.post( - "/api/v1/model-content-permission", - { allowed }, - ); - if (outcome.state === "rejected") { - return failure("rejected", outcome.code); - } - if (outcome.state === "unknown") { - return failure("unknown", outcome.code); - } - const document = permissionState( - outcome.data, - "model_content_permission_response_invalid", - ); - return document.state === "ok" && document.allowed !== allowed - ? failure("unknown", "model_content_permission_state_mismatch") - : document; -} - -export function modelContentPermissionExitCode( - document: ModelContentPermissionDocument, -): number { - return document.state === "ok" ? 0 : 1; -} - -export function renderModelContentPermissionText( - document: ModelContentPermissionDocument, -): string { - if (document.state === "ok") { - return `模型内容外发:${document.allowed ? "允许" : "禁止"}\n`; - } - return `模型内容权限未更新:${document.state}(${document.code})\n`; -} - -function permissionState( - value: unknown, - invalidCode: string, -): ModelContentPermissionDocument { - if ( - value === null || - typeof value !== "object" || - Array.isArray(value) || - Object.keys(value).length !== 1 || - typeof (value as Record).allowed !== "boolean" - ) { - return failure("unknown", invalidCode); - } - return { - schema_version: 1, - product: PRODUCT, - state: "ok", - allowed: (value as { allowed: boolean }).allowed, - }; -} - -function failure( - state: "rejected" | "unknown", - code: string, -): ModelContentPermissionDocument { - return { schema_version: 1, product: PRODUCT, state, code }; -} diff --git a/memhub/apps/cli/src/package-matrix.test.ts b/memhub/apps/cli/src/package-matrix.test.ts index db76eba..4ae17f3 100644 --- a/memhub/apps/cli/src/package-matrix.test.ts +++ b/memhub/apps/cli/src/package-matrix.test.ts @@ -137,8 +137,8 @@ describe("package matrix harness", () => { }; expect(document.state).toBe("verified"); const results = document.commands.stopped_destructive.results; - // 13 destructive commands, each unknown/runtime_not_running with exit 3. - expect(Object.keys(results)).toHaveLength(13); + // 11 destructive commands, each unknown/runtime_not_running with exit 3. + expect(Object.keys(results)).toHaveLength(11); for (const [name, result] of Object.entries(results)) { expect(result, `${name} did not fail closed`).toEqual({ exit: 3, @@ -162,7 +162,7 @@ describe("package matrix harness", () => { const shim = [ "#!/usr/bin/env node", "const a=process.argv.slice(2);", - "if(['calls','import-file','notes','permission','extraction','graph','models'].includes(a[0])||(a[0]==='processing'&&a[1]==='extract')){", + "if(['calls','import-file','notes','extraction','graph','models'].includes(a[0])||(a[0]==='processing'&&a[1]==='extract')){", ' process.stdout.write(\'{"schema_version":1,"product":"memhub","state":"committed","operation_id":"x","receipt":{}}\\n\');', " process.exit(0);", "}", @@ -188,7 +188,7 @@ describe("package matrix harness", () => { expect(failure?.state).toBe("failed"); expect( failure?.failures.filter((item) => item.includes("did not fail closed")), - ).toHaveLength(13); + ).toHaveLength(11); }); it("can require an expected bundle SHA and reports bundle metadata", { @@ -280,9 +280,8 @@ describe("package matrix harness", () => { }); it("rejects a catalog that declares the parent command but not the new verb", async () => { - // A bundle can still declare `permission`/`models` while missing - // `permission allow` or `models activate`. Names alone would pass it; the - // usage marker must not. + // A bundle can still declare `models` while missing `models activate`. + // Names alone would pass it; the usage marker must not. const declared = [ "install", "init", @@ -292,7 +291,6 @@ describe("package matrix harness", () => { "calls", "import-file", "notes", - "permission", "extraction", "graph", "models", @@ -342,9 +340,6 @@ describe("package matrix harness", () => { }, }; const failures = harness.validateCatalog(catalog); - expect(failures).toContain( - 'command "permission" is missing usage "memhub permission allow"', - ); expect(failures).toContain( 'command "models" is missing usage "memhub models activate"', ); diff --git a/memhub/apps/cli/src/product-command-contract.test.ts b/memhub/apps/cli/src/product-command-contract.test.ts index 9505706..0e000b0 100644 --- a/memhub/apps/cli/src/product-command-contract.test.ts +++ b/memhub/apps/cli/src/product-command-contract.test.ts @@ -151,9 +151,6 @@ describe("memhub short-command contract", () => { "memhub import-file discard", "memhub notes add", "memhub notes process", - "memhub permission status", - "memhub permission allow", - "memhub permission deny", "memhub models setup", "memhub models activate", "memhub models edit", @@ -163,6 +160,9 @@ describe("memhub short-command contract", () => { ]) { expect(flattened).toContain(shortForm); } + expect(COMMANDS.some((command) => command.name === "permission")).toBe( + false, + ); }); it("ships a memhub bin entry pointing at a real bundle path", () => { diff --git a/memhub/apps/cli/src/read-only-queries.test.ts b/memhub/apps/cli/src/read-only-queries.test.ts index f078cb7..41bb129 100644 --- a/memhub/apps/cli/src/read-only-queries.test.ts +++ b/memhub/apps/cli/src/read-only-queries.test.ts @@ -840,6 +840,7 @@ describe("CLI-21 memory reads", () => { expect(isReadOnlyPath("/api/v1/memories/graph")).toBe(false); expect(isReadOnlyPath("/api/v1/memories/ask")).toBe(false); expect(isReadOnlyPath("/api/v1/memories/m1")).toBe(true); + expect(isReadOnlyPath("/api/v1/model-content-permission")).toBe(false); expect( isReadOnlyPath( "/api/v1/memory-mutations/123e4567-e89b-12d3-a456-426614174000", diff --git a/memhub/apps/core/one-memory.spec b/memhub/apps/core/one-memory.spec index 21187ac..05e00c3 100644 --- a/memhub/apps/core/one-memory.spec +++ b/memhub/apps/core/one-memory.spec @@ -11,7 +11,7 @@ folder_layout = layout == 'folder' datas = [('src/one_memory/embedding_assets', 'one_memory/embedding_assets')] binaries = [] # Loaded lazily by the API; static dependency analysis cannot discover it. -hiddenimports = ['one_memory.hy_permission_context'] +hiddenimports = [] tmp_ret = collect_all('hy_memory') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('chromadb') diff --git a/memhub/apps/core/src/one_memory/api.py b/memhub/apps/core/src/one_memory/api.py index 273f5d3..94cd6fd 100644 --- a/memhub/apps/core/src/one_memory/api.py +++ b/memhub/apps/core/src/one_memory/api.py @@ -20,7 +20,6 @@ from copy import copy from datetime import UTC, datetime, timedelta from enum import StrEnum from functools import partial -from importlib import import_module from pathlib import Path from typing import Any, Literal, cast from uuid import UUID @@ -164,7 +163,6 @@ from one_memory.memory_library import ( ) from one_memory.memory_notes import MemoryNoteStore, memory_note_router from one_memory.memory_processing import ( - MemoryBridgeProtocol, MemoryProcessingSupervisor, _conversation_chunks, memory_task_timeout_seconds, @@ -178,10 +176,8 @@ from one_memory.memory_queue import ( ) from one_memory.memory_server.config_state import ConfigStore from one_memory.memory_tasks import MemoryTask, MemoryTaskStatus, MemoryTaskStore -from one_memory.model_content_routes import model_content_router from one_memory.model_presets import seed_preset_profile from one_memory.model_probe import ModelProber, ModelProbeResult -from one_memory.model_processing_permission_guard import ModelProcessingPermissionLease from one_memory.model_profiles import ( ActiveModelProfileError, ModelCreationOperationConflictError, @@ -818,6 +814,13 @@ def create_app( evidence=app.state.evidence_store, ) app.state.memory_write_queue = memory_write_queue + # Builds through 1.0.2 parked pre-invocation jobs behind the + # retired model-content consent bit. The embedded SDK is trusted + # now, so make those durable jobs claimable on the first startup. + await asyncio.to_thread( + memory_write_queue.readmit_parked, + states=("waiting_for_permission",), + ) repaired_identity_failures = await asyncio.to_thread( memory_write_queue.repair_pre_send_identity_failures ) @@ -960,7 +963,7 @@ def create_app( # Not fatal: without the tables every read checks exactly, as # it does today. app.state.memory_visibility_error = str(error) - app.state.model_request_budget = ( + model_request_budget = ( ModelRequestBudget( database_path, request_limit=runtime_settings.model_request_limit, @@ -970,6 +973,7 @@ def create_app( if runtime_settings.model_request_limit is not None else None ) + app.state.model_request_budget = model_request_budget app.state.memory_answerer = MemoryAnswerer( governance=app.state.memory_governance_store, filter_memories=app.state.source_read_access.memories, @@ -1008,6 +1012,16 @@ def create_app( governance=app.state.memory_governance_store, data_dir=runtime_settings.data_dir, extraction_settings=app.state.extraction_settings_store, + bridge_factory=lambda profile, api_key, data_dir: HyMemoryBridge( + profile=profile, + api_key=api_key, + data_dir=data_dir, + http_transport_factory=( + (lambda: BudgetedModelTransport(model_request_budget)) + if model_request_budget is not None + else None + ), + ), # `None` means "whatever the active profile allows". Pinned to # 1 in `a59f4b5` as temporary load-shedding for an acceptance # package, and left pinned after it: a hardcoded 1 is rule 9's @@ -1016,10 +1030,6 @@ def create_app( # profile concurrency 4, recall latency is unchanged while # precipitation goes 4x. max_concurrent_tasks=runtime_settings.precipitation_max_concurrent_tasks, - model_content_config=app.state.config_store, - permission_bridge_factory=lambda lease, secret, directory: _permission_context( - app, lease, secret, directory - ), source_writes_permitted=lambda source_id: _source_writes_on(app, source_id), service_levels=app.state.service_levels, ) @@ -1134,7 +1144,6 @@ def create_app( app.state.matter_discovery.processor = app.state.memory_processing_supervisor app.state.matter_discovery.write_intake = app.state.memory_write_intake app.state.matter_discovery.write_queue = memory_write_queue - app.state.processing_coordinator = None app.state.memory_note_store = MemoryNoteStore( app.state.evidence_store, app.state.memory_processing_supervisor, @@ -1446,7 +1455,6 @@ def create_app( app.include_router(matter_router(protected)) app.include_router(memory_note_router(protected)) app.include_router(file_import_router(protected)) - app.include_router(model_content_router(protected)) app.include_router(context_router(protected)) def require_workbuddy_boundary(request: Request) -> None: @@ -2328,7 +2336,7 @@ def create_app( await asyncio.to_thread( app.state.memory_task_store.park_source_for_permission, source_id=source_id, - error_code="model_content_permission_required", + error_code="hy_memory_source_revoked", ) await _live_sync(app).stop_source(source_id) _supervisor(app).unregister_adapter(source_id) @@ -3894,12 +3902,7 @@ async def _authorize_answer_generation( # The explicit question is this operation's input, not a standing permission for history. if not question.strip() or not grounding: raise MemoryAnswerError("memory_answer_generation_not_authorized") - if not await asyncio.to_thread(_config_store(app).model_content_allowed): - raise MemoryAnswerError("memory_answer_generation_not_authorized") - def check() -> None: - if not _config_store(app).model_content_allowed(): - raise MemoryAnswerError("memory_answer_generation_not_authorized") if _model_profile_store(app).active_identity() != ( profile.profile_id, profile.revision, @@ -3922,30 +3925,6 @@ async def _authorize_answer_generation( await recheck() return recheck - -@asynccontextmanager -async def _permission_context( - app: FastAPI, - lease: ModelProcessingPermissionLease, - secret: str | None, - directory: Path, -) -> AsyncIterator[MemoryBridgeProtocol]: - module = await asyncio.to_thread(import_module, "one_memory.hy_permission_context") - budget = cast(ModelRequestBudget | None, app.state.model_request_budget) - source_id = lease.records[0].source_id if lease.records else "manual" - factory = module.permission_bridge_factory( - lambda profile, api_key, data_dir: _memory_processing_supervisor(app)._bridge( - profile, - api_key, - data_dir, - scope_key=source_id, - ), - **({"http_transport": BudgetedModelTransport(budget)} if budget else {}), - ) - async with factory(lease, secret, directory) as bridge: - yield cast(MemoryBridgeProtocol, bridge) - - class _LazyASGIApp: """Compatibility ASGI app that is built only when a server imports it. @@ -4723,8 +4702,6 @@ async def _run_backfill(app: FastAPI) -> None: app.state.memory_backfill_error = None if not app.state.settings.automatic_memory_processing: return - if not await asyncio.to_thread(_config_store(app).model_content_allowed): - return try: queue = app.state.memory_write_queue intake = app.state.memory_write_intake diff --git a/memhub/apps/core/src/one_memory/hy_memory_bridge.py b/memhub/apps/core/src/one_memory/hy_memory_bridge.py index a18e599..ac6889f 100644 --- a/memhub/apps/core/src/one_memory/hy_memory_bridge.py +++ b/memhub/apps/core/src/one_memory/hy_memory_bridge.py @@ -21,7 +21,6 @@ from pydantic import BaseModel, ConfigDict, Field from one_memory.diagnostic_log import diagnostic_event, diagnostic_exception, elapsed_ms from one_memory.hy_lifecycle import HyLifecycle, HyLifecycleUnreadable, read_hy_lifecycle -from one_memory.hy_permission_store import native_store_read_slot from one_memory.hy_presence import HyPresence, confirm_deleted from one_memory.hy_runtime_config import ( HY_MEMORY_MODE, @@ -32,6 +31,7 @@ from one_memory.hy_runtime_config import ( diagnostic_log_level, ) from one_memory.hy_source_context import HySourceContext, HyWriteBindings +from one_memory.hy_store_concurrency import native_store_read_slot from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind, attach_disable_thinking from one_memory.semantic_deadline import SemanticMemoryHit @@ -376,11 +376,13 @@ class HyMemoryBridge: api_key: str | None, data_dir: Path, client: HyMemoryClientProtocol | None = None, + http_transport_factory: Callable[[], Any] | None = None, ) -> None: self.profile = profile self.api_key = api_key self.data_dir = data_dir self._client = client + self._http_transport_factory = http_transport_factory self._client_lock = asyncio.Lock() self._write_lock = asyncio.Lock() self._last_bindings: HyWriteBindings | None = None @@ -427,6 +429,13 @@ class HyMemoryBridge: ) client = await self._get_client() guards = getattr(client, "_one_memory_write_guards", ()) + from one_memory.hy_response_guard import HyReconcileGuard + + reconcile_guards = tuple( + guard for guard in guards if isinstance(guard, HyReconcileGuard) + ) + for guard in reconcile_guards: + guard.begin_write() before = [len(guard.failures) for guard in guards] bindings = HyWriteBindings(request.source_context) if request.source_context else None if bindings: @@ -500,6 +509,12 @@ class HyMemoryBridge: if len(guard.failures) > count: raise HyMemoryBridgeError(guard.failures[count]) result = await self._result(client, request, raw, expected_contents=None, bindings=bindings) + result = await _with_native_mutation_delta( + client, + result, + raw, + reconcile_guards, + ) if bindings: outputs = [] for memory in result.memories: @@ -553,6 +568,8 @@ class HyMemoryBridge: from one_memory.hy_response_guard import HyReconcileGuard report_guards = tuple(guard for guard in guards if isinstance(guard, HyReconcileGuard)) + for guard in report_guards: + guard.begin_write() expected_contents = frozenset(content.strip() for content in contents if content.strip()) for guard in report_guards: guard.imported_contents = expected_contents @@ -599,6 +616,12 @@ class HyMemoryBridge: raw, expected_contents=expected_contents, ) + result = await _with_native_mutation_delta( + client, + result, + raw, + report_guards, + ) diagnostic_event( level="info", component="hy.bridge", @@ -1087,6 +1110,7 @@ class HyMemoryBridge: self.profile, api_key=self.api_key, data_dir=self.data_dir, + http_transport_factory=self._http_transport_factory, ) await _joined_thread(initialize) @@ -1136,6 +1160,7 @@ def create_hy_memory_client( *, api_key: str | None, data_dir: Path, + http_transport_factory: Callable[[], Any] | None = None, ) -> HyMemoryClientProtocol: configure_hy_memory_environment(data_dir) config = build_hy_memory_config( @@ -1143,6 +1168,7 @@ def create_hy_memory_client( api_key=api_key, data_dir=data_dir, ) + config.http_transport_factory = http_transport_factory reattach_legacy_vector_collection(profile, config) mismatch = inspect_graph_width_mismatch(config) if mismatch is not None: @@ -1287,6 +1313,74 @@ async def _coding_result( return HyMemoryIngestResult(success=True, memories=tuple(outputs)) +async def _with_native_mutation_delta( + client: HyMemoryClientProtocol, + result: HyMemoryIngestResult, + raw: Mapping[str, object], + reconcile_guards: Sequence[Any], +) -> HyMemoryIngestResult: + """Map native SDK mutations back into MemHub governance. + + Store permission wrappers used to discover this by intercepting every + native CRUD call. The embedded SDK now owns unrestricted local CRUD, so + chat targets come from the reconcile observer and coding targets come from + the SDK's public result document. + """ + + retired = set(result.retired_memory_ids) + superseded = { + key: tuple(values) for key, values in result.superseded_memory_ids.items() + } + chat_targets = { + key + for guard in reconcile_guards + for key in getattr(guard, "mutation_target_ids", ()) + if isinstance(key, str) and key + } + + for item in raw.get("ops", ()) if isinstance(raw.get("ops"), list) else (): + if not isinstance(item, Mapping): + continue + if item.get("action") == "DELETE": + key = item.get("target_experience_id") + if isinstance(key, str) and key: + retired.add(key) + + for item in raw.get("fact_ops", ()) if isinstance(raw.get("fact_ops"), list) else (): + if not isinstance(item, Mapping) or item.get("action") != "SUPERSEDE": + continue + old, new = item.get("fact_id"), item.get("superseded_by") + if isinstance(old, str) and old: + retired.add(old) + if isinstance(new, str) and new: + superseded[old] = (new,) + + for key in chat_targets: + record = await _joined_thread(_read_hy_record, client, key) + if record is None: + retired.add(key) + continue + status = str(record.get("status", "")).lower() + if status in {"deleted", "shadow", "superseded"}: + retired.add(key) + replacement = record.get("superseded_by") + if isinstance(replacement, str) and replacement: + superseded[key] = (replacement,) + elif isinstance(replacement, list): + values = tuple( + value for value in replacement if isinstance(value, str) and value + ) + if values: + superseded[key] = values + + return result.model_copy( + update={ + "retired_memory_ids": tuple(sorted(retired)), + "superseded_memory_ids": superseded, + } + ) + + def _read_hy_record( client: HyMemoryClientProtocol, memory_id: str, diff --git a/memhub/apps/core/src/one_memory/hy_permission_coding_store.py b/memhub/apps/core/src/one_memory/hy_permission_coding_store.py deleted file mode 100644 index 7825946..0000000 --- a/memhub/apps/core/src/one_memory/hy_permission_coding_store.py +++ /dev/null @@ -1,607 +0,0 @@ -"""Operation-bound read/write capability for the pinned native HY coding store. - -HY's coding route writes two layers, experiences and working facts. Rows this -lease cannot attest stay in the same backend but are invisible here, so they -never reach a coding prompt and cannot be updated, superseded or linked. - -HY's reconcilers catch store exceptions and carry on, so a denial latches and -recheck() raises it again at finish; a swallowed denial still fails the task. -Any native method not implemented here is denied rather than passed through. -""" - -from __future__ import annotations - -import asyncio -import json -from collections.abc import Iterator, Mapping, Sequence -from copy import deepcopy -from hashlib import sha256 -from types import MappingProxyType -from typing import Any, NoReturn - -from hy_memory.coding.types import CodingMemory, WorkingFact # type: ignore[import-untyped] - -from one_memory.hy_permission_store import HyNodePermission -from one_memory.model_processing_permission_guard import ( - ModelProcessingPermissionLease, - permission_error, -) - -_NATIVE_WRITES = frozenset( - { - "insert", - "update", - "delete", - "insert_fact", - "supersede_fact", - "set_fact_derived_experiences", - "set_experience_source_facts", - # Trace rows are raw conversation: writes must be gated like any other. - "append_trace", - } -) - -_TIMESTAMPS = frozenset({"created_at", "updated_at"}) - -# SDK 2.0.2 reads `config` off the coding store while routing a coding -# turn. The wrapper must answer it, but the native `MemoryConfig` carries -# credentials (llm/embedder api keys, graph and cache passwords) and is a live -# handle into the backend, so it is never handed out. Only this redacted -# projection is exposed: credential-bearing keys collapse to None. -_CONFIG_REDACTED_KEYS = frozenset( - { - "api_key", - "password", - "username", - "mysql_password", - "mysql_user", - "extra_headers", - "extra_body", - } -) - - -class _RedactedConfig(Mapping[str, Any]): - """Read-only config view: readable by key and attribute, never the native one. - - The SDK reaches config values both ways, so this keeps attribute access - working. It is deliberately not the native MemoryConfig: it exposes only - plain copied values, so no backend handle, connection or callable escapes. - """ - - __slots__ = ("_values",) - - def __init__(self, values: Mapping[str, Any]) -> None: - object.__setattr__(self, "_values", MappingProxyType(dict(values))) - - def __getitem__(self, key: str) -> Any: - return self._values[key] - - def __iter__(self) -> Iterator[str]: - return iter(self._values) - - def __len__(self) -> int: - return len(self._values) - - def __getattr__(self, name: str) -> Any: - values = object.__getattribute__(self, "_values") - try: - return values[name] - except KeyError: - raise AttributeError(name) from None - - def __setattr__(self, name: str, value: Any) -> NoReturn: - raise AttributeError("redacted config is read-only") - - def __repr__(self) -> str: - return f"_RedactedConfig({sorted(self._values)})" - - -def _redact_config(value: Any, depth: int = 0) -> Any: - """Copy config data into read-only views, collapsing credentials to None. - - The SDK reads this both by attribute and by key, so each config section - becomes a mapping-like view rather than a plain dict: attribute access stays - available while the native object itself is never handed out. - """ - if depth > 4: - return None - if value is None or isinstance(value, (str, int, float, bool)): - return value - if isinstance(value, Mapping): - return _RedactedConfig( - { - key: None if key in _CONFIG_REDACTED_KEYS else _redact_config(item, depth + 1) - for key, item in value.items() - } - ) - if isinstance(value, (list, tuple)): - return [_redact_config(item, depth + 1) for item in value] - if hasattr(value, "__dict__") and type(value).__module__.startswith("hy_memory"): - return _redact_config(vars(value), depth + 1) - return None - - -def _config_projection(native: Any) -> Any: - try: - return _redact_config(native.config) - except Exception: - return None - - -def hy_coding_fingerprint(row: CodingMemory | WorkingFact) -> str: - """Hash the complete native row as read back, including links and timestamps.""" - if type(row) is CodingMemory: - kind = "experience" - elif type(row) is WorkingFact: - kind = "fact" - else: - raise permission_error("hy_coding_store_row_shape") - payload = {"kind": kind, **row.to_dict()} - return sha256( - json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False).encode() - ).hexdigest() - - -def _row_id(row: CodingMemory | WorkingFact) -> str: - return str(row.experience_id if type(row) is CodingMemory else row.fact_id) - - -class HyPermissionCodingStore: - """Closed, task-exclusive adapter; initialize/close never own the native store. - - node_permissions is the same attested ledger the chat vector store uses; - coding rows are keyed by experience or fact id. written_nodes carries the - whole lease footprint for each row written here, as the chat store does. - """ - - def __init__( - self, - native: Any, - lease: ModelProcessingPermissionLease, - *, - agent_id: str, - session_id: str, - node_permissions: Mapping[str, HyNodePermission] | None = None, - ) -> None: - self._native = native - self._lease = lease - self._agent_id = agent_id - self._session_id = session_id - self._user_id = lease.user_id - self._failure: str | None = None - self._closed = False - self._lock = asyncio.Lock() - self._deleted: set[str] = set() - self._written: dict[str, HyNodePermission] = {} - self._superseded: dict[str, str] = {} - self._evidence_ids = tuple(record.evidence_id for record in lease.records) - self._permissions = { - key: proof - for key, proof in (node_permissions or {}).items() - if set(proof.evidence_ids) <= set(self._evidence_ids) - } - self.recheck() - if not agent_id or agent_id == "*" or not session_id or not self._evidence_ids: - self._deny("scope") - - def __getattr__(self, name: str) -> NoReturn: - if name.startswith("__"): - raise AttributeError(name) - self._deny("unsupported_operation") - - @property - def written_nodes(self) -> Mapping[str, HyNodePermission]: - self.recheck() - return MappingProxyType(dict(self._written)) - - @property - def config(self) -> Any: - """Read-only, redacted view of the native coding config. - - SDK 2.0.2 reads `config` when it routes a coding turn. Answering - it is safe only as a projection: the native MemoryConfig is a live - backend handle and carries credentials, so callers get plain values - with every credential-bearing key collapsed to None. This carries no - identity, permission or tenant state and is not the native object. - """ - self._assert_open() - return _config_projection(self._native) - - @property - def retired_ids(self) -> frozenset[str]: - """Experiences HY deleted in this operation.""" - self.recheck() - return frozenset(self._deleted) - - @property - def superseded_ids(self) -> Mapping[str, tuple[str, ...]]: - """Facts HY superseded in this operation, with the fact that replaced each. - - A successor linked before it existed must have been written here by - now; otherwise the old fact points at a row nobody attested. - """ - self.recheck() - if any(new not in self._written for new in self._superseded.values()): - self._deny("link_denied") - return MappingProxyType({old: (new,) for old, new in self._superseded.items()}) - - def recheck(self) -> None: - self._assert_open() - try: - self._lease.recheck() - except Exception as error: - code = str(error) - self._failure = ( - code - if code.startswith("model_content_") and len(code) <= 100 - else "model_content_hy_coding_store_lease_failed" - ) - raise permission_error(self._failure) from None - - def _assert_open(self) -> None: - if self._closed: - self._deny("closed") - if self._failure: - raise permission_error(self._failure) - - async def close(self) -> None: - async with self._lock: - self._closed = True - self._permissions.clear() - self._written.clear() - - def _deny(self, suffix: str) -> NoReturn: - self._failure = self._failure or f"model_content_hy_coding_store_{suffix}" - raise permission_error(self._failure) - - def _scope(self, user_id: str | None) -> None: - if user_id not in (None, self._user_id): - self._deny("scope") - - async def _invoke(self, method: str, *args: Any, **kwargs: Any) -> Any: - writing = method in _NATIVE_WRITES - self.recheck() if writing else self._assert_open() - try: - result = await getattr(self._native, method)(*args, **kwargs) - except Exception: - self._deny("backend_failed") - self.recheck() if writing else self._assert_open() - return result - - def _visible(self, row: Any, expected: Any) -> bool: - if row is None: - return False - if type(row) is not expected: - self._deny("row_shape") - key = _row_id(row) - proof = self._permissions.get(key) - if proof is None or key in self._deleted or row.user_id != self._user_id: - return False - if hy_coding_fingerprint(row) != proof.fingerprint: - self._deny("row_changed") - return True - - async def _experience(self, key: str) -> CodingMemory | None: - if key not in self._permissions or key in self._deleted: - return None - row = await self._invoke("get_by_id", key, user_id=self._user_id) - return deepcopy(row) if self._visible(row, CodingMemory) else None - - async def _fact(self, key: str) -> WorkingFact | None: - if key not in self._permissions or key in self._deleted: - return None - rows = await self._invoke("get_facts", [key], user_id=self._user_id) - row = rows[0] if rows else None - return deepcopy(row) if self._visible(row, WorkingFact) else None - - def _new_row_in_scope(self, row: CodingMemory | WorkingFact) -> bool: - return bool( - row.user_id == self._user_id - and row.agent_id == self._agent_id - and row.session_id == self._session_id - ) - - async def _capture( - self, key: str, kind: Any, expected: CodingMemory | WorkingFact | None = None - ) -> None: - row: Any - if kind is CodingMemory: - row = await self._invoke("get_by_id", key, user_id=self._user_id) - else: - rows = await self._invoke("get_facts", [key], user_id=self._user_id) - row = rows[0] if rows else None - if type(row) is not kind or row.user_id != self._user_id: - self._deny("write_mismatch") - if expected is not None: - stored, planned = row.to_dict(), expected.to_dict() - for field in _TIMESTAMPS: - stored.pop(field, None) - planned.pop(field, None) - if stored != planned: - self._deny("write_mismatch") - proof = HyNodePermission(hy_coding_fingerprint(row), self._evidence_ids) - self._permissions[key] = proof - self._written[key] = proof - - async def _plan_new(self, row: CodingMemory | WorkingFact, kind: Any) -> None: - if type(row) is not kind or not self._new_row_in_scope(row): - self._deny("scope") - key = _row_id(row) - if not key or key in self._permissions or key in self._deleted: - self._deny("scope") - if kind is CodingMemory: - existing = await self._invoke("get_by_id", key) - else: - existing = next(iter(await self._invoke("get_facts", [key])), None) - if existing is not None: - self._deny("scope") - - # Reads - - async def get_by_id( - self, experience_id: str, user_id: str | None = None - ) -> CodingMemory | None: - async with self._lock: - self._scope(user_id) - return await self._experience(experience_id) - - async def get_many( - self, experience_ids: list[str], user_id: str | None = None - ) -> list[CodingMemory]: - async with self._lock: - self._scope(user_id) - wanted = [ - key - for key in dict.fromkeys(experience_ids) - if key in self._permissions and key not in self._deleted - ] - if not wanted: - return [] - rows = await self._invoke("get_many", wanted, user_id=self._user_id) - return [deepcopy(row) for row in rows if self._visible(row, CodingMemory)] - - async def search_by_query_embedding( - self, - query_embedding: list[float], - user_id: str, - *, - workspace_id: str | None = None, - branch: str | None = None, - top: int = 40, - ) -> list[dict[str, Any]]: - async with self._lock: - self._scope(user_id) - hits = await self._invoke( - "search_by_query_embedding", - deepcopy(query_embedding), - self._user_id, - workspace_id=workspace_id, - branch=branch, - top=top * 4, - ) - result = [] - for hit in hits: - key = hit.get("experience_id") - if not isinstance(key, str) or await self._experience(key) is None: - continue - result.append( - { - "experience_id": key, - "score": float(hit.get("score", 0.0)), - "matched_key_kind": hit.get("matched_key_kind"), - "matched_key_text": hit.get("matched_key_text"), - } - ) - return result[:top] - - async def get_fact(self, fact_id: str, user_id: str | None = None) -> WorkingFact | None: - async with self._lock: - self._scope(user_id) - return await self._fact(fact_id) - - async def get_facts(self, fact_ids: list[str], user_id: str | None = None) -> list[WorkingFact]: - async with self._lock: - self._scope(user_id) - found = [await self._fact(key) for key in dict.fromkeys(fact_ids)] - return [row for row in found if row is not None] - - async def search_facts( - self, - query_embedding: list[float], - user_id: str, - *, - workspace_id: str | None = None, - branch: str | None = None, - top: int = 5, - query_text: str | None = None, - min_score: float = 0.0, - ) -> list[tuple[WorkingFact, float]]: - async with self._lock: - self._scope(user_id) - hits = await self._invoke( - "search_facts", - deepcopy(query_embedding), - self._user_id, - workspace_id=workspace_id, - branch=branch, - top=top * 4, - query_text=query_text, - min_score=min_score, - ) - result = [] - for row, score in hits: - if self._visible(row, WorkingFact): - result.append((deepcopy(row), float(score))) - return result[:top] - - async def get_fact_ancestors( - self, fact_id: str, *, user_id: str | None = None, max_depth: int = 8 - ) -> list[WorkingFact]: - async with self._lock: - self._scope(user_id) - if await self._fact(fact_id) is None: - return [] - rows = await self._invoke( - "get_fact_ancestors", fact_id, user_id=self._user_id, max_depth=max_depth - ) - # Oldest first; the chain is only as long as its nearest unapproved link. - kept: list[WorkingFact] = [] - for row in reversed(rows): - if not self._visible(row, WorkingFact): - break - kept.append(deepcopy(row)) - return list(reversed(kept)) - - # Writes - - async def insert(self, memory: CodingMemory) -> None: - async with self._lock: - self.recheck() - await self._plan_new(memory, CodingMemory) - await self._invoke("insert", memory) - await self._capture(memory.experience_id, CodingMemory, memory) - - async def update(self, memory: CodingMemory) -> None: - async with self._lock: - self.recheck() - if type(memory) is not CodingMemory or not self._new_row_in_scope(memory): - self._deny("scope") - if await self._experience(memory.experience_id) is None: - self._deny("target_denied") - await self._invoke("update", memory) - await self._capture(memory.experience_id, CodingMemory, memory) - - async def delete(self, experience_id: str, user_id: str) -> bool: - async with self._lock: - self._scope(user_id) - if await self._experience(experience_id) is None: - self._deny("target_denied") - if await self._invoke("delete", experience_id, user_id=self._user_id) is not True: - self._deny("write_failed") - if await self._invoke("get_by_id", experience_id) is not None: - self._deny("write_mismatch") - self._deleted.add(experience_id) - self._permissions.pop(experience_id, None) - self._written.pop(experience_id, None) - return True - - async def insert_fact(self, fact: WorkingFact) -> None: - async with self._lock: - self.recheck() - await self._plan_new(fact, WorkingFact) - await self._invoke("insert_fact", fact) - await self._capture(fact.fact_id, WorkingFact, fact) - - async def supersede_fact(self, old_fact_id: str, new_fact_id: str) -> bool: - async with self._lock: - self.recheck() - if await self._fact(old_fact_id) is None or old_fact_id == new_fact_id: - self._deny("target_denied") - # HY links the old fact before it inserts the new one. A successor - # that does not exist yet may only become a row this lease writes; - # an existing row it cannot attest stays out of reach. - if await self._fact(new_fact_id) is None: - if not new_fact_id or new_fact_id in self._deleted: - self._deny("target_denied") - if next(iter(await self._invoke("get_facts", [new_fact_id])), None) is not None: - self._deny("target_denied") - changed = await self._invoke("supersede_fact", old_fact_id, new_fact_id) - await self._capture(old_fact_id, WorkingFact) - if changed: - self._superseded[old_fact_id] = new_fact_id - return bool(changed) - - async def set_fact_derived_experiences(self, fact_id: str, experience_ids: list[str]) -> None: - async with self._lock: - self.recheck() - if await self._fact(fact_id) is None: - self._deny("target_denied") - for key in experience_ids: - if await self._experience(key) is None: - self._deny("link_denied") - await self._invoke("set_fact_derived_experiences", fact_id, list(experience_ids)) - await self._capture(fact_id, WorkingFact) - - async def set_experience_source_facts(self, experience_id: str, fact_ids: list[str]) -> None: - async with self._lock: - self.recheck() - if await self._experience(experience_id) is None: - self._deny("target_denied") - for key in fact_ids: - if await self._fact(key) is None: - self._deny("link_denied") - await self._invoke("set_experience_source_facts", experience_id, list(fact_ids)) - await self._capture(experience_id, CodingMemory) - - async def get_recent_trace_messages( - self, - *, - user_id: str, - agent_id: str = "default_agent", - max_user_turns: int = 5, - ) -> list[Any]: - """Read recent raw trace rows, still bounded by this lease. - - Public SDK 2.0.2 asks for recent history before a coding write. The native call is - scoped to the lease's own user and agent, and rows this lease cannot - attest are filtered out, so unapproved conversations never reach a - coding prompt through this path. - """ - self._scope(user_id) - if agent_id not in (self._agent_id, "*"): - self._deny("scope") - rows = await self._invoke( - "get_recent_trace_messages", - user_id=self._user_id, - agent_id=self._agent_id, - max_user_turns=max_user_turns, - ) - return [row for row in (rows or []) if self._trace_visible(row)] - - async def append_trace( - self, - messages: Sequence[Any], - *, - request_id: str, - user_id: str, - agent_id: str = "default_agent", - session_id: str | None = None, - workspace_id: str | None = None, - branch: str | None = None, - ) -> None: - """Append raw trace rows for this lease only. - - Trace rows are raw conversation, so this is a write path: the lease is - rechecked around the call and every message must belong to the leased - user and session. Nothing outside the lease may be recorded. - """ - self._scope(user_id) - if agent_id not in (self._agent_id, "*"): - self._deny("scope") - if session_id not in (None, self._session_id): - self._deny("scope") - for row in messages or []: - if not self._trace_visible(row): - self._deny("scope") - await self._invoke( - "append_trace", - list(messages or []), - request_id=request_id, - user_id=self._user_id, - agent_id=self._agent_id, - session_id=self._session_id, - workspace_id=workspace_id, - branch=branch, - ) - - def _trace_visible(self, row: Any) -> bool: - """A trace row may be read or written only inside this lease's scope.""" - if row is None: - return False - owner = getattr(row, "user_id", None) - session = getattr(row, "session_id", None) - return not ( - (owner is not None and owner != self._user_id) - or (session is not None and session != self._session_id) - ) diff --git a/memhub/apps/core/src/one_memory/hy_permission_context.py b/memhub/apps/core/src/one_memory/hy_permission_context.py deleted file mode 100644 index 5f1f4d3..0000000 --- a/memhub/apps/core/src/one_memory/hy_permission_context.py +++ /dev/null @@ -1,582 +0,0 @@ -"""Task-exclusive HY wiring; provenance metadata stays beside the existing Core DB.""" - -from __future__ import annotations - -import asyncio -import json -from collections.abc import AsyncIterator, Callable -from contextlib import asynccontextmanager, nullcontext, suppress -from pathlib import Path -from typing import Any, cast - -import httpx - -from one_memory.database import take_the_write_lock_first -from one_memory.database_access import main_database -from one_memory.hy_memory_bridge import ( - HyMemoryBridge, - HyMemoryBridgeError, - HyMemoryIngestRequest, - HyMemoryIngestResult, - HyMemoryOutput, - LocalHyEmbedService, - _joined_thread, -) -from one_memory.hy_permission_coding_store import HyPermissionCodingStore -from one_memory.hy_permission_store import ( - HyNodePermission, - HyPermissionGraphStore, - HyPermissionVectorStore, -) -from one_memory.hy_permission_transport import HyPermissionTransport, HyPermissionTransportError -from one_memory.hy_response_guard import HyResponseGuard -from one_memory.hy_step_timing import HyStepTimer -from one_memory.memory_server.migrations import hy_node_permissions_ddl -from one_memory.memory_tasks import evidence_ref -from one_memory.model_processing_permission_guard import ModelProcessingPermissionLease - - -class _NativeProofs: - def __init__(self, database: Path) -> None: - self.database = database - self._loaded_states: dict[str, tuple[int, str]] | None = None - with main_database(database).write() as db, db: - # Schema owned by `memory_server.migrations` (migration 26). - db.execute(hy_node_permissions_ddl("sqlite")) - - def load(self, lease: ModelProcessingPermissionLease) -> dict[str, HyNodePermission]: - allowed = {record.evidence_id: evidence_ref(record) for record in lease.records} - with main_database(self.database).read() as db: - rows = db.execute( - "SELECT p.node_id,p.fingerprint,p.evidence_json,g.revision,g.status " - "FROM hy_node_permissions p LEFT JOIN governed_memories g ON g.memory_id=p.node_id " - # Superseded nodes stay attested: every later head links back to them. - "WHERE g.status IS NULL OR g.status IN ('confirmed','superseded')" - ).fetchall() - proofs = {} - states = {} - for identifier, fingerprint, evidence_json, revision, status in rows: - refs = json.loads(evidence_json) - if ( - not isinstance(refs, list) - or not refs - or not all(isinstance(ref, dict) for ref in refs) - ): - continue - if all(allowed.get(ref.get("evidence_id")) == ref for ref in refs): - proofs[identifier] = HyNodePermission( - fingerprint, tuple(ref["evidence_id"] for ref in refs) - ) - if revision is not None: - states[identifier] = (revision, status) - self._loaded_states = states - return proofs - - def guard(self, values: dict[str, HyNodePermission]) -> Callable[[], None]: - identifiers = tuple(values) - - def state() -> dict[str, tuple[int, str]]: - found: dict[str, tuple[int, str]] = {} - with main_database(self.database).read() as db: - for start in range(0, len(identifiers), 900): - batch = identifiers[start : start + 900] - placeholders = ",".join("?" for _ in batch) - for identifier, revision, status in db.execute( - "SELECT memory_id,revision,status FROM governed_memories " - f"WHERE memory_id IN ({placeholders})", - batch, - ): - found[identifier] = (revision, status) - return found - - if self._loaded_states is None: - raise HyMemoryBridgeError("model_content_context_unverified") - before = dict(self._loaded_states) - - def check() -> None: - if state() != before: - raise HyMemoryBridgeError("model_content_context_changed") - - check() - return check - - def save( - self, lease: ModelProcessingPermissionLease, values: dict[str, HyNodePermission] - ) -> None: - refs = {record.evidence_id: evidence_ref(record) for record in lease.records} - with main_database(self.database).write(autocommit=True) as db: - take_the_write_lock_first(db) - with db: - lease.recheck() - db.executemany( - "INSERT INTO hy_node_permissions VALUES (?,?,?) ON CONFLICT(node_id) " - "DO UPDATE SET fingerprint=excluded.fingerprint," - "evidence_json=excluded.evidence_json", - [ - ( - identifier, - proof.fingerprint, - json.dumps([refs[key] for key in proof.evidence_ids]), - ) - for identifier, proof in values.items() - ], - ) - - def refresh( - self, lease: ModelProcessingPermissionLease, node_id: str - ) -> HyNodePermission | None: - del lease - with main_database(self.database).read() as db: - row = db.execute( - "SELECT fingerprint,evidence_json FROM hy_node_permissions WHERE node_id=?", - (node_id,), - ).fetchone() - if row is None: - return None - fingerprint = str(row["fingerprint"]) - refs = json.loads(row["evidence_json"]) - if ( - len(fingerprint) != 64 - or any(value not in "0123456789abcdef" for value in fingerprint) - or not isinstance(refs, list) - or not refs - or not all(isinstance(ref, dict) for ref in refs) - ): - return None - evidence_ids = tuple(ref.get("evidence_id") for ref in refs) - if any(not isinstance(value, str) or not value for value in evidence_ids): - return None - return HyNodePermission(fingerprint, evidence_ids) - - -class _BoundLease: - def __init__( - self, - lease: ModelProcessingPermissionLease, - proof_check: Callable[[], None], - step_timer: HyStepTimer | None = None, - ) -> None: - self.lease = lease - self.step_timer = step_timer - self.user_id = lease.user_id - self.profile = lease.profile - self.task_id = lease.task_id - self.records = lease.records - self.source_context = lease.source_context - self.proof_check = proof_check - self.vector: HyPermissionVectorStore | None = None - self.coding: HyPermissionCodingStore | None = None - - def recheck(self) -> None: - timer = self.step_timer - with timer.step("permission_check") if timer is not None else nullcontext(): - self.lease.recheck() - self.proof_check() - if self.vector is not None: - self.vector.recheck() - if self.coding is not None: - self.coding.recheck() - - -class _Wiring: - def __init__( - self, - client: Any, - lease: ModelProcessingPermissionLease, - proofs_store: _NativeProofs, - proofs: dict[str, HyNodePermission], - agent_id: str, - session_id: str, - transport: httpx.AsyncBaseTransport | None, - proof_check: Callable[[], None], - step_timer: HyStepTimer, - ) -> None: - self.client, self.lease, self.proofs_store, self.proofs = ( - client, - lease, - proofs_store, - proofs, - ) - self.step_timer = step_timer - self.agent_id, self.session_id, self.http_transport = agent_id, session_id, transport - self.cross_evidence_scope = False - self.proof_check = proof_check - self.saved: list[tuple[Any, str, Any]] = [] - self.transport: HyPermissionTransport - self.vector: HyPermissionVectorStore - self.graph: HyPermissionGraphStore | None = None - self.coding: HyPermissionCodingStore - self.active = False - - def replace(self, target: Any, name: str, value: Any) -> None: - self.saved.append((target, name, getattr(target, name))) - setattr(target, name, value) - - async def enter(self) -> None: - client, registry = self.client, self.client._registry - config = client._config - if config.llm.enable_summary or config.llm.enable_query_rewrite: - raise HyMemoryBridgeError("model_content_context_unverified") - bound = _BoundLease(self.lease, self.proof_check, self.step_timer) - self.transport = await HyPermissionTransport( - bound, http_transport=self.http_transport, step_timer=self.step_timer - ).__aenter__() - self.active = True - try: - for guard in getattr(client, "_one_memory_write_guards", ()): - if hasattr(type(guard), "step_timer"): - self.replace(guard, "step_timer", self.step_timer) - system2 = await registry.get_system2_writer() - writers = (await registry.get_writer(), system2._writer) - native_vector = client._vector_store - store_lease = _BoundLease(self.lease, self.proof_check, self.step_timer) - self.vector = HyPermissionVectorStore( - native_vector, - cast(ModelProcessingPermissionLease, store_lease), - agent_id=self.agent_id, - session_id=self.session_id, - cross_evidence_scope=self.cross_evidence_scope, - node_permissions=self.proofs, - refresh_permission=lambda node_id: asyncio.to_thread( - self.proofs_store.refresh, self.lease, node_id - ), - step_timer=self.step_timer, - ) - bound.vector = self.vector - embed = client._embed_service - if isinstance(embed, LocalHyEmbedService): - self.transport.bind_local_embed_service(embed) - else: - self.transport.bind_embed_service(embed) - for writer in writers: - provider = writer.mem_agent.llm_provider - self.transport.bind_provider( - provider.provider if isinstance(provider, HyResponseGuard) else provider - ) - reconciler = writer.reconciler - provider = reconciler._get_llm() - self.transport.bind_provider( - provider.provider if isinstance(provider, HyResponseGuard) else provider - ) - self.replace(writer, "_external_vector_store", self.vector) - self.replace(writer, "_vector_store", self.vector) - self.replace(writer, "_vector_store_initialized", True) - self.replace(writer, "_cache", None) - self.replace(client, "_vector_store", self.vector) - native_graph = client._graph_store - if self.cross_evidence_scope and native_graph is not None: - self.graph = HyPermissionGraphStore(native_graph, self.proof_check) - self.replace(client, "_graph_store", self.graph) - else: - self.replace(client, "_graph_store", None) - self.replace(registry, "_shared_vector_store", self.vector) - self.replace( - registry, - "_shared_graph_store", - self.graph if self.graph is not None else None, - ) - self.replace(registry, "_shared_cache", None) - self.replace(system2, "_vector_store", self.vector) - self.replace( - system2, - "_graph_store", - self.graph if self.graph is not None else None, - ) - self.replace(system2, "_cache", None) - # Result fallback may call search; rebuild only its non-generative reader. - self.replace(registry, "_reader", None) - self.replace(registry, "_readers", {}) - await self._enter_coding(bound, embed) - self.transport.assert_allowed() - except BaseException: - await self.exit() - raise - - async def _enter_coding(self, bound: _BoundLease, embed: Any) -> None: - """HY judges and writes the coding route itself; every seam it uses is bound here.""" - client = self.client - # Created now, so the judge and every coding stage share one bound provider. - writer = await client._get_coding_writer() - stages = (writer, writer.extractor, writer.reconciler, writer.fact_reconciler) - native_store = client._coding_store - if any( - stage.store is not native_store - for stage in (writer, writer.reconciler, writer.fact_reconciler) - ) or any( - item.embed_service is not embed - for item in (native_store, writer, writer.reconciler, writer.fact_reconciler) - ): - raise HyMemoryBridgeError("model_content_context_unverified") - providers = { - id(p): p for p in (client._coding_llm_provider, *(s.llm_provider for s in stages)) - } - for provider in providers.values(): - self.transport.bind_provider(provider) - self.coding = HyPermissionCodingStore( - native_store, - cast( - ModelProcessingPermissionLease, - _BoundLease(self.lease, self.proof_check, self.step_timer), - ), - agent_id=self.agent_id, - session_id=self.session_id, - node_permissions=self.proofs, - ) - bound.coding = self.coding - for stage in (writer, writer.reconciler, writer.fact_reconciler): - self.replace(stage, "store", self.coding) - self.replace(client, "_coding_store", self.coding) - for stage in (writer, writer.extractor, writer.reconciler): - self.replace(stage, "cache", None) - # The judge logs its prompt through the client's own cache. - self.replace(client, "_cache", None) - - async def finish(self) -> dict[str, HyNodePermission]: - self.transport.assert_allowed() - self.vector.recheck() - self.coding.recheck() - return {**self.vector.written_nodes, **self.coding.written_nodes} - - async def retired(self) -> tuple[tuple[str, ...], dict[str, tuple[str, ...]]]: - self.transport.assert_allowed() - return ( - tuple(sorted(self.vector.retired_node_ids | self.coding.retired_ids)), - {**self.vector.superseded_nodes, **self.coding.superseded_ids}, - ) - - async def exit(self) -> None: - if not self.active: - return - self.active = False - try: - await self.transport.__aexit__(None, None, None) - finally: - for target, name, value in reversed(self.saved): - setattr(target, name, value) - self.saved.clear() - if hasattr(self, "vector"): - await self.vector.close() - if self.graph is not None: - await self.graph.close() - if hasattr(self, "coding"): - await self.coding.close() - - -class _LeasedBridge: - def __init__( - self, - bridge: HyMemoryBridge, - lease: ModelProcessingPermissionLease, - proofs: _NativeProofs, - transport: httpx.AsyncBaseTransport | None, - ) -> None: - self.bridge, self.lease, self.proofs, self.transport = bridge, lease, proofs, transport - self.wiring: _Wiring | None = None - self.finished = False - self.step_timer = HyStepTimer() - self.request_id: str | None = None - - def _step(self, name: str) -> Any: - return self.step_timer.step(name) - - async def _recheck(self) -> None: - def recheck() -> None: - with self._step("permission_check"): - self.lease.recheck() - - await _joined_thread(recheck) - - async def _start(self, request: HyMemoryIngestRequest) -> None: - if self.wiring is not None or self.finished: - raise HyMemoryBridgeError("model_content_context_reused") - if request.user_id != self.lease.user_id or set(request.evidence_ids) - { - item.evidence_id for item in self.lease.records - }: - raise HyMemoryBridgeError("model_content_context_unverified") - await self._start_operation( - user_id=request.user_id, - agent_id=request.agent_id, - request_id=request.request_id, - session_id=request.session_id, - ) - - async def _start_operation( - self, - *, - user_id: str, - agent_id: str, - request_id: str, - session_id: str, - cross_evidence_scope: bool = False, - ) -> None: - if self.wiring is not None or self.finished: - raise HyMemoryBridgeError("model_content_context_reused") - if user_id != self.lease.user_id: - raise HyMemoryBridgeError("model_content_context_unverified") - self.request_id = request_id - await self._recheck() - client = cast(Any, await self.bridge._get_client()) - with self._step("permission_store_read"): - known = await asyncio.to_thread(self.proofs.load, self.lease) - proof_check = await asyncio.to_thread(self.proofs.guard, known) - self.wiring = _Wiring( - client, - self.lease, - self.proofs, - known, - agent_id, - session_id, - self.transport, - proof_check, - self.step_timer, - ) - self.wiring.cross_evidence_scope = cross_evidence_scope - await _joined_thread(client._loop_thread.run, self.wiring.enter()) - - async def list_agent_ids(self, *, user_id: str, limit: int = 1_000) -> tuple[str, ...]: - if user_id != self.lease.user_id or self.wiring is not None or self.finished: - raise HyMemoryBridgeError("model_content_context_unverified") - await self._recheck() - result = await self.bridge.list_agent_ids(user_id=user_id, limit=limit) - await self._recheck() - return result - - async def digest(self, *, user_id: str, agent_id: str) -> dict[str, object]: - await self._start_operation( - user_id=user_id, - agent_id=agent_id, - request_id=f"digest:{self.lease.task_id}:{agent_id}", - session_id=f"digest:{self.lease.task_id}", - cross_evidence_scope=True, - ) - try: - result = dict(await self.bridge.digest(user_id=user_id, agent_id=agent_id)) - except HyMemoryBridgeError: - await self._check() - raise - proofs = await self._check() - with self._step("permission_store_write"): - await asyncio.to_thread(self.proofs.save, self.lease, proofs) - await self.close() - await self._recheck() - return result - - async def graph_snapshot(self, *, user_id: str, agent_id: str, limit: int = 300) -> Any: - await self._start_operation( - user_id=user_id, - agent_id=agent_id, - request_id=f"digest-graph:{self.lease.task_id}:{agent_id}", - session_id=f"digest:{self.lease.task_id}", - cross_evidence_scope=True, - ) - try: - result = await self.bridge.graph_snapshot( - user_id=user_id, - agent_id=agent_id, - limit=limit, - ) - except HyMemoryBridgeError: - await self._check() - raise - await self._check() - await self.close() - await self._recheck() - return result - - async def ingest(self, request: HyMemoryIngestRequest) -> HyMemoryIngestResult: - await self._start(request) - try: - result = await self.bridge.ingest(request) - except HyMemoryBridgeError: - await self._check() - raise - return await self._with_retired(result) - - async def ingest_extracted( - self, request: HyMemoryIngestRequest, contents: tuple[str, ...] - ) -> HyMemoryIngestResult: - await self._start(request) - try: - result = await self.bridge.ingest_extracted(request, contents) - except HyMemoryBridgeError: - await self._check() - raise - return await self._with_retired(result) - - async def _with_retired(self, result: HyMemoryIngestResult) -> HyMemoryIngestResult: - await self._check() - if self.wiring is None: - raise HyMemoryBridgeError("model_content_context_unverified") - try: - retired, superseded = await _joined_thread( - self.wiring.client._loop_thread.run, self.wiring.retired() - ) - except HyPermissionTransportError as error: - await self._recheck() - raise HyMemoryBridgeError(error.code) from None - return result.model_copy( - update={"retired_memory_ids": retired, "superseded_memory_ids": superseded} - ) - - async def _check(self) -> dict[str, HyNodePermission]: - if self.wiring is None or self.finished: - raise HyMemoryBridgeError("model_content_context_unverified") - await self._recheck() - try: - return await _joined_thread(self.wiring.client._loop_thread.run, self.wiring.finish()) - except HyPermissionTransportError as error: - await self._recheck() - raise HyMemoryBridgeError(error.code) from None - - async def profile_memories(self, *, user_id: str, agent_id: str) -> tuple[HyMemoryOutput, ...]: - if self.wiring is None or user_id != self.lease.user_id or agent_id != self.wiring.agent_id: - raise HyMemoryBridgeError("model_content_context_unverified") - result = await self.bridge.profile_memories(user_id=user_id, agent_id=agent_id) - proofs = await self._check() - with self._step("permission_store_write"): - await asyncio.to_thread(self.proofs.save, self.lease, proofs) - # SDK context closes before governance can update a previously known memory. - await self.close() - return result - - async def close(self) -> None: - if self.wiring is not None and not self.finished: - self.finished = True - try: - await _joined_thread(self.wiring.client._loop_thread.run, self.wiring.exit()) - finally: - self.step_timer.emit(request_id=self.request_id, task_id=self.lease.task_id) - - -def permission_bridge_factory( - resolve_bridge: Callable[..., Any], - *, - http_transport: httpx.AsyncBaseTransport | None = None, -) -> Callable[..., Any]: - @asynccontextmanager - async def factory( - lease: ModelProcessingPermissionLease, secret: str | None, data_dir: Path - ) -> AsyncIterator[Any]: - bridge = resolve_bridge(lease.profile, secret, data_dir) - if not isinstance(bridge, HyMemoryBridge): - raise HyMemoryBridgeError("model_content_context_unverified") - proofs = _NativeProofs(data_dir / "one-memory.sqlite3") - async with bridge.exclusive_access(): - await bridge.prepare() - bound = _LeasedBridge(bridge, lease, proofs, http_transport) - try: - yield bound - except BaseException as error: - with suppress(Exception): - await bound.close() - if isinstance(error, HyPermissionTransportError): - await _joined_thread(lease.recheck) - raise HyMemoryBridgeError(error.code) from None - raise - else: - try: - await bound.close() - except HyPermissionTransportError as error: - raise HyMemoryBridgeError(error.code) from None - - return factory diff --git a/memhub/apps/core/src/one_memory/hy_permission_store.py b/memhub/apps/core/src/one_memory/hy_permission_store.py deleted file mode 100644 index 8731627..0000000 --- a/memhub/apps/core/src/one_memory/hy_permission_store.py +++ /dev/null @@ -1,880 +0,0 @@ -"""Operation-bound read/write capability for the pinned native HY chat vector store. - -No semantic storage or extraction lives here. Pre-existing nodes require a full -native-node attestation with complete approved Evidence provenance; known content -alone cannot authorize tags, custom fields, vectors or links. Unknown nodes stay -in the same HY backend but are not exposed to this operation. - -The caller must exclusively own the native backend's writes during execution: -its API has no compare-and-swap, and a swallowed get_by_id error can look missing. -This component cannot make that check/upsert interval atomic. Factory integration -must replace client/registry/both writers/cached-reader references, bind physical -LLM/embedding sends to the lease AND this denial latch, and guard publication. -Coding rows go through hy_permission_coding_store instead; graph, entities, -digest, sweeper and summary remain unsupported. -""" - -from __future__ import annotations - -import asyncio -import json -import math -import struct -import weakref -from collections.abc import AsyncIterator, Awaitable, Callable, Mapping -from contextlib import asynccontextmanager, nullcontext -from copy import deepcopy -from dataclasses import dataclass -from hashlib import sha256 -from types import MappingProxyType -from typing import Any, NoReturn - -from hy_memory.models.memory import ( # type: ignore[import-untyped] - MemoryLayer, - MemoryNode, - MemoryStatus, -) - -from one_memory.hy_step_timing import HyStepTimer -from one_memory.model_processing_permission_guard import ( - ModelProcessingPermissionLease, - permission_error, -) - -_WRITE_LOCKS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = ( - weakref.WeakKeyDictionary() -) - - -def _native_write_lock() -> asyncio.Lock: - loop = asyncio.get_running_loop() - lock = _WRITE_LOCKS.get(loop) - if lock is None: - lock = asyncio.Lock() - _WRITE_LOCKS[loop] = lock - return lock - - -@asynccontextmanager -async def native_store_read_slot() -> AsyncIterator[bool]: - """Admit one native HY read only while no write is active. - - Chroma's native reader may retain the GIL after an asyncio timeout. Running - it concurrently with HY's multi-step store writes has deadlocked the Core - under sustained imports. Reads therefore share the physical store lock, - but never wait behind a writer: host recall can immediately use the local - governance ledger instead. - """ - lock = _native_write_lock() - if lock.locked(): - yield False - return - await lock.acquire() - try: - yield True - finally: - lock.release() - - -@dataclass(frozen=True) -class HyNodePermission: - """Trusted caller's full-node attestation, not permission inferred from an ID.""" - - fingerprint: str - evidence_ids: tuple[str, ...] - - -def hy_node_fingerprint(node: MemoryNode) -> str: - """Hash the complete native serialized node, including vectors and metadata. - - Timestamp precision follows HY's to_dict contract (integer Unix seconds). - Attest the node read from the backend, not an unpersisted input object. - """ - if type(node) is not MemoryNode: - raise permission_error("hy_store_node_shape") - return sha256( - json.dumps(node.to_dict(), sort_keys=True, separators=(",", ":"), allow_nan=False).encode() - ).hexdigest() - - -_CHAT_LAYERS = frozenset( - { - MemoryLayer.L0_BASIC_INFO, - MemoryLayer.L1_RAW, - MemoryLayer.L2_FACT, - MemoryLayer.L4_IDENTITY, - MemoryLayer.L7_INTENTION, - } -) -_UPDATE_FIELDS = frozenset( - { - "content", - "embedding", - "tags", - "tag_ners", - "layer", - "memory_at", - "gmt_modified", - "is_latest", - "status", - "supersedes", - "superseded_by", - "source_raw_memory_id", - # SDK 2.0.2 sends both of these on an in-place reconcile UPDATE. - # They are metadata only and must never carry identity, permission or - # tenant state: `owner` is a provenance label whose only values are - # "user" / "agent" (who said this line), and `custom` is the SDK's own - # free-form notes field. Neither may hold a user_id, scope, grant or - # tenancy marker, so allowing the field names does not widen the - # cross-user boundary that the store still enforces per node. - "owner", - "custom", - } -) -_DEDUP_AUDIT_FIELDS = frozenset({"shadow_reason", "dedup_kept_id"}) -_DIGEST_COUNTER_FIELD = "s2_evidence_count" - - -def _write_view(node: MemoryNode) -> dict[str, Any]: - """Only native Chroma's empty-string and float32 storage normalization.""" - payload: dict[str, Any] = node.to_dict() - payload["source_session_id"] = payload["source_session_id"] or "" - # The pinned public SDK serializes tag_ners as JSON, but its Chroma read path omits this - # field from the JSON decode list. Decode only the exact list-of-strings - # shape that the write path produced; malformed values still fail the - # complete-node comparison below. - tag_ners = payload.get("tag_ners") - if ( - isinstance(tag_ners, list) - and tag_ners - and all(isinstance(value, str) and len(value) == 1 for value in tag_ners) - and tag_ners[0] == "[" - and tag_ners[-1] == "]" - ): - tag_ners = "".join(tag_ners) - if isinstance(tag_ners, str): - try: - decoded = json.loads(tag_ners) - except (json.JSONDecodeError, TypeError): - decoded = None - if isinstance(decoded, list) and all(isinstance(value, str) for value in decoded): - payload["tag_ners"] = decoded - if payload.get("tag_ners") in ([], ["[", "]"]): - payload["tag_ners"] = [] - # HY Chroma encodes None as ""; these JSON fields keep "" when json.loads - # fails (vector_store_chroma.py:741-788). Only an absent link is equivalent. - for field in ("supersedes", "superseded_by"): - if payload[field] == "": - payload[field] = None - if payload["embedding"] is not None: - payload["embedding"] = [ - struct.unpack("f", struct.pack("f", n))[0] for n in payload["embedding"] - ] - if not all(math.isfinite(n) for n in payload["embedding"]): - raise ValueError("nonfinite_embedding") - return payload - - -def _same_written_vector(actual: list[float] | None, expected: list[float] | None) -> bool: - if actual == expected: - return True - if actual is None or expected is None or len(actual) != len(expected): - return False - # Chroma's cosine index can renormalize float32 vectors on readback. Bound - # only this write roundtrip; the saved full-node proof uses exact readback. - return all( - math.isclose(left, right, rel_tol=4 * 2**-23, abs_tol=0.0) - for left, right in zip(actual, expected, strict=True) - ) - - -_NATIVE_WRITES = frozenset( - {"upsert", "upsert_batch", "update_payload", "update_embedding", "delete"} -) - - -class HyPermissionGraphStore: - """Lease-gated graph store for HY System 2's digest transaction. - - The native graph is deliberately absent from ordinary ingest leases. Digest - needs it to write L6 schemas, but every operation must remain inside the - same frozen permission lease rather than exposing the raw store. - """ - - def __init__(self, native: Any, recheck: Any) -> None: - self._native = native - self._recheck = recheck - self._closed = False - - def recheck(self) -> None: - if self._closed: - raise permission_error("hy_graph_store_closed") - self._recheck() - - async def close(self) -> None: - self._closed = True - - def __getattr__(self, name: str) -> Any: - if name.startswith("_") and name != "_run": - raise AttributeError(name) - attribute = getattr(self._native, name) - if not callable(attribute): - return attribute - - async def guarded(*args: Any, **kwargs: Any) -> Any: - self.recheck() - result = await attribute(*args, **kwargs) - self.recheck() - return result - - return guarded - - -class HyPermissionVectorStore: - """Closed, task-exclusive adapter; initialize/close never own the native store. - - node_permissions may attest additional native raw/profile nodes not represented - in known_memories. Every attestation must cover all its contributors, and all - Evidence IDs must already be in this lease. Known entries also constrain exact - content/owner. Without a full attestation even a known entry is denied. - - written_nodes records successful writes with the entire lease footprint, a - conservative provenance bound, not a claim of semantic citation accuracy. - These transient fingerprints must be persisted by the integrating factory if - future operations are to reuse raw/profile nodes. No content is stored here. - """ - - __slots__ = ( - "__native", - "__lease", - "__agent_id", - "__session_id", - "__cross_evidence_scope", - "__user_id", - "__known", - "__permissions", - "__written", - "__evidence_ids", - "__deleted", - "__shadowed", - "__superseded", - "__heads", - "__lock", - "__closed", - "__failure", - "__step_timer", - "__refresh_permission", - ) - - def __init__( - self, - native: Any, - lease: ModelProcessingPermissionLease, - *, - agent_id: str, - session_id: str, - cross_evidence_scope: bool = False, - node_permissions: Mapping[str, HyNodePermission] | None = None, - refresh_permission: Callable[[str], Awaitable[HyNodePermission | None]] | None = None, - step_timer: HyStepTimer | None = None, - ) -> None: - self.__step_timer = step_timer - self.__native = native - self.__lease = lease - self.__agent_id = agent_id - self.__session_id = session_id - self.__cross_evidence_scope = cross_evidence_scope - self.__user_id = lease.user_id - self.__failure: str | None = None - self.__closed = False - self.__lock = asyncio.Lock() - self.__deleted: set[str] = set() - self.__written: dict[str, HyNodePermission] = {} - self.__shadowed: set[str] = set() - self.__superseded: dict[str, tuple[str, ...]] = {} - self.__heads: dict[str, tuple[str, ...]] = {} - self.__refresh_permission = refresh_permission - self.recheck() - self.__evidence_ids = tuple(record.evidence_id for record in lease.records) - current = lease.source_context.current if lease.source_context else lease.records - conversation_mismatch = any(r.conversation_id != session_id for r in current) - if ( - not lease.task_id - or not agent_id - or agent_id == "*" - or not session_id - or not current - or (conversation_mismatch and not cross_evidence_scope) - or not self.__evidence_ids - or len(set(self.__evidence_ids)) != len(lease.records) - or any(r.user_id != self.__user_id for r in lease.records) - ): - self.__deny("scope") - known = lease.source_context.known_memories if lease.source_context else () - self.__known = {item.hy_memory_id: item for item in known} - if len(self.__known) != len(known): - self.__deny("provenance") - self.__permissions = dict(node_permissions or {}) - for key, proof in self.__permissions.items(): - if ( - not key - or not isinstance(proof, HyNodePermission) - or len(proof.fingerprint) != 64 - or any(c not in "0123456789abcdef" for c in proof.fingerprint) - or not isinstance(proof.evidence_ids, tuple) - or not proof.evidence_ids - or len(set(proof.evidence_ids)) != len(proof.evidence_ids) - or not set(proof.evidence_ids) <= set(self.__evidence_ids) - ): - self.__deny("provenance") - for key, item in self.__known.items(): - if not item.evidence_ids or not set(item.evidence_ids) <= set(self.__evidence_ids): - self.__deny("provenance") - known_proof = self.__permissions.get(key) - if known_proof and not set(item.evidence_ids) <= set(known_proof.evidence_ids): - self.__deny("provenance") - - @property - def written_nodes(self) -> Mapping[str, HyNodePermission]: - """Latest verified write proofs; each access returns a new immutable snapshot. - - Export after SDK writes have drained and before close/governance projection. - Merge these rows into the lineage ledger, then remove deleted_node_ids; - this write delta does not include untouched pre-existing permissions. - """ - self.recheck() - return MappingProxyType(dict(self.__written)) - - @property - def deleted_node_ids(self) -> frozenset[str]: - """Verified native deletions in this operation, for lineage-ledger cleanup.""" - self.recheck() - return frozenset(self.__deleted) - - @property - def retired_node_ids(self) -> frozenset[str]: - """Nodes HY deleted or shadowed (its logical delete) in this operation.""" - self.recheck() - return frozenset(self.__deleted | self.__shadowed) - - @property - def superseded_nodes(self) -> Mapping[str, tuple[str, ...]]: - """Nodes HY marked superseded in this operation, with what replaced them. - - HY writes the two directions separately and may fail either one, so a - replacement counts whether it was recorded on the old node or only on - the new head. - """ - self.recheck() - result = {} - for key, by in self.__superseded.items(): - heads = set(by) | {head for head, olds in self.__heads.items() if key in olds} - heads -= self.__deleted | {key} - if key not in self.__deleted and heads: - result[key] = tuple(sorted(heads)) - return MappingProxyType(result) - - @property - def supports_fulltext(self) -> bool: - return False - - def recheck(self) -> None: - """Must also gate transport/publication after HY swallows a store failure.""" - self.__assert_open() - try: - self.__lease.recheck() - except Exception as error: - code = str(error) - self.__failure = ( - code - if code.startswith("model_content_") and len(code) <= 100 - else "model_content_hy_store_lease_failed" - ) - raise permission_error(self.__failure) from None - - def __assert_open(self) -> None: - if self.__closed: - self.__deny("closed") - if self.__failure: - raise permission_error(self.__failure) - - async def initialize(self) -> None: - self.recheck() - - async def close(self) -> None: - """Revoke this wrapper locally without rechecking context or closing HY. - - Cleanup must remain possible after governance projection changes the - lease's known-memory revisions, or after permission has been revoked. - """ - async with self.__lock: - self.__closed = True - self.__permissions.clear() - self.__written.clear() - - async def has_tag_embedding(self, user_id: str, tag: str) -> bool: - self.recheck() - # Native chat calls this best-effort helper. NotImplemented prevents it - # from embedding tags or falling through to an unguarded entity store. - raise NotImplementedError("hy_permission_store_tag_index_unsupported") - - def __deny(self, suffix: str) -> NoReturn: - self.__failure = self.__failure or f"model_content_hy_store_{suffix}" - raise permission_error(self.__failure) - - async def __invoke(self, method: str, *args: Any, **kwargs: Any) -> Any: - writing = method in _NATIVE_WRITES - self.recheck() if writing else self.__assert_open() - step = "vector_store_write" if writing else "vector_store_read" - first = args[0] if args else None - try: - with ( - self.__step_timer.step(step, items=len(first) if isinstance(first, list) else 1) - if self.__step_timer is not None - else nullcontext() - ): - result = await getattr(self.__native, method)(*args, **kwargs) - except Exception: - self.__deny("backend_failed") - self.recheck() if writing else self.__assert_open() - return result - - async def __read_native(self, key: str) -> MemoryNode | None: - value = await self.__invoke("get_by_id", key) - if value is not None and (type(value) is not MemoryNode or value.node_id != key): - self.__deny("node_shape") - return deepcopy(value) - - def __fingerprint(self, node: MemoryNode) -> str: - try: - return hy_node_fingerprint(node) - except (TypeError, ValueError, AttributeError, OverflowError): - self.__deny("node_shape") - - def __write_view(self, node: MemoryNode) -> dict[str, Any]: - self.__fingerprint(node) - try: - return _write_view(node) - except (TypeError, ValueError, OverflowError, struct.error): - self.__deny("node_shape") - - def __in_scope(self, node: MemoryNode) -> bool: - if node.user_id != self.__user_id or node.layer not in _CHAT_LAYERS: - return False - if node.layer is MemoryLayer.L0_BASIC_INFO: - # Public SDK 2.0.2 migrates verified legacy agent-level profiles into one - # user-level chain. Existing nodes still require a full proof in - # __load; accepting their historical agent id does not authorize - # unproven cross-agent memories. - return True - if self.__cross_evidence_scope: - return bool(node.agent_id == self.__agent_id) - return bool(node.agent_id == self.__agent_id and node.session_id == self.__session_id) - - def __new_node_in_scope(self, node: MemoryNode) -> bool: - if not self.__in_scope(node): - return False - if node.layer is MemoryLayer.L0_BASIC_INFO: - return node.agent_id in ("", self.__agent_id) and node.session_id in ( - "", - self.__session_id, - ) - return True - - def __references(self, node: MemoryNode) -> list[str]: - refs = [] - for field in ("supersedes", "superseded_by", "evidence_chain"): - values = getattr(node, field) - if field != "evidence_chain" and values == "": - continue - if values is not None: - if not isinstance(values, list) or any( - not isinstance(v, str) or not v for v in values - ): - self.__deny("node_shape") - refs.extend(values) - if node.source_raw_memory_id is not None: - if not isinstance(node.source_raw_memory_id, str) or not node.source_raw_memory_id: - self.__deny("node_shape") - refs.append(node.source_raw_memory_id) - return refs - - async def __load( - self, - key: str, - first: MemoryNode | None = None, - *, - allow_deleted_links: bool = False, - ) -> MemoryNode | None: - pending = [key] - checked: dict[str, MemoryNode] = {} - while pending: - current = pending.pop() - if current in checked: - continue - # HY dedup deletes head then ancestors. Only this operation's - # verified tombstones may terminate a closure during deletion. - if allow_deleted_links and current != key and current in self.__deleted: - continue - proof = self.__permissions.get(current) - if proof is None or current in self.__deleted: - return None - value = ( - first if current == key and first is not None else await self.__read_native(current) - ) - if value is None or not self.__in_scope(value): - return None - if self.__fingerprint(value) != proof.fingerprint: - refreshed = ( - await self.__refresh_permission(current) - if self.__refresh_permission is not None - else None - ) - if refreshed is None or self.__fingerprint(value) != refreshed.fingerprint: - self.__deny("node_changed") - self.__permissions[current] = refreshed - proof = refreshed - known = self.__known.get(current) if current not in self.__written else None - if known and (value.content != known.content or value.owner != known.owner): - self.__deny("node_changed") - checked[current] = value - pending.extend(self.__references(value)) - return deepcopy(checked[key]) - - async def get_by_id(self, node_id: str) -> MemoryNode | None: - async with self.__lock: - self.recheck() - result = await self.__load(node_id) - self.recheck() - return result - - async def get_by_ids(self, node_ids: list[str]) -> list[MemoryNode]: - async with self.__lock: - self.recheck() - result = [ - n for key in dict.fromkeys(node_ids) if (n := await self.__load(key)) is not None - ] - self.recheck() - return result - - async def get_embeddings(self, node_ids: list[str]) -> dict[str, list[float]]: - return { - n.node_id: list(n.embedding) - for n in await self.get_by_ids(node_ids) - if n.embedding is not None - } - - def __scope(self, user_id: str | None, agent_id: str | None) -> None: - if user_id not in (None, self.__user_id) or agent_id not in (None, self.__agent_id): - self.__deny("scope") - - async def list_by_user( - self, - user_id: str | None = None, - agent_id: str | None = None, - limit: int = 10000, - status_filter: list[Any] | None = None, - layers: list[Any] | None = None, - user_ids: list[str] | None = None, - agent_ids: list[str] | None = None, - only_latest: bool = False, - with_vectors: bool = True, - allow_raw_layer: bool = False, - ) -> list[MemoryNode]: - async with self.__lock: - self.recheck() - effective_users = user_ids or ([user_id] if user_id else []) - effective_agents = agent_ids or ([agent_id] if agent_id else []) - if effective_users != [self.__user_id] or any( - value != self.__agent_id for value in effective_agents - ): - self.__deny("scope") - rows = await self.__invoke( - "list_by_user", - user_id=self.__user_id, - agent_id=agent_id, - limit=limit, - status_filter=status_filter, - layers=layers, - user_ids=[self.__user_id], - agent_ids=effective_agents or None, - only_latest=only_latest, - with_vectors=with_vectors, - allow_raw_layer=allow_raw_layer, - ) - result = [] - for row in rows: - if type(row) is not MemoryNode: - self.__deny("node_shape") - value = await self.__load(row.node_id) - if ( - value is not None - and (not layers or value.layer in layers or value.layer.value in layers) - and ( - not status_filter - or value.status in status_filter - or value.status.value in status_filter - ) - ): - result.append(value) - self.recheck() - return result[:limit] - - async def search( - self, - query_embedding: list[float], - isolation_key: str = "", - isolation_keys: list[str] | None = None, - user_id: str | None = None, - user_ids: list[str] | None = None, - agent_ids: list[str] | None = None, - layers: list[Any] | None = None, - limit: int = 10, - score_threshold: float = 0.0, - status_filter: list[Any] | None = None, - only_latest: bool = True, - tags_match_any: list[str] | None = None, - created_after: float | None = None, - ) -> list[dict[str, Any]]: - async with self.__lock: - self.recheck() - self.__scope(user_id, None) - key = MemoryNode.build_isolation_key(self.__user_id, self.__agent_id, self.__session_id) - if ( - any(uid != self.__user_id for uid in user_ids or []) - or any(aid != self.__agent_id for aid in agent_ids or []) - or isolation_key not in ("", key) - or any(ik != key for ik in isolation_keys or []) - ): - self.__deny("scope") - hits = await self.__invoke( - "search", - query_embedding=deepcopy(query_embedding), - isolation_key=isolation_key, - isolation_keys=isolation_keys, - user_id=self.__user_id, - user_ids=[self.__user_id], - agent_ids=[self.__agent_id], - layers=layers, - limit=limit, - score_threshold=score_threshold, - status_filter=status_filter, - only_latest=only_latest, - tags_match_any=tags_match_any, - created_after=created_after, - ) - result = [] - for hit in hits: - value = await self.__load(hit["node_id"]) - if value is None: - continue - score = float(hit["score"]) - if not math.isfinite(score): - self.__deny("node_shape") - # Do not forward arbitrary backend hit metadata. Rehydrate the - # full fingerprinted node; native search often omits embedding. - result.append({"node_id": value.node_id, "node": value, "score": score}) - self.recheck() - return result[:limit] - - async def __target( - self, - key: str, - first: MemoryNode | None = None, - *, - allow_deleted_links: bool = False, - ) -> MemoryNode: - value = await self.__load(key, first, allow_deleted_links=allow_deleted_links) - if value is None: - self.__deny("target_denied") - return value - - async def __check_links(self, value: MemoryNode) -> None: - for ref in self.__references(value): - if await self.__load(ref) is None: - self.__deny("link_denied") - - async def __plan_upsert(self, value: MemoryNode) -> MemoryNode: - value = deepcopy(value) - if type(value) is not MemoryNode or not value.node_id or not self.__in_scope(value): - self.__deny("scope") - existing = await self.__read_native(value.node_id) - if existing is not None: - await self.__target(value.node_id, existing) - elif ( - not self.__new_node_in_scope(value) - or value.node_id in self.__permissions - or value.node_id in self.__known - or value.node_id in self.__deleted - ): - self.__deny("scope") - await self.__check_links(value) - self.__write_view(value) - return value - - async def __capture( - self, - expected: MemoryNode, - *, - allow_native_modified_time: bool = False, - ) -> None: - actual = await self.__read_native(expected.node_id) - if actual is None: - self.__deny("write_mismatch") - actual_view, expected_view = self.__write_view(actual), self.__write_view(expected) - if allow_native_modified_time: - if actual.gmt_modified < expected.gmt_modified: - self.__deny("write_mismatch") - actual_view["gmt_modified"] = expected_view["gmt_modified"] - actual_vector = actual_view.pop("embedding") - expected_vector = expected_view.pop("embedding") - if actual_view != expected_view or not _same_written_vector(actual_vector, expected_vector): - self.__deny("write_mismatch") - proof = HyNodePermission(self.__fingerprint(actual), self.__evidence_ids) - self.__permissions[actual.node_id] = proof - self.__written[actual.node_id] = proof - status = getattr(actual.status, "value", actual.status) - if status == MemoryStatus.SHADOW.value: - self.__shadowed.add(actual.node_id) - else: - self.__shadowed.discard(actual.node_id) - if status == MemoryStatus.SUPERSEDED.value: - by = actual.superseded_by - self.__superseded[actual.node_id] = tuple(by) if isinstance(by, list) else () - else: - self.__superseded.pop(actual.node_id, None) - olds = actual.supersedes - if isinstance(olds, list) and olds: - self.__heads[actual.node_id] = tuple(olds) - else: - self.__heads.pop(actual.node_id, None) - - async def upsert(self, node: MemoryNode) -> str: - async with _native_write_lock(), self.__lock: - self.recheck() - expected = await self.__plan_upsert(node) - result = await self.__invoke("upsert", deepcopy(expected)) - if result != expected.node_id: - self.__deny("write_mismatch") - await self.__capture(expected) - return str(result) - - async def upsert_batch(self, nodes: list[MemoryNode]) -> list[str]: - async with _native_write_lock(), self.__lock: - self.recheck() - if len({n.node_id for n in nodes}) != len(nodes): - self.__deny("node_shape") - # No forward links to uncommitted batch members. Preflight every - # existing target before the backend can make a partial write. - planned = [await self.__plan_upsert(n) for n in nodes] - ids = [n.node_id for n in planned] - result = await self.__invoke("upsert_batch", deepcopy(planned)) - if result != ids: - self.__deny("write_mismatch") - for expected in planned: - await self.__capture(expected) - return ids - - async def update_payload(self, node_id: str, updates: dict[str, Any]) -> bool: - async with _native_write_lock(), self.__lock: - self.recheck() - current = await self.__target(node_id) - updates = deepcopy(updates) - dedup_audit = False - if set(updates) & _DEDUP_AUDIT_FIELDS: - if ( - not set(updates) <= (_UPDATE_FIELDS | _DEDUP_AUDIT_FIELDS) - or updates.get("status") != MemoryStatus.SHADOW.value - or updates.get("is_latest") is not False - or not isinstance(updates.get("shadow_reason"), str) - or not updates["shadow_reason"].startswith("dedup:") - or not isinstance(updates.get("dedup_kept_id"), str) - or not updates["dedup_kept_id"] - ): - self.__deny("update_fields") - await self.__target(updates["dedup_kept_id"]) - custom = deepcopy(current.custom) - custom["hy_dedup"] = { - "reason": updates.pop("shadow_reason"), - "kept_id": updates.pop("dedup_kept_id"), - } - updates["custom"] = custom - dedup_audit = True - allowed = _UPDATE_FIELDS | ({"custom"} if dedup_audit else set()) - if self.__cross_evidence_scope and set(updates) == {"custom"}: - custom = updates.get("custom") - if not isinstance(custom, dict): - self.__deny("update_fields") - current_custom = current.custom if isinstance(current.custom, dict) else {} - previous = current_custom.get(_DIGEST_COUNTER_FIELD, 0) - proposed = custom.get(_DIGEST_COUNTER_FIELD) - if ( - not isinstance(previous, int) - or isinstance(previous, bool) - or not isinstance(proposed, int) - or isinstance(proposed, bool) - or proposed != previous + 1 - or set(custom) != set(current_custom) | {_DIGEST_COUNTER_FIELD} - or any(custom[key] != value for key, value in current_custom.items()) - ): - self.__deny("update_fields") - allowed |= {"custom"} - # `custom` is an allowed field name (SDK 2.0.2 sends it on a - # reconcile UPDATE), but the System 2 evidence counter it can hold - # stays digest-only: only the digest scope may advance it, and only - # by one per write. Letting the name through must not let an - # ordinary write move that counter. - if not self.__cross_evidence_scope and _DIGEST_COUNTER_FIELD in ( - updates.get("custom") or {} - ): - self.__deny("update_fields") - if not set(updates) <= allowed: - self.__deny("update_fields") - try: - if "layer" in updates: - MemoryLayer(updates["layer"]) - if "status" in updates: - MemoryStatus(updates["status"]) - if "tag_ners" in updates and ( - not isinstance(updates["tag_ners"], list) - or any(not isinstance(value, str) for value in updates["tag_ners"]) - ): - self.__deny("node_shape") - expected = MemoryNode.from_dict({**current.to_dict(), **updates}) - self.__fingerprint(expected) - except (TypeError, ValueError): - self.__deny("node_shape") - if not self.__in_scope(expected): - self.__deny("scope") - await self.__check_links(expected) - self.__write_view(expected) - if await self.__invoke("update_payload", node_id, updates) is not True: - self.__deny("write_failed") - await self.__capture(expected, allow_native_modified_time=True) - return True - - async def update_embedding(self, node_id: str, embedding: list[float]) -> bool: - async with _native_write_lock(), self.__lock: - self.recheck() - expected = await self.__target(node_id) - expected.embedding = deepcopy(embedding) - self.__write_view(expected) - if await self.__invoke("update_embedding", node_id, deepcopy(embedding)) is not True: - self.__deny("write_failed") - await self.__capture(expected, allow_native_modified_time=True) - return True - - async def delete(self, node_id: str) -> bool: - async with _native_write_lock(), self.__lock: - self.recheck() - await self.__target(node_id, allow_deleted_links=True) - if await self.__invoke("delete", node_id) is not True: - self.__deny("write_failed") - if await self.__read_native(node_id) is not None: - self.__deny("write_mismatch") - self.__deleted.add(node_id) - self.__permissions.pop(node_id, None) - self.__written.pop(node_id, None) - self.__superseded.pop(node_id, None) - self.__heads.pop(node_id, None) - return True diff --git a/memhub/apps/core/src/one_memory/hy_permission_transport.py b/memhub/apps/core/src/one_memory/hy_permission_transport.py deleted file mode 100644 index bbd145d..0000000 --- a/memhub/apps/core/src/one_memory/hy_permission_transport.py +++ /dev/null @@ -1,1117 +0,0 @@ -"""Lease-bound leaf transport for the pinned HY Memory SDK, not context isolation. - -The factory must hold an exclusive client lock on HY's own loop throughout this -async context, bind EVERY captured native provider/service before use, and keep -unsupported lazy-provider paths disabled. Store provenance, prompts, extraction, -reconcile and publication remain the caller's responsibility. No global SDK or -logging state is changed here. Saved pre-installation native methods are outside -this boundary; never expose an unbound client to permission-scoped work. - -An embedding queue must be empty/idle at installation. Its cache and queue are -lease-local while bound. Exit cancels and awaits outstanding bound calls/timers, -clears pending futures, closes HTTP resources, then restores instance attributes. -Local encoder threads cannot be cancelled; exit waits for their completion. -The caller must stop creating child work before exit. Old bound callables remain -denied after exit; restored native methods require a NEW context before reuse. - -Recheck happens at the physical transport boundary with no intervening await -before delegation. It cannot recall an in-flight send or make revocation atomic -with remote receipt. Responses and normal context exit are also rechecked. -After the last SDK/profile read, main may ``await transport.seal()`` before its -own governance projection. Seal requires idle children/queues, fully rechecks -the lease, and permanently closes request admission. Sealed exit does NOT -recheck context versions that main may just have updated; main must independently -recheck its frozen permission receipt before committing/publishing governance. -""" - -from __future__ import annotations - -import asyncio -import inspect -import json -import math -import re -from collections import OrderedDict -from collections.abc import AsyncIterator, Awaitable, Callable -from contextlib import asynccontextmanager, nullcontext -from contextvars import ContextVar -from dataclasses import dataclass, field -from functools import wraps -from http.cookiejar import CookieJar, DefaultCookiePolicy -from importlib import import_module, metadata -from time import monotonic -from types import MethodType, TracebackType -from typing import Any, Literal, Never, Protocol, Self, cast -from urllib.parse import urlsplit, urlunsplit - -import httpx -from openai import APIConnectionError, APITimeoutError, AsyncOpenAI, Omit - -from one_memory.diagnostic_log import diagnostic_event, diagnostic_exception, elapsed_ms -from one_memory.hy_runtime_config import HY_MEMORY_DISTRIBUTION, HY_MEMORY_VERSION -from one_memory.hy_step_timing import HyStepTimer -from one_memory.model_profiles import ( - ModelProfileSnapshot, - ProviderKind, - attach_disable_thinking, - validate_model_base_url, -) -from one_memory.model_request_budget import ModelRequestBudgetError - -_MISSING = object() -_OWNER = "_one_memory_permission_transport" -_Role = Literal["generation", "embedding"] -_FailureKind = Literal["timeout", "connection", "transport", "invalid_response", "unknown"] -_HEADERS = frozenset( - { - "accept", - "accept-encoding", - "connection", - "content-length", - "content-type", - "user-agent", - "host", - "authorization", - "x-stainless-async", - "x-stainless-arch", - "x-stainless-lang", - "x-stainless-os", - "x-stainless-package-version", - "x-stainless-runtime", - "x-stainless-runtime-version", - "x-stainless-retry-count", - "x-stainless-read-timeout", - "x-stainless-raw-response", - } -) - - -@dataclass(frozen=True) -class _Recipient: - provider_kind: ProviderKind - endpoint: str | None - model: str - dimensions: int | None = None - - -def _canonical_endpoint(provider: ProviderKind, value: str | None) -> str: - if not value or any(character.isspace() or ord(character) < 32 for character in value): - raise ValueError("missing_endpoint") - validate_model_base_url(value) - parsed = urlsplit(value) - if parsed.query or parsed.fragment or "?" in value or "#" in value: - raise ValueError("ambiguous_endpoint") - hostname = (parsed.hostname or "").encode("idna").decode().lower() - if not hostname or "%" in hostname or "\\" in value: - raise ValueError("ambiguous_endpoint") - host = f"[{hostname}]" if ":" in hostname else hostname - port = parsed.port - if port is not None and port != (443 if parsed.scheme == "https" else 80): - host = f"{host}:{port}" - path = parsed.path.rstrip("/") - if provider is ProviderKind.OLLAMA and not path.endswith("/v1"): - path += "/v1" - return urlunsplit((parsed.scheme.lower(), host, path, "", "")) - - -def _recipients(profile: ModelProfileSnapshot) -> tuple[_Recipient, _Recipient]: - if profile.provider_kind not in {ProviderKind.OPENAI_COMPATIBLE, ProviderKind.OLLAMA}: - raise HyPermissionTransportError("unsupported_backend") - embedding_provider = profile.embedding_provider_kind or profile.provider_kind - if not profile.embedding_model or embedding_provider not in { - ProviderKind.OPENAI_COMPATIBLE, - ProviderKind.OLLAMA, - ProviderKind.LOCAL_ONNX, - }: - raise HyPermissionTransportError("unsupported_backend") - try: - generation = _Recipient( - profile.provider_kind, - _canonical_endpoint(profile.provider_kind, profile.base_url), - profile.model, - ) - embedding = _Recipient( - embedding_provider, - ( - None - if embedding_provider is ProviderKind.LOCAL_ONNX - else _canonical_endpoint( - embedding_provider, - profile.embedding_base_url or profile.base_url, - ) - ), - profile.embedding_model, - profile.embedding_dimensions, - ) - except (UnicodeError, ValueError): - raise HyPermissionTransportError("endpoint_invalid") from None - return generation, embedding - - -class PermissionLease(Protocol): - @property - def user_id(self) -> str: ... - - @property - def profile(self) -> ModelProfileSnapshot: ... - - def recheck(self) -> None: ... - - -class HyPermissionTransportError(RuntimeError): - """Stable code only: never retains a request, credential or upstream error.""" - - def __init__(self, reason: str) -> None: - self.code = f"model_content_transport_{reason}" - super().__init__(self.code) - - -class _RequestFailure(HyPermissionTransportError): - def __init__( - self, - *, - retryable: bool = False, - status_code: int | None = None, - failure_kind: _FailureKind = "unknown", - ) -> None: - super().__init__("request_failed") - self.retryable = retryable - self.status_code = status_code - self.failure_kind = failure_kind - - -def _failure_kind(error: Exception) -> _FailureKind: - if isinstance(error, (APITimeoutError, httpx.TimeoutException, TimeoutError)): - return "timeout" - if isinstance(error, (APIConnectionError, httpx.ConnectError, ConnectionError)): - return "connection" - if isinstance(error, httpx.TransportError): - return "transport" - return "unknown" - - -@dataclass(frozen=True) -class _Spec: - role: _Role - endpoint: str - model: str - dimensions: int | None - api_key: str = field(repr=False) - retries: int - delay: float - timeout: float - extra_body: str - - -@dataclass -class _Remote: - instance: Any = field(repr=False) - spec: _Spec - client: AsyncOpenAI = field(repr=False) - - -@dataclass(frozen=True) -class _Envelope: - remote: _Remote = field(repr=False) - body: str = field(repr=False) - - -class _PhysicalTransport(httpx.AsyncBaseTransport): - def __init__(self, owner: HyPermissionTransport, inner: httpx.AsyncBaseTransport) -> None: - self.owner = owner - self.inner = inner - - async def handle_async_request(self, request: httpx.Request) -> httpx.Response: - # After all httpx request hooks, immediately before the physical send. - self.owner.assert_allowed() - if not isinstance(request.stream, httpx.AsyncByteStream): - self.owner._deny("unsupported_stream") - content = b"".join([part async for part in request.stream]) - request.stream = httpx.ByteStream(content) - self.owner._check_request(request, content=content) - self.owner._physical_requests += 1 - try: - try: - response = await self.inner.handle_async_request(request) - except ModelRequestBudgetError: - self.owner._deny("budget_exceeded") - if 300 <= response.status_code < 400: - await response.aclose() - self.owner._deny("redirect") - return response - finally: - self.owner._physical_requests -= 1 - - async def aclose(self) -> None: - await self.inner.aclose() - - -class HyPermissionTransport: - """Single-use context; bind methods return the same native instance. - - ``http_transport`` is an optional trusted test transport, owned and closed by - this context. Production uses a direct httpx transport with no environment - proxy/auth configuration, HTTP redirects or connection retries. - """ - - def __init__( - self, - lease: PermissionLease, - *, - http_transport: httpx.AsyncBaseTransport | None = None, - step_timer: HyStepTimer | None = None, - ) -> None: - self.lease = lease - self._step_timer = step_timer - self._profile = lease.profile.model_copy(deep=True) - self._transport = http_transport - self._active = False - self._ended = False - self._requests_sealed = False - self._physical_requests = 0 - self._denial: str | None = None - self._patches: list[tuple[Any, str, Any, Any]] = [] - self._tasks: dict[asyncio.Task[Any], int] = {} - self._local_work: set[asyncio.Task[Any]] = set() - self._services: list[Any] = [] - self._clients: list[AsyncOpenAI] = [] - self._http_client: httpx.AsyncClient - self._envelope: ContextVar[_Envelope | None] = ContextVar("hy_leaf_envelope", default=None) - - @property - def denied_code(self) -> str | None: - return f"model_content_transport_{self._denial}" if self._denial else None - - @property - def requests_sealed(self) -> bool: - return self._requests_sealed - - async def seal(self) -> None: - """Fully check an idle lease, then permanently refuse SDK/request admission. - - Call on HY's loop after ALL SDK work, including profile readback. Active - children/queues cause ``active_requests`` without sealing or latching a - permission denial: await those children and try again. No work is dropped - or implicitly approved. There is no await between the idle check, full - lease check and state change. A successful seal is idempotent. - - Main owns the subsequent frozen-permission check and governance commit. - This method deliberately does not call an optional recheck_permission. - """ - if self._ended or not self._active: - raise HyPermissionTransportError("lease_ended") - if self._denial is not None: - raise HyPermissionTransportError(self._denial) - if self._requests_sealed: - return - if ( - self._tasks - or self._physical_requests - or self._local_work - or any(_embedding_queue_active(service) for service in self._services) - ): - raise HyPermissionTransportError("active_requests") - self.assert_allowed() - self._requests_sealed = True - - async def __aenter__(self) -> Self: - if self._active or self._ended: - raise HyPermissionTransportError("already_used") - self._active = True - try: - self._verify_sdk() - self.assert_allowed() - inner = self._transport or httpx.AsyncHTTPTransport(retries=0, trust_env=False) - self._http_client = httpx.AsyncClient( - transport=_PhysicalTransport(self, inner), - trust_env=False, - follow_redirects=False, - # A gateway may set cookies that alter the next call in the - # same task. Model requests never rely on cookies, so never - # store any. - # Pass the jar itself: httpx copies a Cookies object into a - # fresh jar with the default policy, which would store again. - cookies=CookieJar(policy=DefaultCookiePolicy(allowed_domains=[])), - event_hooks={"request": [self._request_hook]}, - ) - except BaseException: - self._active = False - self._ended = True - if self._transport is not None: - await self._transport.aclose() - raise - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - traceback: TracebackType | None, - ) -> None: - failure: HyPermissionTransportError | None = None - if exc_type is None and not self._requests_sealed: - try: - self.assert_allowed() - except HyPermissionTransportError as error: - failure = error - self._active = False - self._ended = True - cleanup = asyncio.create_task(self._cleanup(asyncio.current_task())) - try: - await asyncio.shield(cleanup) - except asyncio.CancelledError: - await cleanup - raise - if exc_type is None: - if failure is not None: - raise failure - if self._denial is not None: - raise HyPermissionTransportError(self._denial) - - def assert_allowed(self) -> None: - """Recheck publication/child boundaries; a denial is terminal for this context.""" - self._assert_active() - try: - self.lease.recheck() - except Exception as error: - reason = str(error).removeprefix("model_content_") - self._deny( - f"lease_denied_{reason}" - if re.fullmatch(r"[a-z_]{1,60}", reason) - else "lease_denied" - ) - if self.lease.profile != self._profile: - self._deny("lease_changed") - - def _assert_active(self) -> None: - if self._ended or not self._active: - raise HyPermissionTransportError("lease_ended") - if self._denial is not None: - raise HyPermissionTransportError(self._denial) - if self._requests_sealed: - raise HyPermissionTransportError("requests_sealed") - - def _deny(self, reason: str) -> Never: - if self._denial is None: - self._denial = reason - raise HyPermissionTransportError(self._denial) from None - - @staticmethod - def _verify_sdk() -> None: - if metadata.version(HY_MEMORY_DISTRIBUTION) != HY_MEMORY_VERSION: - raise HyPermissionTransportError("sdk_version") - llm = import_module("hy_memory.agent.llm_provider").LLMProvider - embed = import_module("hy_memory.core.embed_service").EmbedService - prompt = ( - "self", - "prompt", - "max_tokens", - "temperature", - "stop", - "tools", - "tool_choice", - "kwargs", - ) - messages = ( - "self", - "messages", - "max_tokens", - "temperature", - "tools", - "tool_choice", - ) - chat = ( - "self", - "messages", - "max_tokens", - "temperature", - "stop", - "tools", - "tool_choice", - ) - for cls, name, expected in ( - (llm, "_call_llm", prompt), - (llm, "complete", prompt), - (llm, "complete_messages", messages), - (llm, "_chat_completions", chat), - (embed, "_embed_openai", ("self", "texts")), - ): - method = getattr(cls, name, None) - if ( - not inspect.iscoroutinefunction(method) - or tuple(inspect.signature(method).parameters) != expected - ): - raise HyPermissionTransportError("sdk_shape") - - @asynccontextmanager - async def _operation(self) -> AsyncIterator[None]: - self._assert_active() - task = asyncio.current_task() - assert task is not None - self._tasks[task] = self._tasks.get(task, 0) + 1 - try: - yield - self.assert_allowed() - finally: - count = self._tasks[task] - 1 - if count: - self._tasks[task] = count - else: - del self._tasks[task] - - def _patch(self, instance: Any, name: str, value: Any) -> None: - previous = vars(instance).get(name, _MISSING) - self._patches.append((instance, name, previous, value)) - setattr(instance, name, value) - - def _bind_method( - self, - instance: Any, - name: str, - call: Callable[..., Awaitable[Any]], - check: Callable[[], None], - *, - drain_local: bool = False, - step: str | None = None, - ) -> None: - original = getattr(instance, name, call) - timer = self._step_timer - signature = inspect.signature(original) - - @wraps(getattr(original, "__func__", original)) - async def bound(_instance: Any, *args: Any, **kwargs: Any) -> Any: - async with self._operation(): - check() - try: - signature.bind(*args, **kwargs) - with ( - timer.step(step, items=_step_items(args)) - if timer is not None and step is not None - else nullcontext() - ): - if drain_local: - return await self._drain_local(call(*args, **kwargs)) - return await call(*args, **kwargs) - except HyPermissionTransportError: - raise - except TypeError: - self._deny("unsupported_operation") - except Exception as error: - # Only these two replacement methods create this native - # error, with sanitized attempts. Other native/local errors - # must not propagate provider details to HY's queue logger. - retry_type = import_module("hy_memory.agent.llm_provider").LLMRetryError - if name in {"complete", "complete_messages"} and type(error) is retry_type: - raise - raise _RequestFailure() from None - - self._patch(instance, name, MethodType(bound, instance)) - - async def _drain_local(self, call: Awaitable[Any]) -> Any: - async def run() -> Any: - return await call - - task = asyncio.create_task(run()) - self._local_work.add(task) - try: - return await asyncio.shield(task) - except asyncio.CancelledError: - await asyncio.gather(task, return_exceptions=True) - raise - finally: - self._local_work.discard(task) - - def _claim(self, instance: Any, expected_type: type[Any], names: tuple[str, ...]) -> bool: - self._assert_active() - if type(instance) is not expected_type: - self._deny("unsupported_instance") - owner = vars(instance).get(_OWNER) - if owner is self: - return False - if owner is not None: - self._deny("instance_already_bound") - for name in names: - if getattr(getattr(instance, name, None), "__func__", None) is not getattr( - expected_type, name, None - ): - self._deny("unsupported_instance_method") - return True - - def _spec(self, instance: Any, role: _Role) -> _Spec: - generation, embedding = _recipients(self._profile) - recipient = generation if role == "generation" else embedding - cfg = instance._llm_config if role == "generation" else instance._embed_config - if recipient.provider_kind not in {ProviderKind.OPENAI_COMPATIBLE, ProviderKind.OLLAMA}: - self._deny("unsupported_backend") - if role == "generation": - if ( - getattr(cfg.backend, "value", None) != "openai" - or instance.config.llm.provider != "openai" - ): - self._deny("unsupported_backend") - elif cfg.provider != "openai": - self._deny("unsupported_backend") - try: - endpoint = _canonical_endpoint(ProviderKind.OPENAI_COMPATIBLE, cfg.base_url) - path = httpx.URL(endpoint).path - if ( - "%" in endpoint - or any(part in {".", ".."} for part in cfg.base_url.split("/")) - or "//" in path - ): - self._deny("endpoint_invalid") - except (ValueError, RuntimeError): - self._deny("endpoint_invalid") - if endpoint != recipient.endpoint: - self._deny("endpoint_mismatch") - if cfg.model != recipient.model: - self._deny("model_mismatch") - dimensions = (cfg.embedding_dims or None) if role == "embedding" else None - if dimensions != recipient.dimensions or ( - dimensions is not None and type(dimensions) is not int - ): - self._deny("dimensions_mismatch") - extras = attach_disable_thinking(self._profile, {}) if role == "generation" else {} - if cfg.extra_headers or (cfg.extra_body or {}) != extras: - self._deny("unexpected_overrides") - if ( - not isinstance(cfg.api_key, str) - or not cfg.api_key - or any(c.isspace() for c in cfg.api_key) - ): - self._deny("credentials_invalid") - if type(cfg.max_retries) is not int or cfg.max_retries < (1 if role == "generation" else 0): - self._deny("retry_config_invalid") - if cfg.retry_delay is None or cfg.retry_delay < 0: - self._deny("retry_config_invalid") - return _Spec( - role, - endpoint, - cfg.model, - dimensions, - cfg.api_key, - cfg.max_retries, - float(cfg.retry_delay), - float(cfg.timeout or 3), - _json(extras), - ) - - def _remote(self, instance: Any, role: _Role) -> _Remote: - spec = self._spec(instance, role) - client = AsyncOpenAI( - api_key=spec.api_key, - base_url=spec.endpoint, - max_retries=0, - timeout=spec.timeout, - organization="", - project="", - http_client=self._http_client, - default_headers=cast(Any, {"OpenAI-Organization": Omit(), "OpenAI-Project": Omit()}), - ) - self._clients.append(client) - if getattr(client, "_provider_runtime", None) is not None: - self._deny("unsupported_backend") - return _Remote(instance, spec, client) - - def _check_remote(self, remote: _Remote) -> None: - self._assert_active() - if self._spec(remote.instance, remote.spec.role) != remote.spec: - self._deny("configuration_changed") - - async def _request_hook(self, request: httpx.Request) -> None: - self._check_request(request) - - def _check_request(self, request: httpx.Request, *, content: bytes | None = None) -> None: - self._assert_active() - envelope = self._envelope.get() - if envelope is None: - self._deny("unsupported_request") - remote = envelope.remote - self._check_remote(remote) - suffix = "/chat/completions" if remote.spec.role == "generation" else "/embeddings" - url = httpx.URL(remote.spec.endpoint + suffix) - if request.method != "POST": - self._deny("method_mismatch") - if request.url != url or request.url.raw_path != url.raw_path: - self._deny("endpoint_mismatch") - if ( - set(request.headers) - _HEADERS - or request.headers.get_list("authorization") != [f"Bearer {remote.spec.api_key}"] - or request.headers.get_list("host") != [url.netloc.decode("ascii")] - or request.headers.get("content-type") != "application/json" - ): - self._deny("headers_mismatch") - try: - content = request.content if content is None else content - if request.headers.get_list("content-length") != [str(len(content))]: - self._deny("headers_mismatch") - body = json.loads(content) - matches = _json(body) == envelope.body - except (ValueError, TypeError, httpx.RequestNotRead): - self._deny("body_invalid") - if not matches or body.get("model") != remote.spec.model: - self._deny("body_mismatch") - if remote.spec.role == "embedding" and body.get("dimensions") != remote.spec.dimensions: - self._deny("dimensions_mismatch") - - async def _exchange(self, remote: _Remote, payload: dict[str, Any]) -> tuple[Any, int]: - started = monotonic() - phase = "embedding" if remote.spec.role == "embedding" else "generation" - self._check_remote(remote) - diagnostic_event( - level="info", - component="hy.transport", - event="hy.transport_call.started", - outcome="started", - details={"phase": phase, "call_count": 1}, - ) - # Freeze the complete native payload, not just model/destination fields. - body = _json(payload) - token = self._envelope.set(_Envelope(remote, body)) - try: - if remote.spec.role == "generation": - extras = json.loads(remote.spec.extra_body) - kwargs = {key: value for key, value in payload.items() if key not in extras} - result = await remote.client.chat.completions.create( - **kwargs, extra_body=extras or None - ) - status = 200 - else: - raw = await remote.client.embeddings.with_raw_response.create(**payload) - result, status = raw.parse(), raw.status_code - self.assert_allowed() - diagnostic_event( - level="info", - component="hy.transport", - event="hy.transport_call.completed", - outcome="succeeded", - duration_ms=elapsed_ms(started), - details={"phase": phase, "call_count": 1, "status": str(status)}, - ) - return result, status - except Exception as error: - if self._denial is not None: - raise HyPermissionTransportError(self._denial) from None - status_code = getattr(error, "status_code", None) - failure_kind = _failure_kind(error) - diagnostic_event( - level="error", - component="hy.transport", - event="hy.transport_call.failed", - outcome="failed", - duration_ms=elapsed_ms(started), - error_code=f"hy_transport_{failure_kind}", - **diagnostic_exception(error), - details={ - "phase": phase, - "call_count": 1, - "status": str(status_code) if type(status_code) is int else "unknown", - }, - ) - raise _RequestFailure( - retryable=isinstance( - error, (APIConnectionError, httpx.TransportError, TimeoutError, ConnectionError) - ), - status_code=status_code if type(status_code) is int else None, - failure_kind=failure_kind, - ) from None - finally: - self._envelope.reset(token) - - def bind_provider(self, provider: Any) -> Any: - """Bind a native LLMProvider, including _call_llm and both complete paths.""" - module = import_module("hy_memory.agent.llm_provider") - names = ("complete", "complete_messages", "_call_llm", "_chat_completions") - if not self._claim(provider, module.LLMProvider, names): - return provider - remote = self._remote(provider, "generation") - self._patch(provider, _OWNER, self) - - def check() -> None: - self._check_remote(remote) - - async def leaf_messages( - messages: list[dict[str, Any]], - max_tokens: int, - temperature: float | None, - tools: Any = None, - tool_choice: Any = None, - *, - stop: Any = None, - ) -> Any: - if not any(message.get("role") == "system" for message in messages): - messages = [{"role": "system", "content": ""}, *messages] - payload: dict[str, Any] = { - "model": remote.spec.model, - "messages": messages, - "max_tokens": max_tokens, - } - if temperature is not None: - payload["temperature"] = temperature - if stop: - payload["stop"] = stop - if tools: - payload["tools"] = tools - if tool_choice is not None: - payload["tool_choice"] = tool_choice - payload.update(json.loads(remote.spec.extra_body)) - response, _ = await self._exchange(remote, payload) - try: - choice = response.choices[0] - message, usage = choice.message, response.usage - calls = [ - { - "id": tc.id or "", - "type": tc.type or "function", - "function": { - "name": getattr(tc.function, "name", ""), - "arguments": getattr(tc.function, "arguments", ""), - }, - } - for tc in (message.tool_calls or []) - ] - return module.LLMResponse( - content=message.content or "", - model=remote.spec.model, - tokens_used=_token_count(usage.total_tokens) if usage else 0, - prompt_tokens=_token_count(usage.prompt_tokens) if usage else 0, - completion_tokens=_token_count(usage.completion_tokens) if usage else 0, - finish_reason=choice.finish_reason, - tool_calls=calls or None, - ) - except Exception: - raise _RequestFailure(failure_kind="invalid_response") from None - - async def leaf( - prompt: str, - max_tokens: int, - temperature: float | None, - stop: Any = None, - tools: Any = None, - tool_choice: Any = None, - ) -> Any: - return await leaf_messages( - [{"role": "user", "content": prompt}], - max_tokens, - temperature, - tools, - tool_choice, - stop=stop, - ) - - async def dispatch( - prompt: str, - max_tokens: int, - temperature: float | None, - stop: Any = None, - tools: Any = None, - tool_choice: Any = None, - **kwargs: Any, - ) -> Any: - if kwargs: - self._deny("unexpected_overrides") - return await leaf(prompt, max_tokens, temperature, stop, tools, tool_choice) - - async def retry(call: Callable[[], Awaitable[Any]]) -> Any: - # HY's total-attempt budget/counters/backoff, without native prompt - # logging or raw upstream exceptions in its attempt receipts. - attempts = [] - for attempt in range(remote.spec.retries): - check() - try: - result = await call() - self.assert_allowed() - provider._total_calls += 1 - provider._total_tokens += result.tokens_used - return result - except HyPermissionTransportError as error: - provider._errors += 1 - if self._denial is not None or not isinstance(error, _RequestFailure): - raise - record: dict[str, Any] = { - "attempt": attempt + 1, - "error_type": "TransportError", - "error": error.code, - "failure_kind": error.failure_kind, - } - if error.status_code is not None: - record["status_code"] = error.status_code - attempts.append(record) - if attempt + 1 < remote.spec.retries: - await asyncio.sleep(remote.spec.delay * (attempt + 1)) - raise module.LLMRetryError("hy_memory_model_request_failed", attempts) from None - - async def complete( - prompt: str, - max_tokens: int = 500, - temperature: float | None = None, - stop: Any = None, - tools: Any = None, - tool_choice: Any = None, - **kwargs: Any, - ) -> Any: - if kwargs: - self._deny("unexpected_overrides") - return await retry( - lambda: provider._call_llm( - prompt, max_tokens, temperature, stop, tools, tool_choice - ) - ) - - async def complete_messages( - messages: list[dict[str, Any]], - max_tokens: int = 500, - temperature: float | None = None, - tools: Any = None, - tool_choice: Any = None, - ) -> Any: - return await retry( - lambda: leaf_messages(messages, max_tokens, temperature, tools, tool_choice) - ) - - for name, call in ( - ("complete", complete), - ("complete_messages", complete_messages), - ("_call_llm", dispatch), - ("_chat_completions", leaf_messages), - ): - self._bind_method(provider, name, call, check, step="model") - return provider - - async def _unsupported_backend(self, *args: Any, **kwargs: Any) -> Any: - self._deny("unsupported_backend") - - def bind_embed_service(self, service: Any) -> Any: - """Bind native remote EmbedService; local ONNX uses the separate method.""" - cls = import_module("hy_memory.core.embed_service").EmbedService - names = ( - "embed", - "embed_batch", - "embed_queued", - "embed_queued_with_meta", - "embed_with_meta", - "embed_with_cache_info", - "_embed_texts", - "_embed_texts_with_meta", - "_embed_openai", - "_delayed_flush", - "_send_batch", - ) - if not self._claim(service, cls, names): - return service - if _embedding_queue_active(service): - self._deny("foreign_embedding_queue") - remote = self._remote(service, "embedding") - self._patch(service, _OWNER, self) - isolated_state: tuple[tuple[str, Any], ...] = ( - ("_cache", OrderedDict()), - ("_last_embed_meta", {}), - ("_queue_locks", {}), - ("_queue_pending", {}), - ("_queue_flush_tasks", {}), - ("_client_cache", {}), - ) - for name, value in isolated_state: - self._patch(service, name, value) - self._services.append(service) - - def check() -> None: - self._check_remote(remote) - - async def leaf(texts: list[str]) -> tuple[list[list[float]], dict[str, Any]]: - payload: dict[str, Any] = { - "model": remote.spec.model, - "input": texts, - "encoding_format": "float", - } - if remote.spec.dimensions is not None: - payload["dimensions"] = remote.spec.dimensions - for attempt in range(remote.spec.retries + 1): - check() - try: - result, status = await self._exchange(remote, payload) - vectors = [item.embedding for item in result.data] - if len(vectors) != len(texts) or any( - not isinstance(vector, list) - or ( - remote.spec.dimensions is not None - and len(vector) != remote.spec.dimensions - ) - or any( - type(value) not in {int, float} or not math.isfinite(value) - for value in vector - ) - for vector in vectors - ): - raise _RequestFailure() - service._last_embed_meta = { - "http_status": status, - "model": remote.spec.model, - "attempts": attempt + 1, - "usage": { - "prompt_tokens": _token_count(result.usage.prompt_tokens), - "total_tokens": _token_count(result.usage.total_tokens), - } - if result.usage - else None, - } - return vectors, dict(service._last_embed_meta) - except _RequestFailure as error: - service._last_embed_meta = { - "attempts": attempt + 1, - "retryable": error.retryable, - } - if not error.retryable or attempt == remote.spec.retries: - raise - await asyncio.sleep(remote.spec.delay) - raise _RequestFailure() # Unreachable with validated nonnegative retry count. - - for name in names: - call = leaf if name == "_embed_openai" else getattr(service, name) - self._bind_method(service, name, call, check, step="embedding") - return service - - def bind_local_embed_service(self, service: Any) -> Any: - """Keep the product's LocalHyEmbedService local; never substitute a cloud leaf.""" - from one_memory.embedding_assets import ( - BUILTIN_EMBEDDING_DIMENSIONS, - BUILTIN_EMBEDDING_MODEL, - ) - from one_memory.hy_memory_bridge import LocalHyEmbedService - - names = ( - "embed", - "embed_batch", - "embed_batch_with_meta", - "embed_queued", - "embed_queued_with_meta", - "embed_with_meta", - ) - if not self._claim(service, LocalHyEmbedService, names): - return service - _, recipient = _recipients(self._profile) - if ( - recipient.provider_kind != ProviderKind.LOCAL_ONNX - or recipient.endpoint is not None - or recipient.model != BUILTIN_EMBEDDING_MODEL - or recipient.dimensions != BUILTIN_EMBEDDING_DIMENSIONS - ): - self._deny("local_embedding_mismatch") - encoder = service._embedder - - def check() -> None: - self.assert_allowed() - if ( - service._embedder is not encoder - or service.get_embedding_dims() != recipient.dimensions - ): - self._deny("local_embedding_changed") - - self._patch(service, _OWNER, self) - for name in names: - self._bind_method( - service, name, getattr(service, name), check, drain_local=True, step="embedding" - ) - self._bind_method(service, "_embed_openai", self._unsupported_backend, check) - return service - - async def _cleanup(self, exiting: asyncio.Task[Any] | None) -> None: - tasks = { - task - for task in self._tasks - if task is not exiting and task not in self._local_work and not task.done() - } - # Native HY schedules the timer before its bound coroutine can register - # itself. Include done timers too, to retrieve an admission-time failure. - for service in self._services: - tasks.update(task for task in _embedding_flush_tasks(service) if task is not exiting) - for task in tasks: - if not task.done(): - task.cancel() - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - if self._local_work: - await asyncio.gather(*self._local_work, return_exceptions=True) - for service in self._services: - for future in _embedding_pending_futures(service): - if not future.done(): - future.cancel() - service._queue_pending.clear() - service._cache.clear() - try: - clients: list[AsyncOpenAI | httpx.AsyncClient] = [*self._clients, self._http_client] - for client in clients: - try: - if isinstance(client, httpx.AsyncClient): - await client.aclose() - else: - await client.close() - except Exception: - self._denial = self._denial or "cleanup_failed" - finally: - for instance, name, previous, installed in reversed(self._patches): - # Queue/meta attributes are intentionally mutated by native HY. - dynamic = name in { - "_last_embed_meta", - "_queue_locks", - "_queue_pending", - "_queue_flush_tasks", - "_client_cache", - } - if not dynamic and vars(instance).get(name, _MISSING) is not installed: - self._denial = self._denial or "restoration_conflict" - continue - if previous is _MISSING: - delattr(instance, name) - else: - setattr(instance, name, previous) - self._patches.clear() - self._clients.clear() - self._services.clear() - - -def _json(value: Any) -> str: - return json.dumps( - value, ensure_ascii=True, allow_nan=False, sort_keys=True, separators=(",", ":") - ) - - -def _embedding_flush_tasks(service: Any) -> tuple[asyncio.Task[Any], ...]: - tasks = getattr(service, "_queue_flush_tasks", {}) - if not isinstance(tasks, dict): - return () - return tuple(task for task in tasks.values() if isinstance(task, asyncio.Task)) - - -def _embedding_pending_futures(service: Any) -> tuple[asyncio.Future[Any], ...]: - pending = getattr(service, "_queue_pending", {}) - batches = pending.values() if isinstance(pending, dict) else (pending,) - futures: list[asyncio.Future[Any]] = [] - for batch in batches: - if not isinstance(batch, list): - continue - for item in batch: - if isinstance(item, tuple) and len(item) > 1 and isinstance(item[1], asyncio.Future): - futures.append(item[1]) - return tuple(futures) - - -def _embedding_queue_active(service: Any) -> bool: - pending = getattr(service, "_queue_pending", {}) - has_pending = ( - any(bool(batch) for batch in pending.values()) - if isinstance(pending, dict) - else bool(pending) - ) - return has_pending or any(not task.done() for task in _embedding_flush_tasks(service)) - - -def _step_items(args: tuple[Any, ...]) -> int: - first = args[0] if args else None - return len(first) if isinstance(first, list) else 1 - - -def _token_count(value: Any) -> int: - if type(value) is not int or value < 0: - raise _RequestFailure() - return value diff --git a/memhub/apps/core/src/one_memory/hy_response_guard.py b/memhub/apps/core/src/one_memory/hy_response_guard.py index cdb8287..184c5e8 100644 --- a/memhub/apps/core/src/one_memory/hy_response_guard.py +++ b/memhub/apps/core/src/one_memory/hy_response_guard.py @@ -144,6 +144,10 @@ class HyReconcileGuard: self.failures: list[str] = [] self.bindings: HyWriteBindings | None = None self.imported_contents: frozenset[str] | None = None + self.mutation_target_ids: set[str] = set() + + def begin_write(self) -> None: + self.mutation_target_ids.clear() def __getattr__(self, name: str) -> Any: return getattr(self.reconciler, name) @@ -210,6 +214,15 @@ class HyReconcileGuard: for operation in result.ops: raw_name = getattr(operation, "op", "") name = str(getattr(raw_name, "value", raw_name)).upper() + if name in {"UPDATE", "SUPERSEDE", "DELETE", "SHADOW"}: + target = getattr(operation, "memory_id", None) + if isinstance(target, str) and target: + self.mutation_target_ids.add(target) + self.mutation_target_ids.update( + key + for key in getattr(operation, "memory_ids", ()) + if isinstance(key, str) and key + ) if name == "ADD": operation_counts["ADD"] += 1 elif name in {"UPDATE", "MERGE", "NOOP"}: @@ -232,20 +245,6 @@ class HyReconcileGuard: "result_count": len(result.ops), }, ) - timer = self.step_timer - try: - with ( - timer.step("write_verification", items=len(result.ops)) - if timer is not None - else nullcontext() - ): - await self._check_targets(result, kwargs) - except Exception as error: - code = str(error) - if not code.startswith("hy_memory_write_verification_") or len(code) > 100: - code = "hy_memory_write_verification_failed" - self.failures.append(code) - raise ValueError(code) from error return result async def _check_targets(self, result: Any, kwargs: dict[str, Any]) -> None: diff --git a/memhub/apps/core/src/one_memory/hy_store_concurrency.py b/memhub/apps/core/src/one_memory/hy_store_concurrency.py new file mode 100644 index 0000000..d996926 --- /dev/null +++ b/memhub/apps/core/src/one_memory/hy_store_concurrency.py @@ -0,0 +1,41 @@ +"""Process-local coordination for HY's native stores.""" + +from __future__ import annotations + +import asyncio +import weakref +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +_WRITE_LOCKS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = ( + weakref.WeakKeyDictionary() +) + + +def native_store_write_lock() -> asyncio.Lock: + """Return the write lock for the current event loop.""" + loop = asyncio.get_running_loop() + lock = _WRITE_LOCKS.get(loop) + if lock is None: + lock = asyncio.Lock() + _WRITE_LOCKS[loop] = lock + return lock + + +@asynccontextmanager +async def native_store_read_slot() -> AsyncIterator[bool]: + """Admit a native HY read only while no write is active. + + Chroma's native reader may retain the GIL after an asyncio timeout. Reads + therefore share the physical-store lock, but never wait behind a writer: + host recall can immediately fall back to the governance ledger. + """ + lock = native_store_write_lock() + if lock.locked(): + yield False + return + await lock.acquire() + try: + yield True + finally: + lock.release() diff --git a/memhub/apps/core/src/one_memory/memory_notes.py b/memhub/apps/core/src/one_memory/memory_notes.py index ad17f17..fa0fec5 100644 --- a/memhub/apps/core/src/one_memory/memory_notes.py +++ b/memhub/apps/core/src/one_memory/memory_notes.py @@ -10,7 +10,6 @@ from fastapi.params import Depends from pydantic import BaseModel, ConfigDict, Field, field_validator from one_memory.database_access import main_database -from one_memory.hy_memory_bridge import HyMemoryBridgeError from one_memory.imports.models import ( EvidenceInput, EvidenceOrigin, @@ -186,6 +185,10 @@ class MemoryNoteStore: ] if task is None or task.status == MemoryTaskStatus.WAITING_FOR_MODEL: state = "waiting_for_model" + elif task.status == MemoryTaskStatus.WAITING_FOR_PERMISSION: + # Upgrade compatibility: old rows are claimable and will leave + # this state as soon as the direct SDK runner claims them. + state = "waiting_for_permission" elif task.status == MemoryTaskStatus.COMPLETED: state = "ready" if memory_ids else "no_memories" elif task.status == MemoryTaskStatus.RUNNING: @@ -195,22 +198,6 @@ class MemoryNoteStore: else: state = "failed" error = task.last_error_code if task else None - if self.processor.model_content_config is not None and state in { - "waiting_for_model", - "failed", - }: - try: - active = self.processor.profiles.active(self.processor.secrets) - if active is not None: - self.processor._guard_records((record,), active) - except HyMemoryBridgeError as failure: - if str(failure).startswith("model_content_"): - state = "waiting_for_permission" - error = str(failure) - else: - raise - except SecretStoreUnavailableError: - error = "secure_storage_unavailable" return MemoryNoteReceipt( evidence_id=record.evidence_id, text=sanitize_model_input(record.verbatim), @@ -274,11 +261,8 @@ def memory_note_router(dependencies: Sequence[Depends]) -> APIRouter: # unchanged; replaying the same request returns the original # `entry:` job instead of a second HY call. if readmit: - # `/{evidence_id}/process` is the explicit "the condition - # that parked it may have changed" signal: the person just - # granted consent or fixed the model. `submit_evidence` - # alone would return an existing parked row unchanged, so - # without this readmit the note stays parked forever. + # `/{evidence_id}/process` is the explicit retry signal. + # It also revives pre-upgrade permission waiters. await asyncio.to_thread(notes.intake.queue.readmit_parked) submitted = await asyncio.to_thread( notes.intake.submit_evidence, @@ -296,10 +280,6 @@ def memory_note_router(dependencies: Sequence[Depends]) -> APIRouter: last_round=1, priority="live", ) - except HyMemoryBridgeError as error: - if not str(error).startswith("model_content_"): - raise - return await asyncio.to_thread(notes.receipt, record.evidence_id) except SecretStoreUnavailableError: saved = await asyncio.to_thread(notes.receipt, record.evidence_id) return saved.model_copy( diff --git a/memhub/apps/core/src/one_memory/memory_processing.py b/memhub/apps/core/src/one_memory/memory_processing.py index d6f96ca..98c3320 100644 --- a/memhub/apps/core/src/one_memory/memory_processing.py +++ b/memhub/apps/core/src/one_memory/memory_processing.py @@ -13,7 +13,7 @@ from collections.abc import ( Generator, Mapping, ) -from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress +from contextlib import asynccontextmanager, suppress from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from hashlib import sha256 @@ -82,7 +82,6 @@ from one_memory.memory_queue import ( classify, extraction_window_for, ) -from one_memory.memory_server.config_state import ConfigStore from one_memory.memory_tasks import ( TASK_MAX_ATTEMPTS, ActiveMemoryTask, @@ -99,17 +98,10 @@ from one_memory.memory_tasks import ( materialize_evidence_ref, segment_evidence_record, ) -from one_memory.model_processing_permission_guard import ( - ModelProcessingPermissionGuard, - ModelProcessingPermissionLease, - error_is_policy_stop, - permission_error, -) from one_memory.model_profiles import ( ModelProfile, ModelProfileSnapshot, ModelProfileStore, - ProviderKind, ) from one_memory.secrets import SecretStore, SecretStoreUnavailableError from one_memory.semantic_deadline import SemanticMemoryHit, normalize_semantic_hits @@ -258,12 +250,6 @@ class MemoryBridgeProtocol(Protocol): async def close(self) -> None: ... -PermissionBridgeFactory = Callable[ - [ModelProcessingPermissionLease, str | None, Path], - AbstractAsyncContextManager[MemoryBridgeProtocol], -] - - class MemoryTaskRunner: def __init__( self, @@ -278,8 +264,6 @@ class MemoryTaskRunner: Callable[[ModelProfileSnapshot, str | None, Path], MemoryBridgeProtocol] | None ) = None, close_bridge: bool = True, - model_content_config: ConfigStore | None = None, - permission_bridge_factory: PermissionBridgeFactory | None = None, ) -> None: self.tasks = tasks self.profiles = profiles @@ -289,17 +273,6 @@ class MemoryTaskRunner: self.data_dir = data_dir self.bridge_factory = bridge_factory or self._create_bridge self.close_bridge = close_bridge - self.model_content_config = model_content_config - self.permission_bridge_factory = permission_bridge_factory - self._permission_guard = ( - ModelProcessingPermissionGuard( - model_content_config, - governance, - profiles, - ) - if model_content_config is not None - else None - ) async def run( self, @@ -317,24 +290,13 @@ class MemoryTaskRunner: task_id=task_id, ) try: - if self._permission_guard is not None: - body = ( - self._run_with_content_permission(task_id) - if before_hy_invocation is None - else self._run_with_content_permission( - task_id, before_hy_invocation=before_hy_invocation - ) - ) - else: - # Explicit compatibility for isolated callers without a guard. Production - # Compatibility for isolated callers. Production always supplies ConfigStore. - body = ( - self._run_without_content_permission(task_id) - if before_hy_invocation is None - else self._run_without_content_permission( - task_id, before_hy_invocation=before_hy_invocation - ) + body = ( + self._run_without_content_permission(task_id) + if before_hy_invocation is None + else self._run_without_content_permission( + task_id, before_hy_invocation=before_hy_invocation ) + ) # The ceiling bounds how long this task holds its harness's slot: # a harness is serial, so one hung task starves every turn behind # it. It is insurance against a single bad call, not a way to run @@ -396,213 +358,6 @@ class MemoryTaskRunner: details={"phase": str(step), "action": action}, ) - def _prepare_permission_lease( - self, - task: MemoryTask, - *, - persist_context: bool = True, - ) -> tuple[ModelProcessingPermissionLease, HyMemoryIngestRequest, tuple[EvidenceRecord, ...]]: - guard = self._permission_guard - if guard is None: - raise permission_error("permission_required") - try: - chunk = self._frozen_chunk(task) - if not chunk: - raise permission_error("task_input_scope_required") - permission_records = self._original_records(chunk) - # Check current inputs before expanding history; the complete context - # is checked again before the adapter can see any of it. - guard.check(task.profile_snapshot, permission_records) - request = self._request(task, chunk) - if request.source_context is not None: - context = ( - self._source_context(task, request.source_context) - if persist_context - else self._source_context(task, request.source_context, persist_inputs=False) - ) - request = request.model_copy( - update={ - "source_context": context, - } - ) - lease = guard.lease(task, permission_records, request.source_context) - if request.source_context is not None: - self._check_source_context(request.source_context) - return lease, request, chunk - except HyMemoryBridgeError as error: - if str(error).startswith("model_content_"): - raise - raise permission_error("evidence_changed") from error - - async def _run_with_content_permission( - self, - task_id: str, - *, - before_hy_invocation: Callable[[], Awaitable[None]] | None = None, - ) -> MemoryTask: - step_started = monotonic() - task, token = await in_the_background(self.tasks.claim, task_id) - self._log_step(task_id, 1, "claim", step_started) - hy_invoked = False - try: - if task.kind is not MemoryTaskKind.EXTRACT: - raise permission_error("task_kind_unsupported") - derivation = await in_the_background(self.governance.hy_derivation) - step_started = monotonic() - lease, request, chunk = await in_the_background(self._prepare_permission_lease, task) - self._log_step(task_id, 2, "permission_context", step_started) - factory = self.permission_bridge_factory - if factory is None: - # Explicit refs cannot prove what an existing HY cache holds. - # The adapter must isolate that context and guard SDK children. - raise permission_error("context_unverified") - await in_the_background(lease.recheck) - step_started = monotonic() - secret = await in_the_background( - self.profiles.resolve_snapshot_secret, - task.profile_id, - task.profile_revision, - self.secrets, - ) - if task.profile_snapshot.has_api_key and secret is None: - raise HyMemoryBridgeError("model_secret_unavailable") - self._log_step(task_id, 3, "credential", step_started) - await in_the_background(lease.recheck) - step_started = monotonic() - async with factory(lease, secret, self.data_dir) as bridge: - self._log_step(task_id, 4, "bridge_context", step_started) - await in_the_background(lease.recheck) - contents = tuple( - sanitize_model_input(record.verbatim) - for record in chunk - if _imported_as_said(record) - ) - only_contents = bool(contents) and all(_imported_as_said(r) for r in chunk) - step_started = monotonic() - if before_hy_invocation is not None: - await before_hy_invocation() - await in_the_background( - self.tasks.record_hy_invocation, - task_id, - runner_token=token, - ) - hy_invoked = True - result = ( - await bridge.ingest_extracted(request, contents) - if only_contents - else await bridge.ingest(request) - ) - self._log_step(task_id, 5, "hy_ingest", step_started) - await in_the_background(lease.recheck) - if not result.success: - raise HyMemoryBridgeError("hy_memory_runtime_failed") - if only_contents and not result.memories: - raise HyMemoryBridgeError("hy_memory_import_no_readable_output") - outputs = ( - *result.memories, - *await bridge.profile_memories( - user_id=request.user_id, - agent_id=request.agent_id, - ), - ) - # HY's request-scoped wiring and native-store lease are closed now. - # Governance writes only touch One Memory's SQLite store, so keeping - # the shared HY bridge locked through them serializes unrelated - # sources without protecting any HY state. - await in_the_background(lease.recheck) - step_started = monotonic() - await in_the_background( - self._record_outputs, - task=task, - chunk=self._original_records(chunk), - outputs=outputs, - request=request, - derivation=derivation, - permission_lease=lease, - retired=result.retired_memory_ids, - superseded=result.superseded_memory_ids, - ) - self._log_step(task_id, 6, "governance", step_started) - await in_the_background(lease.recheck_permission) - except SecretStoreUnavailableError: - return await in_the_background( - self.tasks.wait_for_model, - task_id, - runner_token=token, - error_code="secure_storage_unavailable", - ) - except HyMemoryBridgeError as error: - code = str(error) - model_wait_errors = { - "hy_memory_unavailable", - "hy_memory_rate_limit", - "hy_memory_runtime_failed", - "model_secret_unavailable", - "hy_memory_model_authentication_failed", - "hy_memory_model_payment_required", - "hy_memory_model_access_denied", - "hy_memory_model_rate_limited", - "hy_memory_model_service_unavailable", - "hy_memory_model_timeout", - } - safe_terminal_after_invocation = {"hy_memory_import_no_readable_output"} - if hy_invoked and ( - code == "hy_memory_evidence_changed" - or ( - not error_is_policy_stop(code) - and code not in model_wait_errors | safe_terminal_after_invocation - ) - ): - return await in_the_background( - self.tasks.mark_unknown, - task_id, - runner_token=token, - error_code=code, - ) - if error_is_policy_stop(code): - # The user turned this off while the task was running, or the - # policy behind it was revoked. Evidence replacement after an - # HY call is the exception: its outcome is unknown above. - return await in_the_background( - self.tasks.stop, - task_id, - runner_token=token, - error_code=code, - ) - if code == "model_content_permission_required": - return await in_the_background( - self.tasks.wait_for_permission, - task_id, - runner_token=token, - error_code=code, - ) - if code == "model_content_transport_request_failed": - return await in_the_background( - self.tasks.mark_unknown, - task_id, - runner_token=token, - error_code=code, - ) - finish = self.tasks.wait_for_model if code in model_wait_errors else self.tasks.fail - return await in_the_background(finish, task_id, runner_token=token, error_code=code) - except Exception: - # Adapter/setup errors must not strand RUNNING tasks or expose - # content/credentials through provider exception messages. - if hy_invoked: - return await in_the_background( - self.tasks.mark_unknown, - task_id, - runner_token=token, - error_code="model_content_outcome_unknown", - ) - return await in_the_background( - self.tasks.fail, - task_id, - runner_token=token, - error_code="model_content_execution_failed", - ) - return await in_the_background(self.tasks.complete, task_id, runner_token=token) - async def _run_without_content_permission( self, task_id: str, @@ -777,7 +532,7 @@ class MemoryTaskRunner: if hy_invoked and ( code == "hy_memory_evidence_changed" or ( - not error_is_policy_stop(code) + code not in {"hy_memory_source_changed", "hy_memory_source_revoked"} and code not in model_wait_errors | safe_terminal_after_invocation ) ): @@ -787,10 +542,8 @@ class MemoryTaskRunner: runner_token=runner_token, error_code=code, ) - if error_is_policy_stop(code): - # The user stopped the policy after this task was claimed. - # Evidence replacement after an HY call is the exception: its - # outcome is unknown and is handled above. + if code in {"hy_memory_source_changed", "hy_memory_source_revoked"}: + # The source grant changed after this task was claimed. return await in_the_background( self.tasks.stop, task_id, @@ -1022,24 +775,10 @@ class MemoryTaskRunner: outputs: tuple[HyMemoryOutput, ...], request: HyMemoryIngestRequest, derivation: HyDerivation, - permission_lease: ModelProcessingPermissionLease | None = None, retired: tuple[str, ...] = (), superseded: Mapping[str, tuple[str, ...]] | None = None, ) -> None: try: - if permission_lease is not None: - with self.governance.guard_writes(permission_lease.recheck_permission): - self._record_outputs_checked( - task=task, - chunk=chunk, - outputs=outputs, - request=request, - derivation=derivation, - permission_lease=permission_lease, - retired=retired, - superseded=superseded or {}, - ) - return self._record_outputs_checked( task=task, chunk=chunk, @@ -1062,14 +801,11 @@ class MemoryTaskRunner: outputs: tuple[HyMemoryOutput, ...], request: HyMemoryIngestRequest, derivation: HyDerivation, - permission_lease: ModelProcessingPermissionLease | None = None, retired: tuple[str, ...] = (), superseded: Mapping[str, tuple[str, ...]] | None = None, ) -> None: """Writes what HY produced into governance. Synchronous on purpose: the caller runs it in a thread, so the loop stays free to answer.""" - if permission_lease is not None: - permission_lease.recheck() source_records = ( ( *request.source_context.current, @@ -1084,8 +820,6 @@ class MemoryTaskRunner: # rests on everything the turn showed it. whole_turn = derivation.citing(by_id) for output in outputs: - if permission_lease is not None: - permission_lease.recheck_permission() layer = output.layer ids = output.evidence_ids or request.evidence_ids if not set(ids) <= set(by_id): @@ -1115,8 +849,6 @@ class MemoryTaskRunner: if known.owner != output.owner: raise HyMemoryBridgeError("hy_memory_write_verification_authority_invalid") try: - if permission_lease is not None: - permission_lease.recheck_permission() updated = self.governance.write_from_hy( HyRevise( memory_id=known.governed_memory_id, @@ -1130,8 +862,6 @@ class MemoryTaskRunner: ) except MemoryMutationConflictError as error: raise HyMemoryBridgeError("hy_memory_evidence_changed") from error - if permission_lease is not None: - permission_lease.recheck_permission() self._record_provenance(task, updated.memory_id, output, support) continue revised = ( @@ -1146,8 +876,6 @@ class MemoryTaskRunner: ) <= set(by_id): raise HyMemoryBridgeError("hy_memory_write_verification_authority_invalid") try: - if permission_lease is not None: - permission_lease.recheck_permission() updated = self.governance.write_from_hy( HyRevise( memory_id=revised.memory_id, @@ -1161,27 +889,19 @@ class MemoryTaskRunner: ) except MemoryMutationConflictError as error: raise HyMemoryBridgeError("hy_memory_evidence_changed") from error - if permission_lease is not None: - permission_lease.recheck_permission() self._record_provenance(task, updated.memory_id, output, support) continue existing = self.governance.get(output.memory_id, user_id=task.user_id) if existing is not None and existing.content == content: linked = existing for evidence_id in ids: - if permission_lease is not None: - permission_lease.recheck_permission() linked = self.governance.write_from_hy( HyLink(memory_id=existing.memory_id, evidence_id=evidence_id), user_id=task.user_id, derivation=derivation.citing(ids), ) - if permission_lease is not None: - permission_lease.recheck_permission() self._record_provenance(task, linked.memory_id, output, support) continue - if permission_lease is not None: - permission_lease.recheck_permission() memory = self.governance.write_from_hy( HyCreate( draft=MemoryDraft( @@ -1201,15 +921,11 @@ class MemoryTaskRunner: ) # Usable on arrival whoever said it; `origin` tells recall and # matters that an assistant's sentence is not the person's own. - if permission_lease is not None: - permission_lease.recheck_permission() self._record_provenance(task, memory.memory_id, output, support) for memory_id in retired: mirrored = self.governance.get(memory_id, user_id=task.user_id) if mirrored is None or mirrored.status is MemoryStatus.DELETED: continue - if permission_lease is not None: - permission_lease.recheck_permission() self.governance.write_from_hy( HyRetire( memory_id=memory_id, @@ -1236,8 +952,6 @@ class MemoryTaskRunner: ) if not heads: continue - if permission_lease is not None: - permission_lease.recheck_permission() self.governance.write_from_hy( HySupersede( memory_id=memory_id, @@ -1248,8 +962,6 @@ class MemoryTaskRunner: user_id=task.user_id, derivation=whole_turn, ) - if permission_lease is not None: - permission_lease.recheck_permission() self._save_extraction_for_training(task, outputs) def _save_extraction_for_training( @@ -1486,8 +1198,6 @@ class MemoryProcessingSupervisor: Callable[[ModelProfileSnapshot, str | None, Path], MemoryBridgeProtocol] | None ) = None, max_concurrent_tasks: int | None = None, - model_content_config: ConfigStore | None = None, - permission_bridge_factory: PermissionBridgeFactory | None = None, source_writes_permitted: Callable[[str], bool] | None = None, service_levels: ServiceLevelScheduler | None = None, ) -> None: @@ -1497,23 +1207,12 @@ class MemoryProcessingSupervisor: self.evidence = evidence self.governance = governance self.data_dir = data_dir - self.model_content_config = model_content_config - self.permission_bridge_factory = permission_bridge_factory #: Per-source "may this become a memory" switch. Optional so tests and #: a Core without a grant store keep their current behaviour; when #: absent every source is treated as writable, which is what the #: product did before the switch existed. self.source_writes_permitted = source_writes_permitted self.service_levels = service_levels or ServiceLevelScheduler() - self._permission_guard = ( - ModelProcessingPermissionGuard( - model_content_config, - governance, - profiles, - ) - if model_content_config is not None - else None - ) self.extraction_settings = extraction_settings or ExtractionSettingsStore( data_dir / "one-memory.sqlite3" ) @@ -1597,8 +1296,6 @@ class MemoryProcessingSupervisor: active = await in_the_background(self.profiles.active, self.secrets) if active is None or (profile_id is not None and profile_id != active.profile_id): raise HyMemoryBridgeError("model_profile_not_active") - if self._permission_guard is not None: - await in_the_background(self._guard_records, records, active) # The durable drain and explicit processing entry reach HY through # this method, not through the backfill scheduler. New work from a # frozen Harness must be refused before any task or bridge call. @@ -1607,7 +1304,7 @@ class MemoryProcessingSupervisor: and self.source_writes_permitted is not None and not await in_the_background(self._source_may_write, list(records)) ): - raise permission_error("source_disabled") + raise HyMemoryBridgeError("hy_memory_source_disabled") conversations: dict[tuple[str, str, str | None], list[EvidenceRecord]] = {} for record in records: stored = await in_the_background(self.evidence.get, record.evidence_id) @@ -1633,9 +1330,6 @@ class MemoryProcessingSupervisor: for conversation in conversations.values() for chunk in _conversation_chunks(conversation, max_turns=12, completed_only=True) ] - if self._permission_guard is not None: - for chunk in chunks: - await in_the_background(self._preview_chunk_permission, chunk, active) prepared = [] for chunk in chunks: task = await in_the_background( @@ -1701,8 +1395,6 @@ class MemoryProcessingSupervisor: } if expected != actual: raise HyMemoryBridgeError("hy_memory_input_coverage_incomplete") - if self._permission_guard is not None: - await in_the_background(self._guard_records, records, active) if completed: await self._refresh_read_bridge(completed[-1].profile_snapshot) return tuple(completed) @@ -1719,15 +1411,7 @@ class MemoryProcessingSupervisor: bridge: MemoryBridgeProtocol | None = None try: chunk = await in_the_background(runner._frozen_chunk, task) - permission_lease: ModelProcessingPermissionLease | None = None - if runner._permission_guard is not None: - permission_lease, request, chunk = await in_the_background( - runner._prepare_permission_lease, task - ) - assert permission_lease is not None - await in_the_background(permission_lease.recheck) - else: - request = runner._request(task, chunk) + request = runner._request(task, chunk) secret = await in_the_background( self.profiles.resolve_snapshot_secret, task.profile_id, @@ -1756,7 +1440,6 @@ class MemoryProcessingSupervisor: outputs=outputs, request=request, derivation=derivation, - permission_lease=permission_lease, ) resolved = await in_the_background( self.tasks.resolve_unknown, @@ -1877,26 +1560,6 @@ class MemoryProcessingSupervisor: task_ids: tuple[str, ...] | None = None, ) -> list[MemoryTask]: """Promote at most one small batch of deferred work into the queue.""" - if self._permission_guard is not None: - candidates = ( - task_ids - or tuple(self._manual_batch_remaining) - or tuple( - task.task_id - for task in self.tasks.list( - statuses=(MemoryTaskStatus.DEFERRED_OUTSIDE_WINDOW,) - ) - ) - ) - permitted = tuple( - key for key in candidates if self._guard_task(self.tasks.require(key)) - ) - if not permitted: - return [] - if task_ids is not None: - task_ids = permitted - else: - self._manual_batch_remaining = list(permitted) if task_ids is not None: self._plan_manual_batches(list(task_ids)) elif not self._manual_batch_remaining and self._manual_batches_planned == 0: @@ -2032,8 +1695,6 @@ class MemoryProcessingSupervisor: if active is None: self._record_idle("no_active_profile") return - if self._permission_guard is not None: - self._permission_guard.check(self.profiles.snapshot(active.profile_id, active.revision)) tasks: list[MemoryTask] = [] first = True visited = 0 @@ -2057,63 +1718,20 @@ class MemoryProcessingSupervisor: # but preparation must not create additional processing tasks. if not self._source_may_write(conversation): continue - scoped = conversation - allowed = True - if self._permission_guard is not None: - try: - self._guard_records( - tuple(r for r in scoped if _is_hy_turn_record(r)), - active, - ) - except HyMemoryBridgeError: - allowed = False - try: - chunks = ( - self._prepare_conversation_chunks( - scoped, - active, - merge_history=self._permission_guard is not None, - ) - if allowed - else [] - ) - except HyMemoryBridgeError as error: - # A stale conversation is that conversation's problem. Skipping - # it lets the rest of the scan prepare instead of discarding an - # entire round; the next scan re-evaluates this conversation. - if str(error) != "model_content_evidence_changed": - raise - conversation_hash = sha256( - (conversation[0].conversation_id if conversation else "").encode() - ).hexdigest()[:16] - diagnostic_event( - level="warn", - component="memory.scheduler", - event="memory_scheduler.conversation_skipped", - outcome="skipped", - details={ - "reason": "evidence_changed", - "conversation_hash": conversation_hash, - }, - ) - continue + chunks = self._prepare_conversation_chunks(conversation, active) for chunk in chunks: if should_stop() or self._shutting_down: return - try: - # Historical turns may be coalesced across their original - # boundaries. Freeze that exact union so the runner cannot - # reconstruct only the final turn from `input_ref` later. - tasks.append( - self._enqueue_chunk( - chunk, - active, - freeze_inputs=sum(_opens_turn(record) for record in chunk) > 1, - ) + # Historical turns may be coalesced across their original + # boundaries. Freeze that exact union so the runner cannot + # reconstruct only the final turn from `input_ref` later. + tasks.append( + self._enqueue_chunk( + chunk, + active, + freeze_inputs=sum(_opens_turn(record) for record in chunk) > 1, ) - except HyMemoryBridgeError as error: - if not str(error).startswith("model_content_"): - raise + ) if len(tasks) >= (1 if first else _BACKFILL_BATCH_SIZE): self._check_backfill_context(active) classified = self._classify(tasks) @@ -2136,9 +1754,7 @@ class MemoryProcessingSupervisor: active.profile_id, active.revision, ): - raise permission_error("profile_changed") - if self._permission_guard is not None: - self._permission_guard.check(self.profiles.snapshot(active.profile_id, active.revision)) + raise HyMemoryBridgeError("model_profile_changed") def backfill(self) -> None: self._schedule_tasks(self.prepare_backfill()) @@ -2272,12 +1888,10 @@ class MemoryProcessingSupervisor: async def accept_backfill_off_thread(self) -> None: """Accept processing after the first scheduled batch; the rest continues.""" if self._shutting_down: - raise permission_error("processing_unavailable") - if self._permission_guard is not None: - await in_the_background(self._permission_guard.require_allowed) + raise HyMemoryBridgeError("hy_memory_processing_unavailable") async with self._accept_backfill_lock: if self._shutting_down: - raise permission_error("processing_unavailable") + raise HyMemoryBridgeError("hy_memory_processing_unavailable") state = self._backfill_acceptance running = state.task if state is not None else None if running is not None and not running.done() and state is not None: @@ -2291,11 +1905,9 @@ class MemoryProcessingSupervisor: self._live_backfill = state.task await state.ready.wait() if self._shutting_down or state.task is None or state.task.cancelled(): - raise permission_error("processing_unavailable") + raise HyMemoryBridgeError("hy_memory_processing_unavailable") if state.error is not None: raise HyMemoryBridgeError(state.error) - if self._permission_guard is not None: - await in_the_background(self._permission_guard.require_allowed) async def _prewarm_write_bridges(self, source_ids: tuple[str, ...]) -> None: credentials = await self._active_credentials() @@ -2442,10 +2054,7 @@ class MemoryProcessingSupervisor: await self._schedule_tasks_off_thread(work) async def resume_allowed(self) -> None: - """Recover tasks and backfill while the live global permission is enabled.""" - if self._permission_guard is None: - raise permission_error("permission_required") - await in_the_background(self._permission_guard.require_allowed) + """Compatibility entry: recover waiting tasks and start backfill.""" await self.resume_off_thread() await self.backfill_off_thread(cohort_size=8) @@ -2468,12 +2077,6 @@ class MemoryProcessingSupervisor: now = datetime.now(UTC) window = self._extraction_window() if within_window else None exempt_all_apps_from_window = True - if self._permission_guard is not None: - try: - self._permission_guard.require_allowed() - window = None - except HyMemoryBridgeError: - pass work: list[QueuedWork] = [] deferred_count = 0 for task in tasks: @@ -2536,21 +2139,6 @@ class MemoryProcessingSupervisor: def _schedule_tasks(self, work: list[QueuedWork]) -> None: if self._shutting_down: return - tasks_by_id: dict[str, MemoryTask] = {} - for item in work: - task = self.tasks.get(item.task_id) - if task is not None: - tasks_by_id[task.task_id] = task - if self._permission_guard is not None: - work = [ - item - for item in work - if (task := tasks_by_id.get(item.task_id)) is not None and self._guard_task(task) - ] - # `_classify` already validated every task while it was off the event - # loop. Re-running the full guard here doubles permission work before a - # task starts and does not improve revocation semantics: the runner takes a - # fresh lease before model content is released. self._queue_validated_work(work) async def _schedule_tasks_off_thread(self, work: list[QueuedWork]) -> None: @@ -2742,20 +2330,9 @@ class MemoryProcessingSupervisor: task.status is MemoryTaskStatus.FAILED and task.attempts >= TASK_MAX_ATTEMPTS ): continue - if self._permission_guard is not None and not await in_the_background( - self._guard_task, task - ): + if not await in_the_background(self._guard_task, task): continue runner = self.runner_factory() - if self._permission_guard is not None and ( - runner.model_content_config is not self.model_content_config - ): - await in_the_background( - self._permission_guard.reject_unstarted, - work.task_id, - "runner_permission_required", - ) - continue # A wall-clock ceiling, because a harness is serial: a task # that hangs holds the only slot its harness has, and every # later turn of that harness waits behind it. The runner owns @@ -2929,7 +2506,7 @@ class MemoryProcessingSupervisor: self.secrets, ) if self.profiles.active_identity() != identity: - raise permission_error("profile_changed") + raise HyMemoryBridgeError("model_profile_changed") if snapshot.has_api_key and secret is None: raise HyMemoryBridgeError("model_secret_unavailable") return snapshot, secret @@ -2944,8 +2521,6 @@ class MemoryProcessingSupervisor: return await self._digest_locked() async def _digest_locked(self) -> dict[str, object]: - if self._permission_guard is not None: - raise permission_error("context_unverified") credentials = await self._active_credentials() if credentials is None: raise HyMemoryBridgeError("model_profile_not_active") @@ -3163,30 +2738,6 @@ class MemoryProcessingSupervisor: ) async def update_hy_memory(self, memory_id: str, content: str) -> dict[str, object]: - if self._permission_guard is not None: - with self.profiles._connect() as db: - row = db.execute( - "SELECT profile_id,revision FROM model_profiles WHERE is_active=1" - ).fetchone() - if row is None: - raise HyMemoryBridgeError("model_profile_not_active") - profile = self.profiles.snapshot(row["profile_id"], row["revision"]) - if profile.embedding_provider_kind is not ProviderKind.LOCAL_ONNX: - raise permission_error("correction_embedding_consent_required") - current = self.governance.get(memory_id, user_id="local") - if ( - current is None - or current.content != content - or current.status is MemoryStatus.DELETED - ): - raise permission_error("context_changed") - # Explicit correction updates the existing on-device index only. This - # branch cannot authorize extraction, digest or a remote embedder. - bridge = self._bridge(builtin_retrieval_snapshot(), None, self.data_dir) - result = await bridge.update(memory_id, content) - if self.governance.get(memory_id, user_id="local") != current: - raise permission_error("context_changed") - return result credentials = await self._active_credentials() if credentials is None: raise HyMemoryBridgeError("model_profile_not_active") @@ -3315,28 +2866,10 @@ class MemoryProcessingSupervisor: async def semantic_recall( self, query: str, *, limit: int = 12 ) -> tuple[SemanticMemoryHit, ...]: - if self._permission_guard is not None: - # A consent for Evidence cannot authorize an arbitrary remote query. - # Preserve existing local-ONNX paths, without a new fallback/store. - def ensure_local_embedding() -> None: - with self.profiles._connect() as db: - row = db.execute( - "SELECT profile_id, revision FROM model_profiles WHERE is_active=1" - ).fetchone() - if row is not None: - profile = self.profiles.snapshot(row["profile_id"], row["revision"]) - if profile.embedding_provider_kind is not ProviderKind.LOCAL_ONNX: - raise permission_error("semantic_scope_unsupported") - - await in_the_background(ensure_local_embedding) credentials = await self._active_credentials() if credentials is None: return await self._search_with_builtin_embedding(query, limit=limit) snapshot, secret = credentials - if self._permission_guard is not None and ( - snapshot.embedding_provider_kind is not ProviderKind.LOCAL_ONNX - ): - raise permission_error("semantic_scope_unsupported") key = (snapshot.profile_id, snapshot.revision) bridge = self._read_bridges.get(key) if bridge is None or key not in self._read_bridges_ready: @@ -3410,8 +2943,6 @@ class MemoryProcessingSupervisor: data_dir=self.data_dir, bridge_factory=self._bridge, close_bridge=False, - model_content_config=self.model_content_config, - permission_bridge_factory=self.permission_bridge_factory, ) def _bridge( @@ -3589,7 +3120,6 @@ class MemoryProcessingSupervisor: active = self.profiles.active(self.secrets) if active is None: return [] - self._guard_records((seed,), active) conversation = self.evidence.list_conversation(seed.evidence_id) if not self._source_may_write(conversation): return [] @@ -3601,10 +3131,6 @@ class MemoryProcessingSupervisor: for item in conversation ): return [] - self._guard_records( - tuple(r for r in conversation if _is_hy_turn_record(r)), - active, - ) # A realtime turn that is still open must not become a task, whichever # path gets here first. The debounce is the record of "this arrival is # still waiting for its reply"; without this check a periodic scan @@ -3646,16 +3172,6 @@ class MemoryProcessingSupervisor: admitted: list[tuple[tuple[EvidenceRecord, ...], MemoryTask | None]] = [] for chunk in chunks: done = self._finished_chunk_task(chunk) - if done is None and self._permission_guard is not None: - try: - self._preview_chunk_permission(chunk, profile) - except HyMemoryBridgeError: - if not self.governance.blocked_source_ids( - user_id=chunk[0].user_id, - evidence_ids=tuple(record.evidence_id for record in chunk), - ): - raise - continue admitted.append((chunk, done)) return admitted @@ -3710,7 +3226,6 @@ class MemoryProcessingSupervisor: records = tuple(record for record in conversation if _is_hy_turn_record(record)) if self._shutting_down or not records: return [] - self._guard_records(records, profile) self._check_backfill_context(profile) # Keep a full, immutable failure snapshot. Appended originals remain # unprocessed while the same blocker persists; they are never marked done. @@ -3747,28 +3262,8 @@ class MemoryProcessingSupervisor: preparation_error="hy_memory_input_exceeds_window", preparation_group=group, ) - if self._permission_guard is not None: - self._guard_records(records, profile) return [] - def _guard_records( - self, - records: tuple[EvidenceRecord, ...], - profile: ModelProfile | None = None, - ) -> None: - guard = self._permission_guard - if guard is None: - return - records = self._original_records(records) - if profile is None: - active = self.profiles.active_identity() - if active is None: - raise permission_error("profile_changed") - snapshot = self.profiles.snapshot(*active) - else: - snapshot = self.profiles.snapshot(profile.profile_id, profile.revision) - guard.check(snapshot, records) - def _original_records(self, records: tuple[EvidenceRecord, ...]) -> tuple[EvidenceRecord, ...]: originals: dict[str, EvidenceRecord] = {} for record in records: @@ -3778,46 +3273,6 @@ class MemoryProcessingSupervisor: originals.setdefault(current.evidence_id, current) return tuple(originals.values()) - def _preview_chunk_permission( - self, - chunk: tuple[EvidenceRecord, ...], - profile: ModelProfile, - ) -> None: - guard = self._permission_guard - if guard is None: - return - snapshot = self.profiles.snapshot(profile.profile_id, profile.revision) - now = datetime.now(UTC) - # An in-memory task-shaped query selects exactly the history the runner - # would load. It is not persisted and cannot claim or execute work. - preview = MemoryTask( - task_id="permission-preview", - request_id="permission-preview", - user_id=chunk[0].user_id, - kind=MemoryTaskKind.EXTRACT, - input_ref=chunk[0].evidence_id, - status=MemoryTaskStatus.PENDING, - profile_id=snapshot.profile_id, - profile_revision=snapshot.revision, - profile_snapshot=snapshot, - attempts=0, - last_error_code=None, - created_at=now, - updated_at=now, - ) - runner = self._create_runner() - request = runner._request(preview, chunk) - context = request.source_context - originals = self._original_records(chunk) - if context is not None: - try: - context = runner._source_context(preview, context, persist_inputs=False) - except HyMemoryBridgeError as error: - raise permission_error("evidence_changed") from error - records = guard.context_records(originals, context) - guard.check(snapshot, records) - guard.check_context(records, context) - def _guard_task(self, task: MemoryTask) -> bool: if task.last_error_code in { "hy_memory_input_exceeds_window", @@ -3832,19 +3287,6 @@ class MemoryProcessingSupervisor: # Freeze: the durable row stays pending. Reopening the Harness # causes the ordinary resume pass to classify it again. return False - guard = self._permission_guard - if guard is None: - return True - try: - guard.check(task.profile_snapshot) - self._create_runner()._prepare_permission_lease(task, persist_context=False) - except HyMemoryBridgeError as error: - code = str(error) - if code == "model_content_permission_required": - self.tasks.park_for_permission(task.task_id, error_code=code) - else: - guard.reject_unstarted(task.task_id, code) - return False return True def _source_may_write(self, conversation: list[EvidenceRecord]) -> bool: @@ -3868,7 +3310,6 @@ class MemoryProcessingSupervisor: *, freeze_inputs: bool = False, ) -> MemoryTask: - self._guard_records(chunk, profile) if any("segment_hash" in evidence_ref(record) for record in chunk): freeze_inputs = True source = chunk[0] @@ -3876,9 +3317,6 @@ class MemoryProcessingSupervisor: finished = self._finished_chunk_task(chunk) if finished is not None: return finished - if self._permission_guard is not None: - self._preview_chunk_permission(chunk, profile) - freeze_inputs = True digest = sha256( "|".join( ( @@ -3912,15 +3350,6 @@ class MemoryProcessingSupervisor: profile=profile, input_records=chunk if freeze_inputs else (), ) - if self._permission_guard is not None: - try: - self._permission_guard.check( - task.profile_snapshot, - self._original_records(chunk), - ) - except HyMemoryBridgeError as error: - self._permission_guard.reject_unstarted(task.task_id, str(error)) - raise if task.status is MemoryTaskStatus.PENDING: self.tasks.supersede_unstarted( task, diff --git a/memhub/apps/core/src/one_memory/memory_server/config_state.py b/memhub/apps/core/src/one_memory/memory_server/config_state.py index db5e401..1916a07 100644 --- a/memhub/apps/core/src/one_memory/memory_server/config_state.py +++ b/memhub/apps/core/src/one_memory/memory_server/config_state.py @@ -29,7 +29,6 @@ class ConfigDocument(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid", strict=True) schema_version: int = Field(default=1, ge=1, le=1) - model_content_allowed: StrictBool = False sources: dict[str, SourceConfig] = Field(default_factory=dict) @field_validator("sources") @@ -64,10 +63,6 @@ class ConfigStore: source = self._document.sources.get(source_id) return bool(source and source.sync_enabled) - def model_content_allowed(self) -> bool: - with self._lock: - return self._document.model_content_allowed - def set_sync(self, source_id: str, enabled: bool) -> ConfigDocument: return self.set_sync_many({source_id: enabled}) @@ -86,20 +81,17 @@ class ConfigStore: self._document = updated return updated - def set_model_content_allowed(self, allowed: bool) -> ConfigDocument: - if type(allowed) is not bool: - raise ValueError("invalid_model_content_config") - with self._lock: - updated = self._document.model_copy(update={"model_content_allowed": allowed}) - self._write(updated) - self._document = updated - return updated - def _read(self) -> ConfigDocument: if not self.path.exists(): return ConfigDocument() try: - return ConfigDocument.model_validate_json(self.path.read_text(encoding="utf-8")) + value = json.loads(self.path.read_text(encoding="utf-8")) + if isinstance(value, dict): + # Builds through 1.0.2 persisted this global consent bit. + # The embedded SDK is now trusted to use all local memory, so + # accept existing files while discarding the retired setting. + value.pop("model_content_allowed", None) + return ConfigDocument.model_validate(value) except (OSError, ValueError) as error: raise ConfigFileError("config_invalid") from error diff --git a/memhub/apps/core/src/one_memory/memory_server/migrations.py b/memhub/apps/core/src/one_memory/memory_server/migrations.py index 32629c1..a736b08 100644 --- a/memhub/apps/core/src/one_memory/memory_server/migrations.py +++ b/memhub/apps/core/src/one_memory/memory_server/migrations.py @@ -1404,7 +1404,14 @@ async def _024_matter_model_cache(cursor: Any) -> None: async def _026_hy_node_permissions(cursor: Any) -> None: - await cursor.execute(hy_node_permissions_ddl(_dialect(cursor))) + # Historical migration retained as a ledger slot. Fresh databases no + # longer create this retired proof table; migration 51 removes it from + # databases that ran the original v26 body. + return None + + +async def _051_drop_hy_node_permissions(cursor: Any) -> None: + await cursor.execute("DROP TABLE IF EXISTS hy_node_permissions") def model_profiles_ddl(dialect: str = "mysql") -> str: @@ -2756,7 +2763,7 @@ MIGRATIONS: tuple[Migration, ...] = ( ), Migration(23, "source_candidates", _023_source_candidates, ("source_candidates",)), Migration(24, "matter_model_cache", _024_matter_model_cache, ("matter_model_cache",)), - Migration(26, "hy_node_permissions", _026_hy_node_permissions, ("hy_node_permissions",)), + Migration(26, "hy_node_permissions", _026_hy_node_permissions), Migration( 27, "model_profiles", @@ -2841,4 +2848,5 @@ MIGRATIONS: tuple[Migration, ...] = ( "model_probe_operations_reserved_at", _050_model_probe_operations_reserved_at, ), + Migration(51, "drop_hy_node_permissions", _051_drop_hy_node_permissions), ) diff --git a/memhub/apps/core/src/one_memory/memory_tasks.py b/memhub/apps/core/src/one_memory/memory_tasks.py index 1f04923..94c6ab7 100644 --- a/memhub/apps/core/src/one_memory/memory_tasks.py +++ b/memhub/apps/core/src/one_memory/memory_tasks.py @@ -267,15 +267,13 @@ class MemoryTaskOutput(BaseModel): MEMORY_TASK_SCHEMA = memory_tasks_ddl("sqlite") -#: Transport refusals raised before the physical send, so provably nothing -#: reached HY: nothing can have been written and retrying cannot duplicate. +#: Legacy transport refusals raised before the physical send, so provably +#: nothing reached HY: nothing can have been written and retrying cannot +#: duplicate. Kept to recover rows written by builds through 1.0.2. #: -#: Every name here is denied by `HyPermissionTransport._check_request` (the -#: httpx request hook) or by `_PhysicalTransport.handle_async_request` before -#: it counts the request — both run inside httpx, before the socket. Explicit -#: on purpose (D88): the transport also denies `budget_exceeded`, `redirect`, -#: `lease_*` and `request_failed`, all of which can arrive after HY persisted -#: a node, so a prefix rule would requeue work that may already exist. +#: The retired request hook denied every name below before opening a socket. +#: This stays explicit: other old transport codes may have happened after HY +#: persisted a node and must not be blindly requeued. _PRE_SEND_REFUSALS = ( "model_content_transport_headers_mismatch", "model_content_transport_body_mismatch", diff --git a/memhub/apps/core/src/one_memory/model_content_routes.py b/memhub/apps/core/src/one_memory/model_content_routes.py deleted file mode 100644 index a4adeae..0000000 --- a/memhub/apps/core/src/one_memory/model_content_routes.py +++ /dev/null @@ -1,49 +0,0 @@ -"""One global final-state permission for remote model content egress.""" - -import asyncio -from collections.abc import Sequence -from typing import Annotated, cast - -from fastapi import APIRouter, Request -from fastapi.params import Depends -from pydantic import BaseModel, ConfigDict, StrictBool - -from one_memory.memory_server.config_state import ConfigStore - - -class ModelContentPermissionState(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - allowed: bool - - -class SetModelContentPermission(BaseModel): - model_config = ConfigDict(extra="forbid") - - allowed: StrictBool - - -def model_content_router(dependencies: Sequence[Depends]) -> APIRouter: - router = APIRouter(prefix="/api/v1/model-content-permission", dependencies=dependencies) - - def config(request: Request) -> ConfigStore: - return cast(ConfigStore, request.app.state.config_store) - - Config = Annotated[ConfigStore, Depends(config)] - - @router.get("", response_model=ModelContentPermissionState) - async def current(store: Config) -> ModelContentPermissionState: - return ModelContentPermissionState(allowed=store.model_content_allowed()) - - @router.post("", response_model=ModelContentPermissionState) - async def set_permission( - request: Request, - value: SetModelContentPermission, - store: Config, - ) -> ModelContentPermissionState: - await asyncio.to_thread(store.set_model_content_allowed, value.allowed) - if value.allowed: - request.app.state.start_memory_backfill() - return ModelContentPermissionState(allowed=value.allowed) - - return router diff --git a/memhub/apps/core/src/one_memory/model_processing_permission_guard.py b/memhub/apps/core/src/one_memory/model_processing_permission_guard.py deleted file mode 100644 index 5b06bf1..0000000 --- a/memhub/apps/core/src/one_memory/model_processing_permission_guard.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Live global permission checks for remote model-content egress. - -``config.json`` is the only durable permission state. Tasks do not carry a -permission receipt, revision, source scope, or model pin. Every remote send -and every publication boundary comes back through the lease and reads the -current final-state boolean. -""" - -from __future__ import annotations - -from collections.abc import Iterator -from contextlib import contextmanager -from dataclasses import dataclass, field -from datetime import UTC, datetime - -from one_memory.hy_source_context import HySourceContext -from one_memory.imports.models import EvidenceRecord -from one_memory.memory_governance import MemoryGovernanceStore, MemoryStatus -from one_memory.memory_server.config_state import ConfigStore -from one_memory.memory_tasks import MemoryTask, MemoryTaskStatus -from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore - - -def permission_error(code: str) -> RuntimeError: - from one_memory.hy_memory_bridge import HyMemoryBridgeError - - return HyMemoryBridgeError( - code if code.startswith("model_content_") else f"model_content_{code}" - ) - - -def error_is_policy_stop(code: str) -> bool: - """A disabled final-state permission is an intentional stop.""" - - return code in { - "model_content_source_disabled", - "hy_memory_evidence_changed", - } - - -@dataclass(frozen=True) -class ModelProcessingPermissionLease: - task_id: str - user_id: str - profile: ModelProfileSnapshot - records: tuple[EvidenceRecord, ...] - source_context: HySourceContext | None - _guard: ModelProcessingPermissionGuard = field(repr=False, compare=False) - - def recheck(self) -> None: - """Read current config/profile immediately before an egress boundary.""" - - self.recheck_permission() - self._guard.check_context(self.records, self.source_context) - - def recheck_permission(self) -> None: - self._guard.check(self.profile, self.records) - - -class ModelProcessingPermissionGuard: - def __init__( - self, - config: ConfigStore, - governance: MemoryGovernanceStore, - profiles: ModelProfileStore, - ) -> None: - self.config = config - self.governance = governance - self.profiles = profiles - - def require_allowed(self) -> None: - if not self.config.model_content_allowed(): - raise permission_error("permission_required") - - def check( - self, - profile: ModelProfileSnapshot, - records: tuple[EvidenceRecord, ...] = (), - ) -> None: - """Validate only live global consent and the currently active model.""" - - self.require_allowed() - if self.profiles.active_identity() != (profile.profile_id, profile.revision): - raise permission_error("profile_changed") - if records and any(record.user_id != records[0].user_id for record in records): - raise permission_error("context_scope_invalid") - - def lease( - self, - task: MemoryTask, - records: tuple[EvidenceRecord, ...], - context: HySourceContext | None, - ) -> ModelProcessingPermissionLease: - combined = self.context_records(records, context) - lease = ModelProcessingPermissionLease( - task_id=task.task_id, - user_id=task.user_id, - profile=task.profile_snapshot, - records=combined, - source_context=context, - _guard=self, - ) - lease.recheck() - return lease - - @staticmethod - def context_records( - records: tuple[EvidenceRecord, ...], - context: HySourceContext | None, - ) -> tuple[EvidenceRecord, ...]: - by_id = {record.evidence_id: record for record in records} - if len(by_id) != len(records): - raise permission_error("context_scope_invalid") - if context is not None: - for record in (*context.current, *context.history, *context.supporting): - previous = by_id.get(record.evidence_id) - if previous is not None and previous != record: - raise permission_error("evidence_changed") - by_id[record.evidence_id] = record - return tuple(by_id.values()) - - def check_context( - self, - records: tuple[EvidenceRecord, ...], - context: HySourceContext | None, - ) -> None: - if context is None: - return - ids = {record.evidence_id for record in records} - if not context.current: - raise permission_error("context_scope_invalid") - context_records = (*context.current, *context.history, *context.supporting) - if len({record.evidence_id for record in context_records}) != len(context_records): - raise permission_error("context_scope_invalid") - for known in context.known_memories: - if not known.evidence_ids or not set(known.evidence_ids) <= ids: - raise permission_error("context_support_missing") - memory = self.governance.get( - known.governed_memory_id, - user_id=context.current[0].user_id, - ) - if ( - memory is None - or memory.revision != known.revision - or memory.content != known.content - or set(memory.evidence_ids) != set(known.evidence_ids) - or memory.status is not MemoryStatus.CONFIRMED - ): - raise permission_error("context_changed") - - def reject_unstarted(self, task_id: str, code: str) -> None: - stopped = error_is_policy_stop(code) - with self._task_write() as tasks: - tasks.execute( - "UPDATE memory_tasks SET status=?, last_error_code=?, updated_at=? " - "WHERE task_id=? AND status IN (?,?,?,?)", - ( - MemoryTaskStatus.CANCELLED if stopped else MemoryTaskStatus.FAILED, - str(permission_error(code)), - datetime.now(UTC).isoformat(), - task_id, - MemoryTaskStatus.PENDING, - MemoryTaskStatus.WAITING_FOR_PERMISSION, - MemoryTaskStatus.WAITING_FOR_MODEL, - MemoryTaskStatus.DEFERRED_OUTSIDE_WINDOW, - ), - ) - - @contextmanager - def _task_write(self) -> Iterator[object]: - from one_memory.database import take_the_write_lock_first - from one_memory.database_access import main_database - - with main_database(self.governance.database_path).write( - autocommit=True, - foreign_keys=True, - ) as connection: - take_the_write_lock_first(connection) - with connection: - yield connection diff --git a/memhub/apps/core/src/one_memory/processing_coordinator.py b/memhub/apps/core/src/one_memory/processing_coordinator.py deleted file mode 100644 index 72ff170..0000000 --- a/memhub/apps/core/src/one_memory/processing_coordinator.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Continuous recovery monitor for model processing. - -The monitor is intentionally retained. It observes the global config final -state plus ``memory_tasks`` and asks the supervisor to recover work; it owns no -second request/receipt state machine. -""" - -import asyncio -import time -from collections.abc import Hashable - -from one_memory.memory_processing import MemoryProcessingSupervisor -from one_memory.memory_server.config_state import ConfigStore - - -class ProcessingCoordinator: - RESCAN_SECONDS = 60.0 - POLL_SECONDS = 2.0 - - def __init__(self, config: ConfigStore, supervisor: MemoryProcessingSupervisor): - self.config = config - self.supervisor = supervisor - self._stopping = False - self._monitor: asyncio.Task[None] | None = None - self._error: str | None = None - self._observed: tuple[Hashable, ...] | None = None - self._last_scan = 0.0 - self._clock = time.monotonic - - async def recover(self) -> None: - """Start the one process-owned monitor, whether permission is on or off.""" - - if self._stopping: - return - if self._monitor is None or self._monitor.done(): - self._monitor = asyncio.create_task( - self._monitor_loop(), - name="one-memory-processing-monitor", - ) - - def _identity(self) -> tuple[Hashable, ...]: - counts = self.supervisor.tasks.counts() - return ( - self.config.model_content_allowed(), - tuple(sorted((str(status), count) for status, count in counts.items())), - ) - - async def _tick(self) -> None: - identity = await asyncio.to_thread(self._identity) - now = self._clock() - if identity[0] and ( - identity != self._observed or now - self._last_scan >= self.RESCAN_SECONDS - ): - await self.supervisor.resume_allowed() - self._last_scan = now - identity = await asyncio.to_thread(self._identity) - self._observed = identity - self._error = None - - async def _monitor_loop(self) -> None: - while not self._stopping: - try: - await self._tick() - except asyncio.CancelledError: - raise - except Exception: - self._error = "model_content_recovery_blocked" - await asyncio.sleep(self.POLL_SECONDS) - - def status(self) -> dict[str, object]: - return { - "monitoring": self._monitor is not None and not self._monitor.done(), - "allowed": self._observed[0] if self._observed else None, - "error": self._error, - } - - async def shutdown(self) -> None: - self._stopping = True - if self._monitor is not None: - self._monitor.cancel() - await asyncio.gather(self._monitor, return_exceptions=True) diff --git a/memhub/apps/core/tests/TEST_INDEX.md b/memhub/apps/core/tests/TEST_INDEX.md index 9f03083..f157574 100644 --- a/memhub/apps/core/tests/TEST_INDEX.md +++ b/memhub/apps/core/tests/TEST_INDEX.md @@ -13,10 +13,7 @@ | `test_archive_extract.py` | `contract` | Unpacking an archive a stranger sent, onto this user's disk. | | `test_ask_workspace_path.py` | `contract` | Ask must honour the same workspace_path expansion recall uses. | | `test_async_bridge.py` | `contract` | 覆盖 reader rejects parallel workers that could close during next、cancelling a read stops the worker before the batch finishes、starting a read does not stop the app answering 等 10 个场景。 | -| `test_async_dispatch_checks.py` | `contract` | 覆盖 cancelling quiet timer does not start any permission reader、dispatch check does not queue work after shutdown、dispatch permission check denies before queue after classification 等 6 个场景。 | | `test_auth.py` | `integration` | 覆盖 local api requires bearer token、browser origin is checked in addition to token 场景。 | -| `test_backfill_acceptance_lifecycle.py` | `contract` | A processing acknowledgement belongs to one consent revision and live worker. | -| `test_backfill_conversation_isolation.py` | `contract` | One stale conversation cannot invalidate an entire backfill scan. | | `test_background_work.py` | `contract` | 覆盖 a request does not wait behind a catch up、bulk work is kept to a few threads at a time、the pool outlives one hand off 等 4 个场景。 | | `test_backup_destroy_restore_reconciliation.py` | `contract` | Backup -> destroy -> restore, reconciled row for row. | | `test_backup_endpoint.py` | `integration` | The 'POST /api/v1/backup' endpoint the CLI's 'backup create' calls. | @@ -64,7 +61,6 @@ | `test_cli_lifecycle.py` | `integration` | 覆盖 cli starts and stops without residual runtime 场景。 | | `test_cli_restore.py` | `contract` | 覆盖 restore requires a source、restore refuses while the runtime is running、restore previews without confirmation 等 4 个场景。 | | `test_codex_header_provenance.py` | `contract` | 覆盖 header workspace versions and native ids reach file origin、new adapter rereads header after a real checkpoint、machine and unknown session sources do not establish human or agent sender 等 14 个场景。 | -| `test_compact_model_content.py` | `contract` | Server-held previews preserve exact grants without round-tripping all refs. | | `test_connection_lifetime.py` | `integration` | Every SQLite connection a request opens must be closed when the request ends. | | `test_context_access.py` | `contract` | 覆盖 memory only never reads raw authority、raw permission does not grant memory and removes revoked sources、raw requires all apps and source 等 13 个场景。 | | `test_context_api.py` | `integration` | 覆盖 no model local query returns real raw and governed items、existing single lane authorization never runs denied reader、authentication and origin remain required 等 14 个场景。 | @@ -76,12 +72,6 @@ | `test_context_search.py` | `contract` | 覆盖 authorization and requested paths never invoke other lane、empty partial zero unavailable and safe failures、parallel common deadline and finalizers wait for both reads 等 24 个场景。 | | `test_context_search_both_paths.py` | `integration` | The objective names 'recall' and requires BOTH paths: raw and memory. | | `test_context_v2_contract_parity.py` | `unit` | Mechanical parity checks for the shared v2 coverage receipt contract. | -| `test_continuous_completed_input_review.py` | `contract` | Independent review of completed-input reuse; real stores and controlled HY only. | -| `test_continuous_coordinator.py` | `contract` | 覆盖 activation hook and startup recovery start single monitor then stop、failed preparation does not advance observed watermark、new journal event and source regrant wake replay without legacy request 等 6 个场景。 | -| `test_continuous_outcome_unknown.py` | `contract` | 覆盖 a timed out continuous task becomes unknown not pending、a timed out non continuous task also becomes unknown、an unsettled continuous send becomes unknown not failed 等 8 个场景。 | -| `test_continuous_policy.py` | `contract` | 覆盖 backlog snapshot over one hundred thousand is compact、caller rollback includes policy and backlog、enable idempotence rejects changed payload and superseded operation 等 19 个场景。 | -| `test_continuous_processing_bridge.py` | `contract` | 覆盖 bound source regrant refuses old task、continuous inactive never falls back to legacy、continuous prepare lease ingest has no legacy receipt or old context 等 17 个场景。 | -| `test_continuous_processing_integration.py` | `contract` | Real temporary Core/stores with controlled probe and HY boundaries, never real data. | | `test_contract_parity.py` | `contract` | Python 的枚举和 TypeScript 里那份手写的联合类型必须一致。 | | `test_conversation_search.py` | `integration` | Searching imported conversations before any generation model is configured. | | `test_conversation_seed_ids.py` | `contract` | 覆盖 seed ids read metadata only without hydrating、seed eligibility keeps person and memory content only、latest activity includes nonseed turns and import time fallback 等 13 个场景。 | @@ -97,7 +87,6 @@ | `test_database_contention.py` | `contract` | Writers meet in this one file, and the loser used to lose data. | | `test_database_file_permissions.py` | `contract` | 覆盖 a new main database is readable by its owner alone、the wal sidecars are not left world readable、a database from an older build is tightened on open 场景。 | | `test_db_session_utc.py` | `contract` | The MySQL session must be pinned to UTC. | -| `test_delete_no_conversation_freeze.py` | `contract` | 删掉一条记忆,不得让同一对话之后的新 turn 停止沉淀(RC10)。 | | `test_delivery_board.py` | `contract` | 覆盖 parses only the marked structured record、verified requires evidence with a positive control、rejects unknown dependency 等 5 个场景。 | | `test_derived_echo.py` | `contract` | A host that saves our injection back must not mint a new user decision. | | `test_detailed_logging.py` | `contract` | 覆盖 detailed pipeline logging defaults off and survives restart、live log level applies to full hy and provider call chain、info collects hy info without enabling debug pipeline content 等 7 个场景。 | @@ -111,7 +100,6 @@ | `test_dual_engine_helper.py` | `contract` | Tests for the dual-engine harness itself. | | `test_event_loop_blocking.py` | `contract` | Proof, as a test, that blocking work on the loop freezes it — and the fix works. | | `test_evidence_reconcile_primitive.py` | `integration` | A bounded, read-only discovery primitive for the H23 Evidence reconcile. | -| `test_evidence_revision_read_review.py` | `contract` | Read invalidation review: real guard/provenance, controlled HY, no model/network. | | `test_evidence_store.py` | `contract` | 覆盖 same source message merges origins without duplication、identical text from different sources is not deduplicated、rescanning an unchanged file does not write the message again 等 26 个场景。 | | `test_export_probe.py` | `contract` | What a probe may say about a stranger's chat history, and what it may not. | | `test_external_endpoint_scopes.py` | `integration` | Core boundary tests using the broker's envelope, never a host's claimed identity. | @@ -125,10 +113,9 @@ | `test_file_import_operations.py` | `integration` | Bounded synthetic uploads; operation identity is not a content dedup key. | | `test_file_import_operations_review.py` | `contract` | Upload refusal/legacy compatibility review with bounded synthetic files only. | | `test_file_publish_job_boundary.py` | `contract` | Staged uploads cannot widen an earlier directory import's uncaptured input. | -| `test_four_app_live_ingest.py` | `integration` | Actual source readers + OS watch + Core routes; no model or native host calls. | +| `test_four_app_live_ingest.py` | `contract` | Actual source readers + OS watch + Core routes; no model or native host calls. | | `test_frozen_core_layout.py` | `contract` | 覆盖 single file layout remains available without private preset、folder layout keeps same core and all native dependencies、unknown layout does not silently build another package 等 5 个场景。 | | `test_golden_corpus.py` | `contract` | A7-01: the golden corpus comparison. | -| `test_governance_authority_review.py` | `contract` | D04 authority and workspace scope through the real continuous permission guard. | | `test_governance_permission_commit.py` | `contract` | 覆盖 permission guard is rechecked inside write transaction and rolls back、guard context does not leak into subsequent manual actions、guard check and commit hold database write authority 场景。 | | `test_governance_read_path_does_not_take_the_writer.py` | `contract` | The recall read path must not acquire the single global writer. | | `test_governance_store_root.py` | `contract` | 生产代码里治理 store 只在 Core 启动的组合根建一次。 | @@ -144,32 +131,19 @@ | `test_hourly_c1_peak_replay_contract.py` | `contract` | Guard the package peak-replay probe's error accounting. | | `test_hourly_c1_recall_stage_probe_contract.py` | `contract` | Guard the packaged recall stage probe's command and error semantics. | | `test_hy_baseline_source.py` | `contract` | 覆盖 first import replay copies raw prefix without selecting message ids、frozen transcript keeps middle user message and excludes later records 场景。 | -| `test_hy_coding_lease.py` | `contract` | HY's own coding/working route under the permission lease, on the pinned SDK. | | `test_hy_coding_reply.py` | `contract` | R-07: HY's coding-shaped replies read as "no memories at all". | -| `test_hy_lease_responsiveness.py` | `contract` | Blocked lease checks, local failing doubles only; no network or credentials. | -| `test_hy_lifecycle_sync.py` | `contract` | HY decides deletions; the ledger mirrors them. Pinned SDK, scripted HTTP. | | `test_hy_log_privacy.py` | `contract` | HY SDK records are not rewritten by One Memory's logging setup. | | `test_hy_memory_bridge.py` | `contract` | 覆盖 hy client initialization is serial across bridges、native hy clients share one runtime per data directory、ollama profile ignores ambient remote storage and models 等 41 个场景。 | | `test_hy_metrics_lifecycle.py` | `contract` | What the pinned public SDK's MetricsCollector does, and what that costs us. | -| `test_hy_native_write_chain.py` | `contract` | HY writes with its own prompts and judgment; the product adds no audit of its own. | | `test_hy_network_egress.py` | `contract` | Where a real HY client is allowed to send bytes, and where it is not. | -| `test_hy_permission_context.py` | `contract` | Whole native HY client with synthetic content and HTTP MockTransport, never cloud. | -| `test_hy_permission_context_races.py` | `contract` | Bounded factory races with a temporary native HY client and no model calls. | -| `test_hy_permission_raw_context.py` | `contract` | Real pinned HY SDK/Chroma and guards; scripted HTTP is not model acceptance. | -| `test_hy_permission_store.py` | `contract` | Native HY node/algorithm tests with synthetic data and an in-memory backend only. | -| `test_hy_permission_transport.py` | `contract` | Native HY entry points, synthetic inputs and MockTransport only. | | `test_hy_response_guard.py` | `contract` | 覆盖 invalid response retries same hy provider without changing prompt、repeated empty response fails instead of becoming no op、failure receipt survives a later successful model call 等 23 个场景。 | | `test_hy_response_shapes.py` | `contract` | HY response shape selection only; no provider, write routing or model calls. | | `test_hy_runtime_config.py` | `contract` | MEMORY.md is the audited record of what we send HY; keep it answerable. | | `test_hy_sdk_idempotency_probe.py` | `contract` | A0-03: probe whether the HY SDK can make a write idempotent. | | `test_hy_source_context.py` | `contract` | 覆盖 bound context rejects a same named conversation in another workspace、bound context rejects a duplicated evidence id、bound context rejects a same conversation from another source 等 9 个场景。 | | `test_hy_stage_trace.py` | `contract` | 覆盖 observer returns native results without editing or requesting models、observer redacts content without recording prompts or exception details 场景。 | -| `test_hy_state_source.py` | `contract` | RC4:记忆生命周期(有效 / 被取代 / 去重隐藏 / 过期)直接读 HY,产品账本不再逐类镜像。 | -| `test_hy_step_timing.py` | `contract` | Where an HY write spends the time that is not a model call. | | `test_hy_step_trace_switch.py` | `contract` | Rule 15: the per-step HY trace is off unless the debug tier asks for it. | -| `test_hy_supersede_sync.py` | `contract` | HY decides supersessions; the ledger mirrors them. Pinned SDK, scripted HTTP. | | `test_hy_trial_budget.py` | `contract` | Offline proofs for the real-model acceptance budget (no network, no Keychain). | -| `test_hy_turn_input.py` | `contract` | HY gets one turn at a time, exactly as it happened, and picks its own route. | | `test_hy_writeback.py` | `integration` | HY 的结果只有一扇门进治理表,而且每次进门都带着调用开始时的证据快照。 | | `test_import_api.py` | `integration` | 覆盖 resume during cleanup returns conflict without internal error、authorize detect import and revoke api flow、protected api rejects missing token and foreign origin 等 5 个场景。 | | `test_import_control_races_review.py` | `contract` | Read-only review repro: real runner/store, synthetic delayed adapter cleanup. | @@ -181,7 +155,6 @@ | `test_import_three_scenarios.py` | `contract` | The three import scenarios a user actually meets. | | `test_import_three_scenarios_real.py` | `contract` | The three import scenarios, on a real Core at a real volume. | | `test_imported_index_background.py` | `contract` | The ordering index is built off the startup path. | -| `test_incremental_memory_backfill.py` | `contract` | 覆盖 accept backfill returns after first batch while later history is still read、backfill budget yields between rounds and resumes same cursor、backfill holds an unanswered raw turn until its reply arrives 等 18 个场景。 | | `test_ingest_journal.py` | `contract` | 覆盖 baseline and empty initialize are explicit transactional and idempotent、old date new import and revise noop and rollbacks、import metadata changes invalidate without authorizing content 等 12 个场景。 | | `test_input_sanitization.py` | `unit` | 覆盖 redacts cli and assignment secrets without removing context、redacts bearer known tokens urls and private keys、redacts quoted json assignments and basic authorization 等 8 个场景。 | | `test_installer.py` | `contract` | 覆盖 installer create verify and rollback is transactional、installer updates owned file and restores backup、installer refuses unowned or changed target 场景。 | @@ -209,7 +182,6 @@ | `test_main_database_fairness.py` | `contract` | Waiting for the one database has to be a queue, not a scramble. | | `test_main_database_isolation.py` | `contract` | What one checkout of the main database can do, and what it leaves for the next. | | `test_managed_restore.py` | `contract` | 覆盖 restore rejects a source outside the managed backup directory、restore rejects a symlinked source、restore rejects a non one memory database 等 9 个场景。 | -| `test_manual_memory_via_hy.py` | `contract` | T04: what the person explicitly asks to remember is written through HY's import path. | | `test_manual_transcript_import.py` | `integration` | 覆盖 manual json keeps authors unknown time and exact text、marked text keeps code fences and never invents timestamps、ambiguous partial or invalid manual data never leaves partial import 等 4 个场景。 | | `test_matter_collection.py` | `integration` | 覆盖 hy windows cover every turn including leading and boundary assistants、oversize turn is split without silently dropping content、partial timestamps preserve source sequence 等 8 个场景。 | | `test_matter_discovery.py` | `contract` | 覆盖 synthesis materializes only known verbatim citations、duplicate claims keep all governed refs and distinct states、synthesis cannot echo the original sixty claim list 等 27 个场景。 | @@ -267,30 +239,18 @@ | `test_memory_write_registry.py` | `contract` | A3-01: acceptance and outcome recording for the unified write path. | | `test_memory_write_routes.py` | `integration` | A3-01: the HTTP write entry, tested against a real FastAPI app. | | `test_migrate_off_sqlite.py` | `contract` | A1-06: the migration is only done when reconciliation agrees. | -| `test_model_content_bulk.py` | `contract` | Bulk consent reads preserve exact identities and SQLite snapshot boundaries. | -| `test_model_content_parse_cost.py` | `contract` | Memoize parsing only; every permission decision still reads current SQLite state. | -| `test_model_content_permissions.py` | `contract` | Content permission tests use only a temporary database and synthetic sources. | -| `test_model_content_preview_races.py` | `contract` | Confirmation must tolerate append-only import without broadening consent. | -| `test_model_content_routes.py` | `integration` | 覆盖 permission route reads and writes only config final state、core composition starts with config permission and durable drain 场景。 | -| `test_model_content_scope_seals.py` | `contract` | Indexed scope invalidation retains complete-snapshot semantics without rereading it. | -| `test_model_content_snapshot_cost.py` | `contract` | Reuse derived fingerprints only after reading every raw row in this transaction. | | `test_model_creation_api.py` | `integration` | Normal protected model creation routes with temporary data and in-memory secrets. | | `test_model_creation_operations.py` | `contract` | 覆盖 lookup without table is read only and legacy create does not initialize、operation commits with profile and version without autoactivation、any replay conflicts before secret put 等 21 个场景。 | | `test_model_creation_validation_boundary.py` | `integration` | Real ASGI validation must precede creation reservation and secret writes. | -| `test_model_policy_activation.py` | `contract` | 覆盖 activation helper is transactional and restores row factory、backlog scan does not hold the single writer、cloud credential is checked without keychain write 等 21 个场景。 | -| `test_model_policy_routes.py` | `contract` | 覆盖 all routes require auth、backlog is required not defaulted、enable and transition safe shape and callback once after commit 等 10 个场景。 | | `test_model_probe.py` | `contract` | 覆盖 probe still measures embedding when structured output fails、probe records the real embedding width on the profile、openai compatible probe checks model and structured output 等 23 个场景。 | | `test_model_probe_input_revision.py` | `contract` | 覆盖 input revision precedes measured width update、concurrent update still rejects width or capability cas、early returns record the actual starting revision 等 6 个场景。 | | `test_model_probe_operations.py` | `integration` | Persisted probe identity, real SQLite/API and synthetic transport only. | -| `test_model_processing_permissions.py` | `contract` | 覆盖 lease reads current allowed before each boundary、lease refuses a task profile that is no longer active 场景。 | | `test_model_profile_api.py` | `integration` | 覆盖 model profile api never returns key and enforces revision、model profile probe route returns only machine results、model profile validation never echoes api key 等 4 个场景。 | | `test_model_profile_context_reset.py` | `integration` | 覆盖 create context default and inclusive bounds、explicit null clears override and preserves history and secret、omitted or same value is noop with unchanged revision and metadata 等 11 个场景。 | | `test_model_profile_identity.py` | `contract` | Stable model editing and configured-vs-available credentials; no Keychain/network. | | `test_model_profile_queue_handover.py` | `contract` | Queued work when the model changes or its configuration is deleted (D75). | | `test_model_profiles.py` | `contract` | 覆盖 profile uses one model and keeps key out of database、api key updates reuse one stable secret reference、startup cleanup removes only secrets not referenced by current profiles 等 15 个场景。 | | `test_model_request_budget.py` | `contract` | 覆盖 persists failed request reservations without content、parallel leases cannot exceed request limit、unknown or excessive output and body are denied 等 6 个场景。 | -| `test_model_request_budget_integration.py` | `contract` | Real HY leaf retries plus durable budget accounting; no network or provider. | -| `test_model_scope_lifecycle.py` | `contract` | Bound derived/temporary data without changing consent or original history. | | `test_mysql_lifecycle.py` | `contract` | A1-07: the decisions Runtime makes around the bundled MySQL. | | `test_mysql_platform.py` | `contract` | A1-07: the machine-facing half of managing the bundled MySQL. | | `test_obs03_health_probe.py` | `contract` | OBS-03: '/health' answered with the web app instead of a health payload. | @@ -299,14 +259,11 @@ | `test_oversized_file_guard.py` | `contract` | One pass over a source file is bounded by a byte budget, not skipped. | | `test_parent_watchdog.py` | `integration` | A Core that outlives its launcher makes the next launch impossible. | | `test_policy_api_wiring.py` | `integration` | 覆盖 global model permission off does not backfill、backfill puts history on write jobs without scheduling memory tasks、source pause skips history until the source is reenabled 场景。 | -| `test_policy_operation_lookup.py` | `contract` | 覆盖 corrupt current row cannot make history or missing operation look safe、current operation uses safe receipt without callback or writes、lookup connection rejects writes and preserves database 等 12 个场景。 | | `test_policy_production_entry.py` | `contract` | 覆盖 production serve passes only supported app options、maintenance serve does not own the runtime 场景。 | | `test_precipitation_concurrency_setting.py` | `contract` | Rule 2/9: precipitation concurrency is configurable, not hardcoded to 1. | | `test_primary_source_adapters.py` | `contract` | 覆盖 pipeline json is host generated even when truncated、workbuddy record parsing never blocks the event loop、cursor store user info envelope is not attributed to the person 等 34 个场景。 | | `test_probe_code_contract_parity.py` | `contract` | 探测码在三处必须同时存在:python 枚举、TS 契约、界面文案表。 | | `test_probe_payment_required.py` | `contract` | 覆盖 http 402 without a useful body is still understood as unpaid、a wrong address is still a missing model not an unpaid one 场景。 | -| `test_processing_coordinator.py` | `contract` | 覆盖 monitor is retained and observes config plus tasks 场景。 | -| `test_processing_merged_context.py` | `contract` | 覆盖 new chunk omits known memory with changed support but keeps original history 场景。 | | `test_processing_requests.py` | `contract` | 覆盖 concurrent claim only one snapshot wins、concurrent create same grant has one id and profile、concurrent different profile never reuses another scope 等 14 个场景。 | | `test_quality_acceptance.py` | `contract` | 覆盖 acceptance pack is deterministic and scores personal boundary、acceptance score fails missing personal memory 场景。 | | `test_raw_search_without_model.py` | `integration` | Search imported conversations with no generative model configured at all. | @@ -375,8 +332,6 @@ | `test_session_inbox.py` | `contract` | A session inbox is not a shared mailbox and a pull is not adoption. | | `test_session_lease.py` | `contract` | A session file on disk is not proof that the session is online. | | `test_session_registry.py` | `contract` | Trusted session identity is not a workspace path. | -| `test_settings_policy_lifecycle.py` | `contract` | Settings data-plane lifecycle: real temporary Core, fake probe/HY, no UI or package. | -| `test_simplified_model_permission_migrations.py` | `contract` | 覆盖 fresh schema omits removed permission state machines 场景。 | | `test_snapshot_adapter.py` | `contract` | Selection-only binding reopens checksum-verified frozen copies. | | `test_snapshot_pool.py` | `contract` | A whole-directory snapshot must not hold a thread everything else is waiting for. | | `test_soak_probe_effectiveness.py` | `integration` | The soak probe must observe the load it reports on. | @@ -392,7 +347,7 @@ | `test_source_parsing.py` | `contract` | 覆盖 reminders are removed only at message edges、original supported outer context and query envelopes、attributed reminder is opaque before finding the outer query 等 11 个场景。 | | `test_source_permission_races.py` | `integration` | Permission execution regressions using temporary stores and Core ASGI instances. | | `test_source_query_preview.py` | `contract` | 覆盖 preserves exact user text and span、supported outer wrapper is stripped without paraphrasing、cursor host prefixes are not shown as the user query 等 8 个场景。 | -| `test_source_read_revocation.py` | `integration` | 覆盖 source revocation stops every outward read but keeps other sources、revocation during model wait prevents late answer 场景。 | +| `test_source_read_revocation.py` | `contract` | 覆盖 revocation during model wait prevents late answer、source revocation stops every outward read but keeps other sources 场景。 | | `test_source_readers.py` | `contract` | 覆盖 jsonl is resumable and does not advance over an incomplete tail、sqlite reader uses monotonic row checkpoint、zstd jsonl reader resumes by completed record 等 7 个场景。 | | `test_source_registry_and_grants.py` | `contract` | 覆盖 catalog resolves defaults and marks environment overrides、recall hosts that can append join the live sync poller、declarations with no reader are named rather than mistaken for behaviour 等 15 个场景。 | | `test_source_revocation_parking.py` | `integration` | Revoking a source parks its unfinished extraction instead of failing it (D73). | @@ -402,7 +357,7 @@ | `test_source_sync_idempotent.py` | `integration` | Final-state Harness sync is idempotent and resumes missed source work. | | `test_source_sync_permissions.py` | `integration` | S1.3 source permission tests use temporary files only, never real host history. | | `test_source_totals.py` | `contract` | 覆盖 the first ask is answered from the rows、same named sessions in two workspaces count as two conversations、insert during count is not marked as already counted 等 7 个场景。 | -| `test_source_upload_intents.py` | `integration` | Upload identity binds retries without replacing file receipts or directory consent. | +| `test_source_upload_intents.py` | `contract` | Upload identity binds retries without replacing file receipts or directory consent. | | `test_source_write_switch.py` | `contract` | A frozen Harness produces no extraction tasks. | | `test_sql_compat.py` | `contract` | The shared compatibility layer, especially 'rowcount'. | | `test_sqlite_semantics_gate.py` | `contract` | D56: prevent SQLite implementation details spreading outside the data layer. | @@ -411,13 +366,10 @@ | `test_store_binding.py` | `contract` | A2: which store is in force, and honest reporting of it. | | `test_store_binding_ratchet.py` | `contract` | D56: ratchet the retired dual-engine 'store_for' seam out of the Core. | | `test_store_port.py` | `contract` | A1-04/A2: the store port, and that real implementations satisfy it. | -| `test_subagent_sessions.py` | `contract` | D98: a sub agent's transcript is its own conversation, not part of the main one. | -| `test_subagent_turns.py` | `contract` | D98: a sub agent's turn is ordinary user-assistant data. | | `test_subscription_visibility.py` | `unit` | A shared directory is not a read grant. | | `test_sync_facade.py` | `contract` | A2: the sync facade must keep the sync shape call sites already use. | | `test_tail_metadata_lane.py` | `contract` | Post-response metadata retries must be bounded and never block the route. | | `test_terminal_payload_reaping.py` | `contract` | Rule 14: dead-end payloads are actually reaped, not just reapable. | -| `test_tool_evidence_hy_turns.py` | `contract` | Every adapter's tool records reach HY inside the turn they happened in. | | `test_two_level_discovery.py` | `contract` | 3925a kernel item 1: two-level discovery. | | `test_ui_session.py` | `contract` | 覆盖 exchange is single use and creates a session、expired exchange cannot create a session、exchange is bound to the core instance 等 5 个场景。 | | `test_ui_session_api.py` | `integration` | 覆盖 ui session exchange sets httponly strict cookie、ui session requires bearer for exchange、ui session reports missing web assets 场景。 | @@ -429,8 +381,8 @@ | `test_windows_process_control.py` | `contract` | R-29:Windows 上 'os.kill' 不是发信号,是无条件杀进程。 | | `test_workbuddy_boundary.py` | `contract` | WorkBuddy only injects on SessionStart and UserPromptSubmit. | | `test_workbuddy_parsing.py` | `contract` | 覆盖 automation preserves query without claiming fresh human authorship、automation context attributes do not depend on order or quote style、quoted full automation envelope is not scheduled provenance 等 17 个场景。 | -| `test_workbuddy_prompt_backfill.py` | `integration` | When the prompt hook's capture fails, the transcript file is what brings the words in. | +| `test_workbuddy_prompt_backfill.py` | `contract` | When the prompt hook's capture fails, the transcript file is what brings the words 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. | -共 428 个测试文件。 +共 380 个测试文件。 diff --git a/memhub/apps/core/tests/core_test_classification.py b/memhub/apps/core/tests/core_test_classification.py index 53a6a11..e2272ad 100644 --- a/memhub/apps/core/tests/core_test_classification.py +++ b/memhub/apps/core/tests/core_test_classification.py @@ -14,8 +14,8 @@ from pathlib import Path import pytest PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live") -EXPECTED_TEST_FILE_COUNT = 428 -EXPECTED_TEST_FILES_SHA256 = "bd3bf4fc9cda2a8e4427d106283c40d22dad026fd452db954046b6f28d21b896" +EXPECTED_TEST_FILE_COUNT = 380 +EXPECTED_TEST_FILES_SHA256 = "9f075d18403328882c25325d29ebe77d0e3365d675ba19d56134a6a0fb9b64b4" # Pure transformations, validation models, parity checks and state machines. # This list is intentionally small: uncertain files default to contract. @@ -68,7 +68,6 @@ INTEGRATION_FILES = frozenset( "test_external_installation_permissions.py", "test_fault_matrix.py", "test_file_import_operations.py", - "test_four_app_live_ingest.py", "test_graph_width_endpoint.py", "test_home_summary.py", "test_hy_writeback.py", @@ -100,7 +99,6 @@ INTEGRATION_FILES = frozenset( "test_memory_supersede.py", "test_memory_visibility.py", "test_memory_write_routes.py", - "test_model_content_routes.py", "test_model_creation_api.py", "test_model_creation_validation_boundary.py", "test_model_probe_operations.py", @@ -124,17 +122,14 @@ INTEGRATION_FILES = frozenset( "test_source_connection_intents.py", "test_source_import_receipts.py", "test_source_permission_races.py", - "test_source_read_revocation.py", "test_source_revocation_parking.py", "test_source_session_count_api.py", "test_source_status_latency.py", "test_source_sync_idempotent.py", "test_source_sync_permissions.py", - "test_source_upload_intents.py", "test_ui_session_api.py", "test_unified_write_entry_wiring.py", "test_visibility_background.py", - "test_workbuddy_prompt_backfill.py", } ) diff --git a/memhub/apps/core/tests/source_consent.py b/memhub/apps/core/tests/source_consent.py index 1b647f0..1e6b138 100644 --- a/memhub/apps/core/tests/source_consent.py +++ b/memhub/apps/core/tests/source_consent.py @@ -26,67 +26,6 @@ async def thaw_source(client, source_id, receipt): return response.json() -async def confirm_model_content( - client, - *, - source_ids, - evidence_ids=(), - all_imported=None, - future_additions=True, - process=True, -): - """Grant model processing the same way the product does: preview, then confirm. - - Live suites need this because importing Evidence is not consent to send it - to a model; without it the processing guard refuses, which is the correct - product behaviour and not something a test may bypass by writing the store. - """ - profiles = await client.get("/api/v1/model-profiles") - profiles.raise_for_status() - active = next(item for item in profiles.json() if item["is_active"]) - preview = await client.post( - "/api/v1/model-content-permission/preview", - json={ - "profile_id": active["profile_id"], - "profile_revision": active["revision"], - "source_ids": list(source_ids), - "evidence_ids": list(evidence_ids), - # The manual source only accepts named originals, so "everything - # from these sources" is not a default the caller can ignore. - "all_imported_from_selected_sources": ( - not evidence_ids if all_imported is None else all_imported - ), - "include_future_additions": future_additions, - }, - ) - # Report the refusal code, not just the status: every content refusal is a - # named product decision and a live run that hides it cannot be diagnosed. - assert preview.status_code == 200, preview.text - current = await client.get("/api/v1/model-content-permission") - current.raise_for_status() - existing = current.json() - confirmed = await client.post( - "/api/v1/model-content-permission", - json={ - "snapshot": preview.json(), - "expected_revision": existing["revision"] if existing else 0, - "user_confirmed": True, - }, - ) - confirmed.raise_for_status() - receipt = confirmed.json() - if process: - # What the product does after the user confirms: take the already - # imported Evidence through the pipeline instead of waiting for the - # next import to trigger a debounce. - accepted = await client.post( - "/api/v1/model-content-permission/process", - json={"expected_revision": receipt["revision"], "user_confirmed": True}, - ) - accepted.raise_for_status() - return receipt - - async def connection_request(client, source_ids, **options): roots = {} for source_id in source_ids: diff --git a/memhub/apps/core/tests/test_architecture_layer_gate.py b/memhub/apps/core/tests/test_architecture_layer_gate.py index 001da44..95e5745 100644 --- a/memhub/apps/core/tests/test_architecture_layer_gate.py +++ b/memhub/apps/core/tests/test_architecture_layer_gate.py @@ -65,12 +65,7 @@ HY_BRIDGE = f"{PACKAGE_LABEL}/hy_memory_bridge.py" SDK_CALLER = HY_BRIDGE #: Type-only importers of HY datatypes; they hold no client and write nothing. -HY_TYPE_ONLY = frozenset( - { - f"{PACKAGE_LABEL}/hy_permission_store.py", - f"{PACKAGE_LABEL}/hy_permission_coding_store.py", - } -) +HY_TYPE_ONLY: frozenset[str] = frozenset() def _relative(path: Path) -> str: diff --git a/memhub/apps/core/tests/test_async_dispatch_checks.py b/memhub/apps/core/tests/test_async_dispatch_checks.py deleted file mode 100644 index 998ce9d..0000000 --- a/memhub/apps/core/tests/test_async_dispatch_checks.py +++ /dev/null @@ -1,248 +0,0 @@ -import asyncio -import threading - -import pytest - -from one_memory.model_profiles import ModelProfilePatch -from tests.test_model_processing_permissions import Harness - - -def test_quiet_arrival_guard_is_off_loop_and_5000_events_coalesce(tmp_path, monkeypatch): - h = Harness(tmp_path) - supervisor = h.supervisor() - entered, release = threading.Event(), threading.Event() - checked = [] - original = supervisor._guard_records - - async def scenario(): - loop_thread = threading.get_ident() - - def guard(records, *args, **kwargs): - checked.append(threading.get_ident()) - assert threading.get_ident() != loop_thread, "arrival parsed permission on the loop" - entered.set() - assert release.wait(5) - return original(records, *args, **kwargs) - - def credentials(*_args, **_kwargs): - pytest.fail("denied preparation reached credential/profile resolution") - - monkeypatch.setattr(supervisor, "_guard_records", guard) - monkeypatch.setattr(h.profiles, "active", credentials) - monkeypatch.setattr(h.profiles, "resolve_snapshot_secret", credentials) - try: - for _ in range(5000): - supervisor.enqueue_evidence(h.record) - assert checked == [] - assert len(supervisor._debounces) == 1 - assert await asyncio.to_thread(entered.wait, 3) - # This callback must run while the actual permission reader is blocked. - responsive = asyncio.Event() - asyncio.get_running_loop().call_soon(responsive.set) - await asyncio.wait_for(responsive.wait(), 0.5) - assert checked == [checked[0]] - release.set() - await asyncio.gather(*tuple(supervisor._debounces.values())) - assert supervisor._preparations == {} - assert h.tasks.list() == [] - assert len(supervisor._queue) == 0 and supervisor._workers == {} - assert h.factory_calls == [] and h.bridge.calls == [] - finally: - release.set() - await supervisor.shutdown() - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("stop_mode", ["cancel", "shutdown"]) -def test_live_preparation_cancel_joins_thread_without_late_dispatch( - tmp_path, monkeypatch, stop_mode -): - h = Harness(tmp_path) - h.grant() - supervisor = h.supervisor() - entered, release = threading.Event(), threading.Event() - original = supervisor._prepare_conversation_work - completed = [] - - def blocked(seed, **kwargs): - entered.set() - assert release.wait(5) - result = original(seed, **kwargs) - completed.append(True) - return result - - monkeypatch.setattr(supervisor, "_prepare_conversation_work", blocked) - - async def scenario(): - stopping = None - preparation = None - try: - supervisor.enqueue_evidence(h.record) - assert await asyncio.to_thread(entered.wait, 3) - preparation = next(iter(supervisor._preparations.values())) - if stop_mode == "cancel": - preparation.cancel() - await asyncio.sleep(0.02) - preparation.cancel() - else: - stopping = asyncio.create_task(supervisor.shutdown()) - await asyncio.sleep(0.02) - assert not preparation.done(), "cancel detached the actual preparation thread" - if stopping is not None: - assert not stopping.done() - finally: - release.set() - if preparation is not None: - await asyncio.gather(preparation, return_exceptions=True) - if stopping is not None: - await stopping - await supervisor.shutdown() - assert completed == [True] - assert supervisor._preparations == {} and supervisor._debounces == {} - assert len(supervisor._queue) == 0 and supervisor._workers == {} - assert h.factory_calls == [] and h.bridge.calls == [] - - asyncio.run(scenario()) - - -@pytest.mark.parametrize( - "change", ["model-revoked", "source-revoked", "profile-changed", "regranted-other-input"] -) -def test_quiet_preparation_rechecks_permission_before_credentials_or_durable_work( - tmp_path, monkeypatch, change -): - h = Harness(tmp_path) - receipt = h.grant() - supervisor = h.supervisor() - entered, release = threading.Event(), threading.Event() - original = supervisor._prepare_conversation_work - guarded = [] - original_guard = supervisor._guard_records - - def blocked(seed, **kwargs): - entered.set() - assert release.wait(5) - return original(seed, **kwargs) - - def guard(*args, **kwargs): - guarded.append(True) - return original_guard(*args, **kwargs) - - def credentials(*_args, **_kwargs): - pytest.fail("denied preparation reached credential/profile resolution") - - monkeypatch.setattr(supervisor, "_prepare_conversation_work", blocked) - monkeypatch.setattr(supervisor, "_guard_records", guard) - - async def scenario(): - try: - supervisor.enqueue_evidence(h.record) - assert await asyncio.to_thread(entered.wait, 3) - if change in {"model-revoked", "regranted-other-input"}: - h.permissions.revoke( - user_id="local", expected_revision=receipt.revision, user_confirmed=True - ) - if change == "regranted-other-input": - h.grant((h.put("other", conversation="other"),)) - elif change == "source-revoked": - h.sources.revoke(user_id="local", source_id="cursor") - else: - h.profiles.update( - h.profile.profile_id, - ModelProfilePatch(expected_revision=h.profile.revision, model="changed"), - h.secrets, - ) - monkeypatch.setattr(h.profiles, "active", credentials) - monkeypatch.setattr(h.profiles, "resolve_snapshot_secret", credentials) - release.set() - await asyncio.gather(*tuple(supervisor._debounces.values())) - assert guarded == [True] - assert h.tasks.list() == [] - assert h.factory_calls == [] and h.bridge.calls == [] - assert len(supervisor._queue) == 0 and supervisor._workers == {} - finally: - release.set() - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_cancelling_quiet_timer_does_not_start_any_permission_reader(tmp_path, monkeypatch): - h = Harness(tmp_path) - h.grant() - supervisor = h.supervisor() - checks = [] - original = supervisor._guard_records - - def guard(*args, **kwargs): - checks.append(True) - return original(*args, **kwargs) - - monkeypatch.setattr(supervisor, "_guard_records", guard) - - async def scenario(): - try: - supervisor.enqueue_evidence(h.record) - timer = next(iter(supervisor._debounces.values())) - await asyncio.sleep(0) - timer.cancel() - await asyncio.gather(timer, return_exceptions=True) - assert checks == [] - assert supervisor._debounces == {} and supervisor._preparations == {} - assert h.tasks.list() == [] and h.factory_calls == [] and h.bridge.calls == [] - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_dispatch_permission_check_denies_before_queue_after_classification(tmp_path, monkeypatch): - h = Harness(tmp_path) - h.grant() - supervisor = h.supervisor() - work = supervisor._classify(supervisor.prepare_conversation(h.record)) - checked = [] - - def reject(task): - checked.append(task.task_id) - return False - - async def scenario(): - monkeypatch.setattr(supervisor, "_guard_task", reject) - try: - supervisor._schedule_tasks(work) - assert len(checked) == 1 - assert len(supervisor._queue) == 0 and supervisor._workers == {} - assert h.factory_calls == [] - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_dispatch_check_does_not_queue_work_after_shutdown(tmp_path, monkeypatch): - h = Harness(tmp_path) - h.grant() - supervisor = h.supervisor() - work = supervisor._classify(supervisor.prepare_conversation(h.record)) - checked = [] - - def allowed(task): - checked.append(task.task_id) - return True - - monkeypatch.setattr(supervisor, "_guard_task", allowed) - - async def scenario(): - stopping = asyncio.create_task(supervisor.shutdown()) - try: - await stopping - supervisor._schedule_tasks(work) - finally: - await supervisor.shutdown() - assert len(supervisor._queue) == 0 and supervisor._workers == {} - assert checked == [] - assert h.factory_calls == [] - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_backfill_acceptance_lifecycle.py b/memhub/apps/core/tests/test_backfill_acceptance_lifecycle.py deleted file mode 100644 index 970dfdd..0000000 --- a/memhub/apps/core/tests/test_backfill_acceptance_lifecycle.py +++ /dev/null @@ -1,244 +0,0 @@ -"""A processing acknowledgement belongs to one consent revision and live worker.""" - -import asyncio - -import pytest - -from one_memory.hy_memory_bridge import HyMemoryBridgeError -from tests.test_model_processing_permissions import Harness - - -def test_changed_consent_does_not_acknowledge_an_older_running_request(tmp_path, monkeypatch): - async def scenario(): - h = Harness(tmp_path) - first = h.grant() - supervisor = h.supervisor() - started = asyncio.Event() - release = asyncio.Event() - calls = [] - - async def backfill(*, expected_permission_revision=None, on_first_scheduled=None): - calls.append(expected_permission_revision) - if on_first_scheduled is not None: - on_first_scheduled() - started.set() - await release.wait() - - monkeypatch.setattr(supervisor, "backfill_off_thread", backfill) - try: - await asyncio.wait_for( - supervisor.accept_backfill_off_thread(expected_permission_revision=first.revision), - timeout=5, - ) - await asyncio.wait_for(started.wait(), timeout=5) - second = h.grant() - await asyncio.wait_for( - supervisor.accept_backfill_off_thread(expected_permission_revision=second.revision), - timeout=5, - ) - started.clear() - await asyncio.wait_for(started.wait(), timeout=5) - assert calls == [first.revision, second.revision] - finally: - release.set() - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_late_failure_is_observed_in_processing_status(tmp_path, monkeypatch): - async def scenario(): - h = Harness(tmp_path) - receipt = h.grant() - supervisor = h.supervisor() - release = asyncio.Event() - - async def backfill(*, expected_permission_revision=None, on_first_scheduled=None): - if on_first_scheduled is not None: - on_first_scheduled() - await release.wait() - raise HyMemoryBridgeError("model_content_source_permission_changed") - - monkeypatch.setattr(supervisor, "backfill_off_thread", backfill) - try: - await supervisor.accept_backfill_off_thread( - expected_permission_revision=receipt.revision - ) - assert supervisor.accepted_backfill_status() == ("running", None) - release.set() - await supervisor._live_backfill - assert supervisor.accepted_backfill_status() == ( - "failed", - "model_content_source_permission_changed", - ) - finally: - release.set() - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_same_consent_concurrent_requests_share_one_worker(tmp_path, monkeypatch): - async def scenario(): - h = Harness(tmp_path) - receipt = h.grant() - supervisor = h.supervisor() - entered, acknowledge, finish = asyncio.Event(), asyncio.Event(), asyncio.Event() - calls = [] - - async def backfill(*, expected_permission_revision=None, on_first_scheduled=None): - calls.append(expected_permission_revision) - entered.set() - await acknowledge.wait() - if on_first_scheduled is not None: - on_first_scheduled() - await finish.wait() - - monkeypatch.setattr(supervisor, "backfill_off_thread", backfill) - first = asyncio.create_task( - supervisor.accept_backfill_off_thread(expected_permission_revision=receipt.revision) - ) - try: - await entered.wait() - second = asyncio.create_task( - supervisor.accept_backfill_off_thread(expected_permission_revision=receipt.revision) - ) - await asyncio.sleep(0) - acknowledge.set() - await asyncio.wait_for(asyncio.gather(first, second), 2) - assert calls == [receipt.revision] - finally: - acknowledge.set() - finish.set() - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_background_failure_is_reported_after_acceptance_and_can_retry(tmp_path, monkeypatch): - async def scenario(): - h = Harness(tmp_path) - receipt = h.grant() - supervisor = h.supervisor() - - async def broken(**_kwargs): - raise RuntimeError("secret or provider body must not be returned") - - monkeypatch.setattr(supervisor, "backfill_off_thread", broken) - try: - await supervisor.accept_backfill_off_thread( - expected_permission_revision=receipt.revision - ) - await supervisor._live_backfill - assert supervisor.accepted_backfill_status() == ( - "failed", - "model_content_processing_unavailable", - ) - - async def complete(**kwargs): - callback = kwargs.get("on_first_scheduled") - if callback is not None: - callback() - - monkeypatch.setattr(supervisor, "backfill_off_thread", complete) - await supervisor.accept_backfill_off_thread( - expected_permission_revision=receipt.revision - ) - await supervisor._live_backfill - assert supervisor.accepted_backfill_status() == ("done", None) - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_shutdown_rejects_new_processing_acceptance(tmp_path): - async def scenario(): - h = Harness(tmp_path) - receipt = h.grant() - supervisor = h.supervisor() - await supervisor.shutdown() - with pytest.raises(HyMemoryBridgeError, match="model_content_processing_unavailable"): - await supervisor.accept_backfill_off_thread( - expected_permission_revision=receipt.revision - ) - assert h.tasks.list() == [] - assert h.factory_calls == [] - - asyncio.run(scenario()) - - -def test_health_reports_late_explicit_processing_failure(tmp_path, monkeypatch): - from httpx import ASGITransport, AsyncClient - - from one_memory.api import create_app - from one_memory.config import Settings - - monkeypatch.setattr("one_memory.model_presets._preset_data", lambda: None) - - async def scenario(): - app = create_app(Settings(_env_file=None, data_dir=tmp_path, imports_enabled=False)) - release = asyncio.Event() - async with app.router.lifespan_context(app): - await app.state.memory_backfill - supervisor = app.state.memory_processing_supervisor - supervisor._permission_guard = None - - async def failed_later(**kwargs): - callback = kwargs.get("on_first_scheduled") - if callback is not None: - callback() - await release.wait() - raise HyMemoryBridgeError("model_content_source_permission_changed") - - monkeypatch.setattr(supervisor, "backfill_off_thread", failed_later) - try: - await supervisor.accept_backfill_off_thread() - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: - running = (await client.get("/api/v1/health")).json() - assert running["memory_backfill"] == "running" - release.set() - await supervisor._live_backfill - failed = (await client.get("/api/v1/health")).json() - assert failed["memory_backfill"] == "failed" - assert ( - failed["memory_backfill_error"] == "model_content_source_permission_changed" - ) - finally: - release.set() - - asyncio.run(scenario()) - - -def test_completed_caller_does_not_cancel_accepted_background_work(tmp_path, monkeypatch): - async def scenario(): - h = Harness(tmp_path) - receipt = h.grant() - supervisor = h.supervisor() - entered, release = asyncio.Event(), asyncio.Event() - - async def backfill(**kwargs): - entered.set() - await release.wait() - callback = kwargs.get("on_first_scheduled") - if callback is not None: - callback() - - monkeypatch.setattr(supervisor, "backfill_off_thread", backfill) - caller = asyncio.create_task( - supervisor.accept_backfill_off_thread(expected_permission_revision=receipt.revision) - ) - try: - await asyncio.wait_for(caller, timeout=2) - assert not supervisor._live_backfill.done() - await asyncio.wait_for(entered.wait(), timeout=5) - release.set() - await supervisor._live_backfill - assert supervisor.accepted_backfill_status() == ("done", None) - finally: - release.set() - await supervisor.shutdown() - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_backfill_conversation_isolation.py b/memhub/apps/core/tests/test_backfill_conversation_isolation.py deleted file mode 100644 index d5cf9ef..0000000 --- a/memhub/apps/core/tests/test_backfill_conversation_isolation.py +++ /dev/null @@ -1,54 +0,0 @@ -"""One stale conversation cannot invalidate an entire backfill scan.""" - -import asyncio -from contextlib import suppress - -from one_memory.hy_memory_bridge import HyMemoryBridgeError -from one_memory.imports.models import EvidenceInput -from tests.test_continuous_processing_integration import IntegrationCore - - -def test_one_changed_conversation_does_not_block_other_conversations(tmp_path, monkeypatch): - core = IntegrationCore(tmp_path, monkeypatch) - - async def scenario(): - async with core.running(automatic_memory_processing=False): - monkeypatch.setenv("ONE_MEMORY_DIAGNOSTICS", "1") - core.grant_source() - core.put_complete("conversation-good", conversation="good") - changed = core.put_complete("conversation-changed", conversation="changed") - await core.save_and_probe() - core.kernel_enable("include") - - evidence_store = core.app.state.evidence_store - changed_input = EvidenceInput.model_validate( - { - **changed.model_dump(include=set(EvidenceInput.model_fields)), - "verbatim": changed.verbatim + " edited", - } - ) - evidence_store.put(changed_input) - assert evidence_store.get(changed.evidence_id).revision > changed.revision - - # Production reaches completed-input reuse with the live chunk when - # an earlier task already covered it; that reuse rechecks the - # stored input against the row now in Evidence. Model the observed - # stale-row refusal at the exact boundary that raises in production. - guard = core.app.state.memory_processing_supervisor._permission_guard - original_completed_lookup = guard.completed_input_task - - def refuse_changed_conversation(receipt, records, **kwargs): - if any(record.conversation_id == "changed" for record in records): - raise HyMemoryBridgeError("model_content_evidence_changed") - return original_completed_lookup(receipt, records, **kwargs) - - monkeypatch.setattr(guard, "completed_input_task", refuse_changed_conversation) - - supervisor = core.app.state.memory_processing_supervisor - with suppress(Exception): - await supervisor.backfill_off_thread() - - await core.wait_completed(timeout=10, minimum_tasks=1) - assert len(core.bridge.requests) == 1 - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_c1_preview_scope_diagnostic.py b/memhub/apps/core/tests/test_c1_preview_scope_diagnostic.py index e5fc42b..e9121b2 100644 --- a/memhub/apps/core/tests/test_c1_preview_scope_diagnostic.py +++ b/memhub/apps/core/tests/test_c1_preview_scope_diagnostic.py @@ -1,9 +1,8 @@ """Diagnostic: why does an all-imported preview see zero Evidence? -The C0 integration red asserts a preview snapshot has two Evidence rows after a -real WorkBuddy import. Reproduces the exact query path from -`ModelContentPermissionStore.snapshot_from_ids` and reports what the import -actually wrote, so the owner can be named from evidence instead of inference. +The C0 integration red asserted a preview snapshot had two Evidence rows after +a real WorkBuddy import. This keeps the useful import-path diagnostic after +the retired model-content preview layer was removed. """ import asyncio diff --git a/memhub/apps/core/tests/test_compact_model_content.py b/memhub/apps/core/tests/test_compact_model_content.py deleted file mode 100644 index 277442c..0000000 --- a/memhub/apps/core/tests/test_compact_model_content.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Server-held previews preserve exact grants without round-tripping all refs.""" - -import json -import sqlite3 - -import pytest - -from one_memory.model_content_permissions import ( - ModelContentPermissionError, - ModelContentPermissionStore, -) -from tests.test_model_content_bulk import many_records -from tests.test_model_content_permissions import USER, evidence_value -from tests.test_model_content_permissions import env as env - - -def test_compact_preview_grants_exact_scope_and_survives_restart(env): - records = many_records(env, 1000) - snapshot = env.snapshot(records=tuple(records), future=True) - result = env.permissions.retain_preview(snapshot) - assert result["evidence"] == [] and result["evidence_count"] == 1000 - assert len(json.dumps(result)) < 5000 - assert env.permissions.receipt(user_id=USER) is None - restarted = ModelContentPermissionStore(env.database) - receipt = restarted.grant_preview( - result["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True - ) - assert receipt.snapshot == snapshot - assert env.check(receipt) == receipt - with pytest.raises(ModelContentPermissionError, match="permission_changed"): - restarted.grant_preview( - result["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True - ) - - -@pytest.mark.parametrize( - "change", ["edit", "revoke-source", "grant-race", "expiry", "wrong-owner", "confirmation"] -) -def test_compact_preview_does_not_bypass_live_checks(env, change): - result = env.permissions.retain_preview(env.snapshot(future=True)) - if change == "edit": - with sqlite3.connect(env.database) as db: - db.execute("UPDATE evidence SET verbatim='changed raw original'") - elif change == "revoke-source": - env.sources.revoke(user_id=USER, source_id=env.record.source_id) - elif change == "grant-race": - env.grant() - elif change == "expiry": - with sqlite3.connect(env.database) as db: - db.execute("UPDATE model_content_previews SET expires_at='2000-01-01T00:00:00+00:00'") - with pytest.raises(ModelContentPermissionError): - env.permissions.grant_preview( - result["preview_id"], - user_id="other" if change == "wrong-owner" else USER, - expected_revision=0, - user_confirmed=change != "confirmation", - ) - - -def test_new_originals_keep_explicit_future_scope_but_not_exact_scope(env): - result = env.permissions.retain_preview(env.snapshot(future=True)) - later = env.evidence.put(evidence_value("new-arrival")).record - receipt = env.permissions.grant_preview( - result["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True - ) - assert len(receipt.snapshot.evidence) == 1 - assert env.check(receipt, records=(later,)) == receipt - - -def test_compact_receipt_is_metadata_only_and_version_bound(env): - result = env.permissions.retain_preview(env.snapshot()) - receipt = env.permissions.grant_preview( - result["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True - ) - compact = env.permissions.compact_receipt(receipt) - assert compact["revision"] == receipt.revision - assert compact["snapshot"]["snapshot_hash"] == result["snapshot_hash"] - assert compact["snapshot"]["source_counts"] == {env.record.source_id: 1} - assert env.record.verbatim not in json.dumps(compact) - assert compact["snapshot"]["evidence"] == [] - - -@pytest.mark.parametrize("future", [False, True]) -def test_preview_cannot_rebase_over_a_grant_or_revoke_between_reads(env, future): - first = env.grant(future=future) - preview = env.snapshot(future=future) - env.permissions.revoke(user_id=USER, expected_revision=first.revision, user_confirmed=True) - with pytest.raises(ModelContentPermissionError, match="permission_changed"): - env.permissions.retain_preview(preview) - assert env.permissions.receipt(user_id=USER).revoked_at is not None - - -def test_shared_migration_declares_the_retained_preview_table() -> None: - """Retained previews are product state; migration 28 must recreate them. - - This is a red-state guard for the A1-04 schema extraction. The table exists - in the historical f6d624a schema and is still read/written by this module, - so migration 28's generator must declare it for both SQLite and MySQL. - """ - from one_memory.memory_server import migrations - - sqlite_schema = migrations.model_content_permissions_ddl("sqlite") - mysql_schema = migrations.model_content_permissions_ddl("mysql") - assert "model_content_previews" in sqlite_schema - assert "model_content_previews" in mysql_schema diff --git a/memhub/apps/core/tests/test_connection_lifetime.py b/memhub/apps/core/tests/test_connection_lifetime.py index c993052..1356920 100644 --- a/memhub/apps/core/tests/test_connection_lifetime.py +++ b/memhub/apps/core/tests/test_connection_lifetime.py @@ -12,15 +12,12 @@ import asyncio import gc import sqlite3 from contextlib import contextmanager -from types import SimpleNamespace from httpx import ASGITransport, AsyncClient from pydantic import SecretStr from one_memory.api import create_app from one_memory.config import Settings -from one_memory.database_access import close_main_databases, main_database_stats -from one_memory.hy_permission_context import _NativeProofs from one_memory.imports.sources.registry import build_source_catalog @@ -81,24 +78,3 @@ def test_polled_endpoints_close_every_connection_they_open(tmp_path, monkeypatch assert left_open == 0, f"{left_open} connections left for the collector" asyncio.run(scenario()) - - -def test_hy_permission_proofs_close_their_connections(tmp_path): - database = tmp_path / "one-memory.sqlite3" - with sqlite3.connect(database) as db: - db.execute("CREATE TABLE governed_memories (memory_id TEXT, revision INT, status TEXT)") - db.close() - proofs = _NativeProofs(database) - lease = SimpleNamespace(records=()) - # The first read opens the pooled reader; it is meant to stay open. - proofs.guard(proofs.load(lease))() - - with _without_cyclic_collection(): - before = _open_connections() - for _ in range(5): - check = proofs.guard(proofs.load(lease)) - check() - left_open = _open_connections() - before - assert left_open == 0, f"{left_open} connections left for the collector" - close_main_databases() - assert main_database_stats()["open_connections"] == 0 diff --git a/memhub/apps/core/tests/test_continuous_completed_input_review.py b/memhub/apps/core/tests/test_continuous_completed_input_review.py deleted file mode 100644 index 61c5777..0000000 --- a/memhub/apps/core/tests/test_continuous_completed_input_review.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Independent review of completed-input reuse; real stores and controlled HY only.""" - -import asyncio -import sqlite3 -from contextlib import closing -from uuid import uuid4 - -import pytest - -from one_memory import continuous_policy -from one_memory.continuous_permission_bridge import ContinuousPermissionReceipt -from one_memory.hy_memory_bridge import HyMemoryBridgeError -from one_memory.imports.models import EvidenceInput -from one_memory.imports.sources.registry import RootCandidateKind -from one_memory.memory_processing import MEMORY_INGEST_POLICY_VERSION -from one_memory.memory_tasks import MemoryTaskStatus -from tests.test_continuous_processing_integration import IntegrationCore - - -def transition(core, action): - with closing(sqlite3.connect(core.database)) as db, db: - db.execute("BEGIN IMMEDIATE") - policy = continuous_policy.current(db) - return continuous_policy.transition( - db, - action=action, - expected_revision=policy.revision, - operation_id=str(uuid4()), - user_confirmed=True, - ) - - -def bindings(core): - with closing(sqlite3.connect(core.database)) as db: - return db.execute( - "SELECT task_id,policy_revision,policy_epoch,receipt_json " - "FROM memory_task_continuous_permissions ORDER BY task_id" - ).fetchall() - - -async def completed(core): - core.grant_source() - await core.save_and_probe() - core.kernel_enable("none") - record = core.put_complete("review-completed", conversation="review") - tasks = await core.run_prepared(record) - assert len(tasks) == 1 and tasks[0].status == MemoryTaskStatus.COMPLETED - assert len(core.bridge.requests) == 1 - return record, tasks[0] - - -def frozen(core, records): - guard = core.app.state.memory_processing_supervisor._permission_guard - receipt = guard.freeze(guard.current("local"), records) - assert isinstance(receipt, ContinuousPermissionReceipt) - return guard, receipt - - -def lookup(guard, receipt, records, seed): - return guard.completed_input_task( - receipt, - records, - input_ref=seed.evidence_id, - request_prefix=f"conversation:{MEMORY_INGEST_POLICY_VERSION}:{seed.source_id}:", - ) - - -def test_resume_reuses_completed_without_rebinding_historical_receipt(tmp_path, monkeypatch): - core = IntegrationCore(tmp_path, monkeypatch) - - async def scenario(): - async with core.running(): - record, task = await completed(core) - before = bindings(core) - transition(core, "pause") - resumed = transition(core, "resume") - supervisor = core.app.state.memory_processing_supervisor - profile = core.app.state.model_profile_store.active(core.secrets) - - def cannot_rebind(*args, **kwargs): - pytest.fail("completed history was rebound to a new execution receipt") - - with monkeypatch.context() as patch: - patch.setattr(supervisor._permission_guard, "bind", cannot_rebind) - reused = supervisor._enqueue_chunk(core.turn_records(record), profile) - assert reused.task_id == task.task_id - assert reused.status == MemoryTaskStatus.COMPLETED - assert resumed.revision == 3 and before[0][1] == 1 - assert bindings(core) == before - assert supervisor._classify([reused]) == [] - assert len(core.bridge.requests) == 1 - assert len(core.app.state.memory_task_store.list()) == 1 - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("change", ["chunk", "event-revision", "profile"]) -def test_changed_input_identity_is_not_reused_as_completed(tmp_path, monkeypatch, change): - core = IntegrationCore(tmp_path, monkeypatch) - - async def scenario(): - async with core.running(): - record, task = await completed(core) - before = bindings(core) - records = core.turn_records(record) - if change == "chunk": - other = core.put("review-added", conversation="review") - # Same input_ref deliberately: input_ref alone cannot prove chunk identity. - records = (other, *records) - elif change == "event-revision": - changed = EvidenceInput.model_validate( - { - **record.model_dump(include=set(EvidenceInput.model_fields)), - "verbatim": record.verbatim + " changed", - } - ) - record = core.app.state.evidence_store.put(changed).record - assert record.revision > 1 - records = (record, *records[1:]) - elif change == "profile": - transition(core, "revoke") - # Persist a new profile through the real API; probe remains controlled. - saved = await core.client.post( - "/api/v1/model-profiles", - json={ - "name": "review-second-profile", - "provider_kind": "openai_compatible", - "base_url": "https://integration.invalid/v1", - "model": "integration-model", - "api_key": "synthetic-key-not-a-credential", - "embedding_model": "integration-vector", - "embedding_provider_kind": "openai_compatible", - "embedding_base_url": "https://integration.invalid/v1", - }, - ) - assert saved.status_code == 200 - result = await core.client.post( - f"/api/v1/model-profiles/{saved.json()['profile_id']}/probe" - ) - assert result.status_code == 200 - core.profile = next( - p - for p in (await core.client.get("/api/v1/model-profiles")).json() - if p["profile_id"] == saved.json()["profile_id"] - ) - current = core.policy_current() - core.app.state.model_policy_activation.enable( - operation_id=str(uuid4()), - profile_id=core.profile["profile_id"], - profile_revision=core.profile["revision"], - expected_policy_revision=current.revision, - expected_legacy_revision=current.legacy_revision, - backlog_mode="include", - user_confirmed=True, - ) - guard, receipt = frozen(core, records) - assert lookup(guard, receipt, records, record) is None - assert bindings(core) == before - assert ( - core.app.state.memory_task_store.require(task.task_id).status - == MemoryTaskStatus.COMPLETED - ) - assert len(core.bridge.requests) == 1 - - asyncio.run(scenario()) - - -def test_changed_source_root_is_not_reused_as_completed(tmp_path, monkeypatch): - core = IntegrationCore(tmp_path, monkeypatch) - - async def scenario(): - async with core.running(): - record, task = await completed(core) - before = bindings(core) - grants = core.app.state.source_grant_store - grants.revoke(user_id="local", source_id="cursor") - replacement = core.home / "cursor-replacement" - replacement.mkdir() - grants.grant( - user_id="local", - source_id="cursor", - root=replacement, - candidate_kind=RootCandidateKind.DEFAULT, - ) - records = core.turn_records(record) - guard, receipt = frozen(core, records) - - assert lookup(guard, receipt, records, record) is None - assert bindings(core) == before - assert ( - core.app.state.memory_task_store.require(task.task_id).status - == MemoryTaskStatus.COMPLETED - ) - assert len(core.bridge.requests) == 1 - - asyncio.run(scenario()) - - -def test_reenabled_policy_reuses_exact_completed_input_without_duplicate_hy_call( - tmp_path, monkeypatch -): - core = IntegrationCore(tmp_path, monkeypatch) - - async def scenario(): - async with core.running(): - record, task = await completed(core) - before = bindings(core) - transition(core, "revoke") - prior = core.policy_current() - core.app.state.model_policy_activation.enable( - operation_id=str(uuid4()), - profile_id=core.profile["profile_id"], - profile_revision=core.profile["revision"], - expected_policy_revision=prior.revision, - expected_legacy_revision=prior.legacy_revision, - backlog_mode="include", - user_confirmed=True, - ) - assert core.policy_current().epoch != before[0][2] - - supervisor = core.app.state.memory_processing_supervisor - profile = core.app.state.model_profile_store.active(core.secrets) - reused = supervisor._enqueue_chunk(core.turn_records(record), profile) - - assert reused.task_id == task.task_id - assert reused.status == MemoryTaskStatus.COMPLETED - assert bindings(core) == before - assert len(core.bridge.requests) == 1 - assert len(core.app.state.memory_task_store.list()) == 1 - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("when", ["before", "after-first-check"]) -@pytest.mark.parametrize("authority", ["source", "policy"]) -def test_current_source_permission_is_rechecked_around_completed_lookup( - tmp_path, monkeypatch, when, authority -): - core = IntegrationCore(tmp_path, monkeypatch) - - async def scenario(): - async with core.running(): - record, _ = await completed(core) - before = bindings(core) - records = core.turn_records(record) - guard, receipt = frozen(core, records) - - def revoke(): - if authority == "source": - core.app.state.source_grant_store.revoke(user_id="local", source_id="cursor") - else: - transition(core, "pause") - - calls = [] - original = guard.check - - def checked(*args, **kwargs): - value = original(*args, **kwargs) - calls.append(True) - if len(calls) == 1: - revoke() - return value - - if when == "before": - revoke() - else: - monkeypatch.setattr(guard, "check", checked) - with pytest.raises(HyMemoryBridgeError): - lookup(guard, receipt, records, record) - assert bindings(core) == before - assert len(core.bridge.requests) == 1 - if when == "after-first-check": - assert calls == [True] - - asyncio.run(scenario()) - - -def test_corrupt_historical_binding_is_not_accepted_as_completion(tmp_path, monkeypatch): - core = IntegrationCore(tmp_path, monkeypatch) - - async def scenario(): - async with core.running(): - record, task = await completed(core) - with sqlite3.connect(core.database) as db: - db.execute( - "UPDATE memory_task_continuous_permissions " - "SET policy_revision=99 WHERE task_id=?", - (task.task_id,), - ) - records = core.turn_records(record) - guard, receipt = frozen(core, records) - with pytest.raises(HyMemoryBridgeError, match="continuous_binding_invalid"): - lookup(guard, receipt, records, record) - assert len(core.bridge.requests) == 1 - - asyncio.run(scenario()) - - -def test_sdk_admitted_unknown_is_not_reused_or_resent_on_resume(tmp_path, monkeypatch): - core = IntegrationCore(tmp_path, monkeypatch) - - async def scenario(): - async with core.running(): - core.grant_source() - await core.save_and_probe() - core.kernel_enable() - record = core.put_complete("review-unknown") - supervisor = core.app.state.memory_processing_supervisor - tasks = supervisor.prepare_conversation(record) - entered = asyncio.Event() - - async def interrupted(request): - core.bridge.requests.append(request) - entered.set() - await asyncio.Event().wait() - - monkeypatch.setattr(core.bridge, "ingest", interrupted) - running = asyncio.create_task(supervisor._create_runner().run(tasks[0].task_id)) - try: - await asyncio.wait_for(entered.wait(), 3) - finally: - running.cancel() - with pytest.raises(asyncio.CancelledError): - await running - # Actual restart recovery marks a previously admitted RUNNING task unknown. - core.app.state.memory_task_store.recover_running() - before = bindings(core) - transition(core, "pause") - transition(core, "resume") - with pytest.raises(HyMemoryBridgeError, match="outcome_unknown"): - supervisor._enqueue_chunk( - core.turn_records(record), - core.app.state.model_profile_store.active(core.secrets), - ) - assert bindings(core) == before - assert len(core.bridge.requests) == 1 - task = core.app.state.memory_task_store.require(tasks[0].task_id) - assert task.last_error_code == "model_content_outcome_unknown" and task.attempts == 1 - assert len(core.app.state.memory_task_store.list()) == 1 - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_continuous_coordinator.py b/memhub/apps/core/tests/test_continuous_coordinator.py deleted file mode 100644 index a7eff42..0000000 --- a/memhub/apps/core/tests/test_continuous_coordinator.py +++ /dev/null @@ -1,190 +0,0 @@ -import asyncio - -import pytest - -from one_memory.hy_memory_bridge import HyMemoryBridgeError -from one_memory.processing_coordinator import ProcessingCoordinator -from one_memory.processing_requests import ProcessingRequestStore -from tests.test_continuous_processing_bridge import enable, put_after_policy, transition -from tests.test_model_processing_permissions import h as h # noqa: F401 - - -def coordinator(h, monkeypatch): - supervisor = h.supervisor() - scheduled = [] - - async def schedule(work): - scheduled.extend(work) - - monkeypatch.setattr(supervisor, "_schedule_tasks_off_thread", schedule) - return ProcessingCoordinator(ProcessingRequestStore(h.database), supervisor), scheduled - - -def test_restart_replays_lost_callback_into_same_durable_task_queue(h, monkeypatch): - policy = enable(h) - fresh = put_after_policy(h, "committed-before-crash") - owner, scheduled = coordinator(h, monkeypatch) - - async def run(): - try: - await owner._continuous_tick() - assert scheduled - task_ids = {task.task_id for task in h.tasks.list()} - assert len(task_ids) == 1 - assert { - ref["evidence_id"] - for task in h.tasks.list() - for ref in h.tasks.inputs(task.task_id) - } == {fresh.evidence_id} - assert owner.continuous_status()["observed_seq"] > policy.after_seq - await owner._continuous_tick() - assert {task.task_id for task in h.tasks.list()} == task_ids - restarted, _ = coordinator(h, monkeypatch) - try: - await restarted._continuous_tick() - assert {task.task_id for task in h.tasks.list()} == task_ids - finally: - await restarted.shutdown() - await restarted.supervisor.shutdown() - finally: - await owner.shutdown() - await owner.supervisor.shutdown() - - asyncio.run(run()) - - -def test_new_journal_event_and_source_regrant_wake_replay_without_legacy_request(h, monkeypatch): - from one_memory.imports.sources.registry import RootCandidateKind - - enable(h) - owner, scheduled = coordinator(h, monkeypatch) - - async def run(): - try: - await owner._continuous_tick() - assert not scheduled - fresh = put_after_policy(h, "new") - h.sources.revoke(user_id="local", source_id="cursor") - await owner._continuous_tick() - assert not scheduled - h.sources.grant( - user_id="local", - source_id="cursor", - root=h.root / "synthetic-source", - candidate_kind=RootCandidateKind.DEFAULT, - ) - await owner._continuous_tick() - assert scheduled - assert h.tasks.inputs(scheduled[-1].task_id)[0]["evidence_id"] == fresh.evidence_id - with h.evidence._connect() as db: - assert db.execute("SELECT count(*) FROM processing_requests").fetchone()[0] == 0 - finally: - await owner.shutdown() - await owner.supervisor.shutdown() - - asyncio.run(run()) - - -def test_activation_hook_and_startup_recovery_start_single_monitor_then_stop(h, monkeypatch): - policy = enable(h) - put_after_policy(h, "new") - owner, scheduled = coordinator(h, monkeypatch) - - async def run(): - try: - await owner.recover() - worker = owner._continuous_worker - assert worker is not None - await owner.resume_continuous(expected_policy_revision=policy.revision) - assert owner._continuous_worker is worker - for _ in range(100): - if scheduled: - break - await asyncio.sleep(0.01) - assert scheduled - finally: - await owner.shutdown() - await owner.supervisor.shutdown() - assert worker.done() - with pytest.raises(HyMemoryBridgeError): - await owner.resume_continuous(expected_policy_revision=policy.revision) - - asyncio.run(run()) - - -def test_pause_refuses_recovery_without_fallback_and_resume_replays(h, monkeypatch): - h.grant() - enable(h) - fresh = put_after_policy(h, "new") - owner, scheduled = coordinator(h, monkeypatch) - transition(h, "pause") - - async def run(): - try: - await owner.recover() - assert not scheduled and owner._continuous_worker is None - with pytest.raises(HyMemoryBridgeError): - await owner.resume_continuous() - transition(h, "resume") - await owner._continuous_tick() - assert scheduled - assert h.tasks.inputs(scheduled[-1].task_id)[0]["evidence_id"] == fresh.evidence_id - finally: - await owner.shutdown() - await owner.supervisor.shutdown() - - asyncio.run(run()) - - -def test_failed_preparation_does_not_advance_observed_watermark(h, monkeypatch): - enable(h) - put_after_policy(h, "new") - owner, _ = coordinator(h, monkeypatch) - original = owner.supervisor.resume_continuous - - async def fail(**kwargs): - raise RuntimeError("synthetic failed preparation") - - monkeypatch.setattr(owner.supervisor, "resume_continuous", fail) - - async def run(): - try: - with pytest.raises(RuntimeError): - await owner._continuous_tick() - assert owner.continuous_status()["observed_seq"] is None - monkeypatch.setattr(owner.supervisor, "resume_continuous", original) - await owner._continuous_tick() - assert owner.continuous_status()["observed_seq"] is not None - finally: - await owner.shutdown() - await owner.supervisor.shutdown() - - asyncio.run(run()) - - -def test_unchanged_continuous_identity_is_rescanned_every_sixty_seconds(h, monkeypatch): - enable(h) - owner, _scheduled = coordinator(h, monkeypatch) - calls = [] - now = 100.0 - - async def resume(*, expected_policy_revision=None): - calls.append(expected_policy_revision) - - monkeypatch.setattr(owner.supervisor, "resume_continuous", resume) - owner._clock = lambda: now - - async def run(): - nonlocal now - try: - await owner._continuous_tick() - now = 159.9 - await owner._continuous_tick() - now = 160.0 - await owner._continuous_tick() - assert calls == [1, 1] - finally: - await owner.shutdown() - await owner.supervisor.shutdown() - - asyncio.run(run()) diff --git a/memhub/apps/core/tests/test_continuous_outcome_unknown.py b/memhub/apps/core/tests/test_continuous_outcome_unknown.py deleted file mode 100644 index 594b8a3..0000000 --- a/memhub/apps/core/tests/test_continuous_outcome_unknown.py +++ /dev/null @@ -1,176 +0,0 @@ -import asyncio - -import pytest - -from one_memory.hy_memory_bridge import HyMemoryBridgeError -from one_memory.memory_tasks import MemoryTaskOwnershipError, MemoryTaskStatus -from tests.test_continuous_processing_bridge import enable, put_after_policy -from tests.test_model_processing_permissions import h as h # noqa: F401 - - -def test_crashed_continuous_send_is_not_replayed_but_new_event_progresses(h): - enable(h) - fresh = put_after_policy(h, "sent-before-crash") - supervisor = h.supervisor() - task = supervisor.prepare_conversation(fresh)[0] - claimed, _ = h.tasks.claim(task.task_id) - runner = supervisor._create_runner() - lease, request, _ = runner._prepare_permission_lease(claimed) - h.bridge.fail = False - asyncio.run(h.bridge.ingest(request)) # Controlled send returned, process lost its receipt. - assert len(h.bridge.calls) == 1 - assert h.tasks.recover_running() == 1 - recovered = h.tasks.require(task.task_id) - assert recovered.status == MemoryTaskStatus.UNKNOWN - assert recovered.last_error_code == "model_content_outcome_unknown" - with pytest.raises(MemoryTaskOwnershipError): - h.tasks.claim(task.task_id) - assert supervisor.prepare_resume() == [] - assert supervisor.prepare_backfill() == [] - # The continuous-policy filter now excludes an already possibly-sent - # Evidence before task preparation. Either explicit unknown or outside - # policy proves it cannot be blindly replayed. - with pytest.raises(HyMemoryBridgeError, match="outcome_unknown|evidence_outside_policy"): - asyncio.run(supervisor.process_records((fresh,))) - # A new message in the same conversation must not resend the unknown original as context. - newest = put_after_policy(h, "new-after-restart") - tasks = supervisor.prepare_conversation(newest) - assert len(tasks) == 1 and tasks[0].task_id != task.task_id - - async def run(): - try: - result = await supervisor._create_runner().run(tasks[0].task_id) - assert result.status == MemoryTaskStatus.COMPLETED - sent = [ - request - for kind, request in h.bridge.calls - if kind in {"ingest", "ingest_extracted"} - ] - assert len(sent) == 2 - assert sent[-1].evidence_ids == (newest.evidence_id,) - assert all(r.evidence_id != fresh.evidence_id for r in h.leases[-1].records) - assert supervisor.prepare_backfill() == [] - finally: - await supervisor.shutdown() - - asyncio.run(run()) - assert h.tasks.require(task.task_id).last_error_code == "model_content_outcome_unknown" - - -def test_legacy_running_recovery_remains_pending_and_no_continuous_table(h): - legacy = h.legacy_task() - h.tasks.claim(legacy.task_id) - assert h.tasks.recover_running() == 1 - restored = h.tasks.require(legacy.task_id) - assert restored.status == MemoryTaskStatus.PENDING - assert restored.last_error_code == "worker_restarted" - - -def test_a_timed_out_non_continuous_task_also_becomes_unknown(h): - """The timeout reason alone is enough to make a task possibly-sent. - - The continuous-permission record is not the only way HY can have been - reached: `MemoryTaskRunner.run` wraps the HY call in `asyncio.wait_for`, so - a wall-clock timeout can fire after the request was in flight. A - non-continuous task must therefore also become sticky `unknown`, or the - scheduler re-selects it and sends a second copy (HY has no idempotency key). - """ - # A legacy (non-continuous) task: no continuous permission record exists. - task = h.legacy_task() - _claimed, runner_token = h.tasks.claim(task.task_id) - h.tasks.record_hy_invocation(task.task_id, runner_token=runner_token) - assert h.tasks.recover_one_running(task.task_id, reason="hy_memory_timeout") is True - recovered = h.tasks.require(task.task_id) - assert recovered.status == MemoryTaskStatus.UNKNOWN - assert recovered.last_error_code == "model_content_outcome_unknown" - with pytest.raises(MemoryTaskOwnershipError): - h.tasks.claim(task.task_id) - - -def test_claim_without_proven_send_is_still_conservatively_unknown(h): - enable(h) - fresh = put_after_policy(h, "claim-only") - task = h.supervisor().prepare_conversation(fresh)[0] - h.tasks.claim(task.task_id) - h.tasks.recover_running() - recovered = h.tasks.require(task.task_id) - assert recovered.status == MemoryTaskStatus.UNKNOWN - assert recovered.last_error_code == "model_content_outcome_unknown" - assert h.bridge.calls == [] - - -def test_a_timed_out_continuous_task_becomes_unknown_not_pending(h): - """The one-shot recovery must be continuous-aware, like process-start recovery. - - `recover_one_running` is the path a timeout takes. A continuous task may - already have reached HY, so returning it to `pending` would let it be - re-sent and duplicate the memory; it must become sticky `unknown` exactly - as `recover_running` marks it at process start. - """ - enable(h) - fresh = put_after_policy(h, "timed-out-continuous-send") - task = h.supervisor().prepare_conversation(fresh)[0] - _claimed, runner_token = h.tasks.claim(task.task_id) - h.tasks.record_hy_invocation(task.task_id, runner_token=runner_token) - - assert h.tasks.recover_one_running(task.task_id, reason="hy_memory_timeout") is True - - recovered = h.tasks.require(task.task_id) - assert recovered.status == MemoryTaskStatus.UNKNOWN - assert recovered.last_error_code == "model_content_outcome_unknown" - with pytest.raises(MemoryTaskOwnershipError): - h.tasks.claim(task.task_id) - - -def test_restart_preserves_the_original_unknown_ordering_time(h): - """Recovery must not collapse old in-flight work into the restart instant. - - `unknown_jobs()` pages in `(updated_at, job_id)` order. If a process - restart rewrote every recovered task's `updated_at`, a crash with many - in-flight tasks would erase their original request order and make the - inquiry drain start from an arbitrary same-timestamp batch. The recovery - transition changes status/error only; the timestamp remains the durable - moment the row last changed before the process died. - """ - enable(h) - fresh = put_after_policy(h, "restart-order") - task = h.supervisor().prepare_conversation(fresh)[0] - h.tasks.claim(task.task_id) - original = h.tasks.require(task.task_id).updated_at - - assert h.tasks.recover_running() == 1 - - recovered = h.tasks.require(task.task_id) - assert recovered.status is MemoryTaskStatus.UNKNOWN - assert recovered.updated_at == original - - -def test_an_unsettled_continuous_send_becomes_unknown_not_failed(h): - """`failed` is retryable, so an abandoned continuous send must be `unknown`. - - `fail_unsettled` runs when a runner raised without settling. That exception - may have arrived after HY was already called; `failed` would be re-claimed - and re-sent, duplicating the memory. Only a task that never reached - `running` (still pending) is provably unsent and may be `failed`. - """ - enable(h) - fresh = put_after_policy(h, "unsettled-continuous-send") - task = h.supervisor().prepare_conversation(fresh)[0] - h.tasks.claim(task.task_id) - - assert h.tasks.fail_unsettled(task.task_id, error_code="memory_task_dispatch_failed") is True - - settled = h.tasks.require(task.task_id) - assert settled.status == MemoryTaskStatus.UNKNOWN - with pytest.raises(MemoryTaskOwnershipError): - h.tasks.claim(task.task_id) - - -def test_an_unsettled_non_continuous_task_is_still_failed(h): - """The continuous rule must not change the ordinary path.""" - legacy = h.legacy_task() - h.tasks.claim(legacy.task_id) - - assert h.tasks.fail_unsettled(legacy.task_id, error_code="memory_task_dispatch_failed") is True - - assert h.tasks.require(legacy.task_id).status == MemoryTaskStatus.FAILED diff --git a/memhub/apps/core/tests/test_continuous_policy.py b/memhub/apps/core/tests/test_continuous_policy.py deleted file mode 100644 index c100059..0000000 --- a/memhub/apps/core/tests/test_continuous_policy.py +++ /dev/null @@ -1,336 +0,0 @@ -import sqlite3 -from contextlib import contextmanager -from uuid import uuid4 - -import pytest - -from one_memory import continuous_policy as policy -from one_memory import ingest_journal as journal -from one_memory.imports.sources.registry import RootCandidateKind -from one_memory.model_profiles import ModelProfilePatch -from tests.test_model_content_permissions import SOURCE, evidence_value -from tests.test_model_content_permissions import env as env # noqa: F401 - - -@contextmanager -def transaction(environment): - db = sqlite3.connect(environment.database) - db.row_factory = sqlite3.Row - db.execute("BEGIN IMMEDIATE") - try: - journal.initialize(db) - policy.initialize(db) - yield db - db.commit() - except BaseException: - db.rollback() - raise - finally: - db.close() - - -def enable(db, environment, **overrides): - values = dict( - profile=environment.profile, - expected_revision=0, - expected_legacy_revision=0, - operation_id=str(uuid4()), - backlog_mode="none", - user_confirmed=True, - ) - values.update(overrides) - return policy.enable(db, **values) - - -def test_explicit_policy_never_upgrades_legacy_or_sends_or_creates_tasks(env): - with transaction(env) as db: - assert policy.current(db) is None - receipt = enable(db, env) - assert policy.current(db) == receipt - assert db.execute("SELECT COUNT(*) FROM model_content_permissions").fetchone()[0] == 0 - assert receipt.backlog_events == () - with pytest.raises(policy.ContinuousPolicyError, match="evidence_outside_policy"): - policy.check(db, receipt, evidence_ids=(env.record.evidence_id,)) - - -def test_backlog_snapshot_over_one_hundred_thousand_is_compact(monkeypatch, env) -> None: - count = 100_001 - - class Database: - in_transaction = True - - def execute(self, _sql, _values): - return iter(((SOURCE, count, 1),)) - - monkeypatch.setattr(policy.ingest_journal, "watermark", lambda _db: count) - monkeypatch.setattr( - policy.ModelContentPermissionStore, - "_source", - lambda _db, _user, _source: object(), - ) - - snapshot = policy.snapshot_backlog(Database()) # type: ignore[arg-type] - - assert snapshot.after_seq == count - assert snapshot.source_ids == (SOURCE,) - assert snapshot.evidence_count == count - assert snapshot.conversation_count == 1 - with transaction(env) as db: - receipt = enable(db, env, backlog_mode="include", backlog_snapshot=snapshot) - assert receipt.backlog_through_seq == count - assert receipt.backlog_source_ids == (SOURCE,) - assert receipt.backlog_count == count - assert receipt.backlog_conversation_count == 1 - assert receipt.backlog_events == () - assert len(receipt.model_dump_json()) < 5_000 - - -def test_legacy_backlog_receipt_remains_readable(env) -> None: - with transaction(env) as db: - receipt = enable(db, env) - legacy = receipt.model_dump( - exclude={"backlog_through_seq", "backlog_source_ids", "backlog_count"} - ) - legacy["backlog_mode"] = "include" - legacy["backlog_events"] = (1,) - restored = policy.ContinuousPolicy.model_validate(legacy) - db.execute( - "UPDATE continuous_policy_history SET receipt_json=? WHERE user_id=?", - (restored.model_dump_json(), restored.user_id), - ) - assert policy.check(db, restored, evidence_ids=(env.record.evidence_id,)) == restored - - -@pytest.mark.parametrize("confirmation", [False, 1, "true", None]) -def test_explicit_confirmation_is_required(env, confirmation): - with transaction(env) as db: - with pytest.raises(policy.ContinuousPolicyError): - enable(db, env, user_confirmed=confirmation) - assert policy.current(db) is None - - -def test_future_old_dated_import_and_new_allowed_source_are_eligible(env, tmp_path): - with transaction(env) as db: - receipt = enable(db, env) - other_root = tmp_path / "other-source" - other_root.mkdir() - env.sources.grant( - user_id="local", - source_id="cursor", - root=other_root, - candidate_kind=RootCandidateKind.DEFAULT, - ) - with transaction(env) as db: - value = evidence_value("after", source_id="cursor") - result = env.evidence.put(value, connection=db) - event = journal.record(db, result.record.evidence_id) - assert event.seq > receipt.after_seq - assert policy.check(db, receipt, evidence_ids=(result.record.evidence_id,)) == receipt - - -def test_include_uses_current_source_authority_not_the_enable_time_snapshot(env, tmp_path): - old_ungranted = env.evidence.put(evidence_value("old-cursor", source_id="cursor")).record - with transaction(env) as db: - receipt = enable(db, env, backlog_mode="include") - assert receipt.backlog_source_ids == (SOURCE,) - - other_root = tmp_path / "later-authorized-source" - other_root.mkdir() - env.sources.grant( - user_id="local", - source_id="cursor", - root=other_root, - candidate_kind=RootCandidateKind.DEFAULT, - ) - with transaction(env) as db: - assert policy.check(db, receipt, evidence_ids=(old_ungranted.evidence_id,)) == receipt - - -def test_include_keeps_legacy_backlog_fields_readable_without_using_them_as_a_boundary(env): - with transaction(env) as db: - receipt = enable(db, env, backlog_mode="include") - assert receipt.backlog_events == () - assert receipt.backlog_through_seq == 1 - assert receipt.backlog_source_ids == (SOURCE,) - policy.check(db, receipt, evidence_ids=(env.record.evidence_id,)) - db.execute( - "UPDATE evidence SET workspace='new identity' WHERE evidence_id=?", - (env.record.evidence_id,), - ) - journal.record(db, env.record.evidence_id, cause="maintenance") - assert policy.check(db, receipt, evidence_ids=(env.record.evidence_id,)) == receipt - - -def test_only_new_mode_keeps_the_enable_time_boundary(env): - with transaction(env) as db: - receipt = enable(db, env, backlog_mode="none") - with pytest.raises(policy.ContinuousPolicyError, match="evidence_outside_policy"): - policy.check(db, receipt, evidence_ids=(env.record.evidence_id,)) - - -def test_pause_resume_and_revoke_are_cas_and_old_receipts_never_reactivate(env): - with transaction(env) as db: - receipt = enable(db, env) - paused = policy.transition( - db, action="pause", operation_id=str(uuid4()), user_confirmed=True, expected_revision=1 - ) - with pytest.raises(policy.ContinuousPolicyError): - policy.check(db, receipt) - with pytest.raises(policy.ContinuousPolicyError): - policy.check(db, paused) - resumed = policy.transition( - db, action="resume", operation_id=str(uuid4()), user_confirmed=True, expected_revision=2 - ) - assert resumed.epoch == receipt.epoch and resumed.after_seq == receipt.after_seq - revoked = policy.transition( - db, action="revoke", operation_id=str(uuid4()), user_confirmed=True, expected_revision=3 - ) - with pytest.raises(policy.ContinuousPolicyError): - policy.check(db, resumed) - with pytest.raises(policy.ContinuousPolicyError, match="requires_new_enable"): - policy.transition( - db, - action="resume", - operation_id=str(uuid4()), - user_confirmed=True, - expected_revision=4, - ) - new = enable(db, env, expected_revision=revoked.revision) - assert new.epoch != receipt.epoch - - -def test_enable_idempotence_rejects_changed_payload_and_superseded_operation(env): - with transaction(env) as db: - request_id = str(uuid4()) - receipt = enable(db, env, operation_id=request_id) - assert enable(db, env, operation_id=request_id) == receipt - assert db.execute("SELECT COUNT(*) FROM continuous_policy_history").fetchone()[0] == 1 - with pytest.raises(policy.ContinuousPolicyError, match="payload_changed"): - enable(db, env, operation_id=request_id, backlog_mode="include") - policy.transition( - db, action="revoke", operation_id=str(uuid4()), user_confirmed=True, expected_revision=1 - ) - with pytest.raises(policy.ContinuousPolicyError, match="superseded"): - enable(db, env, operation_id=request_id) - - -def test_source_revoke_profile_change_and_legacy_changes_block_existing_policy(env): - with transaction(env) as db: - receipt = enable(db, env, backlog_mode="include") - env.sources.revoke(user_id="local", source_id=SOURCE) - with transaction(env) as db, pytest.raises(RuntimeError): - policy.check(db, receipt, evidence_ids=(env.record.evidence_id,)) - env.profiles.update( - env.profile.profile_id, - ModelProfilePatch(expected_revision=1, model="different"), - env.secrets, - ) - with transaction(env) as db, pytest.raises(RuntimeError): - policy.check(db, receipt) - - -def test_caller_rollback_includes_policy_and_backlog(env): - with pytest.raises(RuntimeError, match="rollback"), transaction(env) as db: - enable(db, env) - raise RuntimeError("rollback") - with transaction(env) as db: - assert policy.current(db) is None - - -@pytest.mark.parametrize("value", [True, -1, "0", 0.5]) -def test_revision_values_are_not_coerced(env, value): - with ( - transaction(env) as db, - pytest.raises(policy.ContinuousPolicyError, match="invalid_revision"), - ): - enable(db, env, expected_revision=value) - - -def test_legacy_permission_changes_do_not_invalidate_continuous_policy(env): - legacy = env.grant() - with transaction(env) as db: - receipt = enable(db, env, expected_legacy_revision=legacy.revision) - env.permissions.revoke(user_id="local", expected_revision=legacy.revision, user_confirmed=True) - with transaction(env) as db: - assert policy.check(db, receipt) == receipt - paused = policy.transition( - db, - expected_revision=receipt.revision, - action="pause", - operation_id=str(uuid4()), - user_confirmed=True, - ) - resumed = policy.transition( - db, - expected_revision=paused.revision, - action="resume", - operation_id=str(uuid4()), - user_confirmed=True, - ) - assert resumed.state == "enabled" - - -def test_paused_policy_cannot_resume_after_model_identity_changes(env): - with transaction(env) as db: - enable(db, env) - policy.transition( - db, expected_revision=1, action="pause", operation_id=str(uuid4()), user_confirmed=True - ) - env.profiles.update( - env.profile.profile_id, - ModelProfilePatch(expected_revision=1, model="changed-model"), - env.secrets, - ) - with transaction(env) as db: - with pytest.raises(RuntimeError): - policy.transition( - db, - expected_revision=2, - action="resume", - operation_id=str(uuid4()), - user_confirmed=True, - ) - assert policy.current(db).state == "paused" - - -def test_read_check_restores_callers_row_factory(env): - with transaction(env) as db: - receipt = enable(db, env) - db.row_factory = None - assert policy.check(db, receipt) == receipt - assert db.row_factory is None - - -def test_prepared_input_cannot_be_replaced_by_a_newer_allowed_revision(env): - with transaction(env) as db: - receipt = enable(db, env) - first = env.evidence.put(evidence_value("new-input"), connection=db).record - lease = policy.bind_inputs(db, receipt, (first.evidence_id,)) - policy.recheck_inputs(db, lease) - env.evidence.put(evidence_value("new-input", text="changed material"), connection=db) - policy.check(db, receipt, evidence_ids=(first.evidence_id,)) - with pytest.raises(policy.ContinuousPolicyError, match="bound_input_changed"): - policy.recheck_inputs(db, lease) - - -def test_prepared_source_cannot_survive_regrant_without_revalidation(env): - with transaction(env) as db: - receipt = enable(db, env, backlog_mode="include") - lease = policy.bind_inputs(db, receipt, (env.record.evidence_id,)) - env.sources.revoke(user_id="local", source_id=SOURCE) - env.sources.grant( - user_id="local", source_id=SOURCE, root=env.root, candidate_kind=RootCandidateKind.DEFAULT - ) - with ( - transaction(env) as db, - pytest.raises(policy.ContinuousPolicyError, match="bound_source_authority_changed"), - ): - policy.recheck_inputs(db, lease) - - -@pytest.mark.parametrize("inputs", [(), ("duplicate", "duplicate")]) -def test_lease_requires_explicit_unique_inputs(env, inputs): - with transaction(env) as db: - receipt = enable(db, env) - with pytest.raises(policy.ContinuousPolicyError, match="unique_inputs"): - policy.bind_inputs(db, receipt, inputs) diff --git a/memhub/apps/core/tests/test_continuous_processing_bridge.py b/memhub/apps/core/tests/test_continuous_processing_bridge.py deleted file mode 100644 index 79c3d6c..0000000 --- a/memhub/apps/core/tests/test_continuous_processing_bridge.py +++ /dev/null @@ -1,472 +0,0 @@ -import asyncio -import json -from uuid import uuid4 - -import pytest - -from one_memory import continuous_policy as cp -from one_memory import ingest_journal -from one_memory.hy_memory_bridge import HyMemoryBridgeError -from one_memory.imports.sources.registry import RootCandidateKind -from one_memory.memory_tasks import MemoryTaskStatus -from one_memory.model_processing_permission_guard import ModelProcessingPermissionGuard -from tests.test_model_processing_permissions import h as h # noqa: F401 - - -def enable(h, backlog="none"): - with h.evidence._connect(writing=True) as db: - ingest_journal.initialize(db) - cp.initialize(db) - legacy = h.permissions.receipt(user_id="local") - return cp.enable( - db, - profile=h.profiles.snapshot(h.profile.profile_id, h.profile.revision), - expected_revision=0, - expected_legacy_revision=legacy.revision if legacy else 0, - operation_id=str(uuid4()), - backlog_mode=backlog, - user_confirmed=True, - ) - - -def transition(h, action): - with h.evidence._connect(writing=True) as db: - policy = cp.current(db) - return cp.transition( - db, - expected_revision=policy.revision, - action=action, - operation_id=str(uuid4()), - user_confirmed=True, - ) - - -def put_after_policy(h, key, *, source="cursor"): - """Create one complete continuous input and record its ingest identity. - - These policy tests used a raw user-only record, which the complete-turn - contract correctly holds back. MEMORY_CONTENT is a complete input by - itself; recording it after policy enable proves the `after_seq` boundary - without inventing an assistant reply that the assertions do not inspect. - """ - record = h.put(key, source=source, memory_content=True) - with h.evidence._connect(writing=True) as db: - ingest_journal.record(db, record.evidence_id, cause="import") - return h.evidence.get(record.evidence_id) - - -def test_continuous_prepare_lease_ingest_has_no_legacy_receipt_or_old_context(h): - policy = enable(h) - fresh = put_after_policy(h, "new") - supervisor = h.supervisor() - - async def scenario(): - try: - tasks = supervisor.prepare_conversation(fresh) - assert len(tasks) == 1 - result = await supervisor._create_runner().run(tasks[0].task_id) - assert result.status == MemoryTaskStatus.WAITING_FOR_MODEL # Controlled HY failure. - assert result.last_error_code == "hy_memory_unavailable" - lease = h.leases[0] - assert lease.receipt.schema_version == 2 - assert lease.receipt.authority_kind == "continuous" - assert lease.receipt.policy == policy - assert {r.evidence_id for r in lease.records} == {fresh.evidence_id} - assert h.record.evidence_id not in {r.evidence_id for r in lease.records} - assert h.permissions.receipt(user_id="local") is None - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("action", ["pause", "revoke"]) -def test_continuous_inactive_never_falls_back_to_legacy(h, action): - h.grant() - enable(h, "include") - transition(h, action) - supervisor = h.supervisor() - with pytest.raises(HyMemoryBridgeError, match="model_content_"): - supervisor.prepare_conversation(h.record) - assert not h.bridge.calls - - -@pytest.mark.parametrize("phase", ["before_run", "ingest", "profile"]) -def test_revocation_rechecked_at_runner_and_publication_boundaries(h, phase): - enable(h) - fresh = put_after_policy(h, "new") - supervisor = h.supervisor() - task = supervisor.prepare_conversation(fresh)[0] - h.bridge.fail = False - if phase == "before_run": - transition(h, "revoke") - elif phase == "ingest": - h.bridge.on_ingest = lambda: transition(h, "revoke") - else: - h.bridge.on_profile = lambda: transition(h, "revoke") - - async def scenario(): - try: - result = await supervisor._create_runner().run(task.task_id) - assert result.status == MemoryTaskStatus.CANCELLED - assert result.last_error_code.startswith("model_content_") - assert h.governance.list(user_id="local") == [] - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_durable_binding_restarts_and_cannot_adopt_new_policy_or_new_revision(h): - enable(h) - fresh = put_after_policy(h, "new") - supervisor = h.supervisor() - task = supervisor.prepare_conversation(fresh)[0] - guard = ModelProcessingPermissionGuard(h.permissions, h.governance) - saved = guard.bound(task) - assert saved.authority_kind == "continuous" and saved.inputs - guard.lease(task, (fresh,), None).recheck() - with h.evidence._connect(writing=True) as db: - db.execute( - "UPDATE evidence SET revision=revision+1,verbatim='changed' WHERE evidence_id=?", - (fresh.evidence_id,), - ) - ingest_journal.record(db, fresh.evidence_id, cause="import") - with pytest.raises(HyMemoryBridgeError): - guard.lease(task, (fresh,), None).recheck() - - -def test_legacy_task_not_promoted_by_continuous_policy(h): - legacy = h.grant() - task = h.legacy_task() - guard = ModelProcessingPermissionGuard(h.permissions, h.governance) - guard.bind(task, legacy) - enable(h, "include") - with pytest.raises(HyMemoryBridgeError): - guard.bound(task) - - -def test_future_new_source_old_date_uses_continuous_ingest_boundary(h, tmp_path): - enable(h) - folder = tmp_path / "second" - folder.mkdir() - h.sources.grant( - user_id="local", - source_id="new-source", - root=folder, - candidate_kind=RootCandidateKind.DEFAULT, - ) - fresh = put_after_policy(h, "new", source="new-source") - with h.evidence._connect(writing=True) as db: - db.execute( - "UPDATE evidence SET created_at='1990-01-01T00:00:00+00:00' WHERE evidence_id=?", - (fresh.evidence_id,), - ) - # Actual import material revision, not authorizing an attribution repair. - db.execute( - "UPDATE evidence SET verbatim='Newly imported old-dated content' WHERE evidence_id=?", - (fresh.evidence_id,), - ) - ingest_journal.record(db, fresh.evidence_id, cause="import") - fresh = h.evidence.get(fresh.evidence_id) - supervisor = h.supervisor() - tasks = supervisor.prepare_conversation(fresh) - assert len(tasks) == 1 - assert [work.task_id for work in supervisor._classify(tasks)] == [tasks[0].task_id] - assert supervisor.tasks.require(tasks[0].task_id).status is MemoryTaskStatus.PENDING - - -def test_schema2_roundtrip_rejects_unknown_and_preserves_bound_events(h): - from one_memory.continuous_permission_bridge import decode_authority - - enable(h) - fresh = put_after_policy(h, "new") - supervisor = h.supervisor() - task = supervisor.prepare_conversation(fresh)[0] - receipt = supervisor._permission_guard.bound(task) - assert decode_authority(receipt.model_dump_json()) == receipt - payload = receipt.model_dump(mode="json") - payload["schema_version"] = 99 - with pytest.raises(ValueError): - decode_authority(json.dumps(payload)) - - -def test_transport_rechecks_continuous_lease_without_forged_snapshot(h): - from one_memory.hy_permission_transport import HyPermissionTransport, HyPermissionTransportError - - enable(h) - fresh = put_after_policy(h, "new") - supervisor = h.supervisor() - task = supervisor.prepare_conversation(fresh)[0] - lease = supervisor._create_runner()._prepare_permission_lease(task)[0] - transport = HyPermissionTransport(lease) - transport._active = True - transport.assert_allowed() - transition(h, "pause") - with pytest.raises(HyPermissionTransportError, match="lease_denied"): - transport.assert_allowed() - - -def test_transport_denial_names_why_the_lease_failed(h): - from one_memory.hy_permission_transport import HyPermissionTransport, HyPermissionTransportError - - enable(h) - task = h.supervisor().prepare_conversation(put_after_policy(h, "new"))[0] - lease = h.supervisor()._create_runner()._prepare_permission_lease(task)[0] - transport = HyPermissionTransport(lease) - transport._active = True - transition(h, "pause") - with pytest.raises(Exception) as direct: - lease.recheck() - reason = str(direct.value).removeprefix("model_content_") - assert reason and reason != "lease_denied" - - with pytest.raises(HyPermissionTransportError) as denied: - transport.assert_allowed() - - assert denied.value.code == f"model_content_transport_lease_denied_{reason}" - - -def test_successful_continuous_runner_completes_without_old_backlog(h): - enable(h) - fresh = put_after_policy(h, "new") - supervisor = h.supervisor() - h.bridge.fail = False - - async def run(): - try: - task = supervisor.prepare_conversation(fresh)[0] - result = await supervisor._create_runner().run(task.task_id) - assert result.status == MemoryTaskStatus.COMPLETED - assert [kind for kind, _ in h.bridge.calls] == [ - "ingest_extracted", - "profile", - ] - request = h.bridge.calls[0][1] - assert request.evidence_ids == (fresh.evidence_id,) - assert request.source_context is None - finally: - await supervisor.shutdown() - - asyncio.run(run()) - - -def test_prepare_backfill_filters_old_inputs_and_paused_policy_refuses(h): - policy = enable(h) - fresh = put_after_policy(h, "new") - supervisor = h.supervisor() - tasks = supervisor.prepare_backfill(expected_permission_revision=policy.revision) - assert tasks - for work in tasks: - task = h.tasks.require(work.task_id) - assert {ref["evidence_id"] for ref in h.tasks.inputs(task.task_id)} == {fresh.evidence_id} - transition(h, "pause") - with pytest.raises(HyMemoryBridgeError): - supervisor.prepare_backfill() - - -def test_bound_source_regrant_refuses_old_task(h): - enable(h) - fresh = put_after_policy(h, "new") - supervisor = h.supervisor() - task = supervisor.prepare_conversation(fresh)[0] - h.sources.revoke(user_id="local", source_id="cursor") - h.sources.grant( - user_id="local", - source_id="cursor", - root=h.root / "synthetic-source", - candidate_kind=RootCandidateKind.DEFAULT, - ) - with pytest.raises(HyMemoryBridgeError): - supervisor._create_runner()._prepare_permission_lease(task) - - -def test_schema2_native_sdk_child_send_denies_after_policy_revoke(h): - import httpx - - from one_memory.hy_permission_transport import HyPermissionTransport, HyPermissionTransportError - from tests.test_hy_permission_transport import native, response - - enable(h) - fresh = put_after_policy(h, "new") - supervisor = h.supervisor() - task = supervisor.prepare_conversation(fresh)[0] - lease = supervisor._create_runner()._prepare_permission_lease(task)[0] - provider, _ = native(lease) - requests = [] - - def transport(request): - requests.append(request.url.path) - return response(request) - - async def run(): - with pytest.raises(HyPermissionTransportError): - async with HyPermissionTransport( - lease, http_transport=httpx.MockTransport(transport) - ) as boundary: - boundary.bind_provider(provider) - await provider.complete("Synthetic permitted input") - assert len(requests) == 1 - transition(h, "revoke") - await provider.complete("This request must not leave") - assert len(requests) == 1 - - asyncio.run(run()) - - -def test_recreated_event_identity_produces_new_task_even_same_revision(h): - enable(h) - fresh = put_after_policy(h, "new") - supervisor = h.supervisor() - first = supervisor.prepare_conversation(fresh)[0] - with h.evidence._connect(writing=True) as db: - db.execute( - "UPDATE evidence SET verbatim='changed without revision' WHERE evidence_id=?", - (fresh.evidence_id,), - ) - ingest_journal.record(db, fresh.evidence_id, cause="import") - new = h.evidence.get(fresh.evidence_id) - second = supervisor.prepare_conversation(new)[0] - assert second.task_id != first.task_id - - -def test_default_guard_does_not_create_continuous_schema(h): - ModelProcessingPermissionGuard(h.permissions, h.governance) - with h.evidence._connect() as db: - assert not db.execute( - "SELECT 1 FROM sqlite_master WHERE name='memory_task_continuous_permissions'" - ).fetchone() - assert not db.execute( - "SELECT 1 FROM sqlite_master WHERE name='continuous_policy_history'" - ).fetchone() - - -def test_publish_guard_rechecks_continuous_after_last_bridge_read(h, monkeypatch): - enable(h) - fresh = put_after_policy(h, "new") - supervisor = h.supervisor() - h.bridge.fail = False - task = supervisor.prepare_conversation(fresh)[0] - runner = supervisor._create_runner() - original = runner._record_outputs - - def revoke_at_commit(**kwargs): - transition(h, "revoke") - return original(**kwargs) - - monkeypatch.setattr(runner, "_record_outputs", revoke_at_commit) - result = asyncio.run(runner.run(task.task_id)) - assert result.status == MemoryTaskStatus.CANCELLED - assert result.last_error_code.startswith("model_content_") - assert h.tasks.extraction(task.task_id) is None - assert h.governance.list(user_id="local") == [] - - -def test_native_hy_continuous_source_to_governance_with_mock_network(tmp_path): - import httpx - - from one_memory.hy_permission_context import permission_bridge_factory - from one_memory.imports.models import ( - EvidenceContentKind, - EvidenceInput, - EvidenceOrigin, - OriginMetadata, - WrittenBy, - ) - from tests.test_hy_permission_context import whole_client - - async def run(): - with whole_client(tmp_path) as (supervisor, old, permissions, legacy): - with supervisor.evidence._connect(writing=True) as db: - ingest_journal.initialize(db) - cp.initialize(db) - cp.enable( - db, - profile=legacy.snapshot.profile, - expected_revision=0, - expected_legacy_revision=legacy.revision, - operation_id=str(uuid4()), - backlog_mode="none", - user_confirmed=True, - ) - fresh = supervisor.evidence.put( - EvidenceInput( - source_id=old.source_id, - conversation_id=old.conversation_id, - message_id="new", - role="user", - written_by=WrittenBy.PERSON, - content_kind=EvidenceContentKind.MEMORY_CONTENT, - verbatim="Synthetic fresh continuous memory, not old backlog.", - origin_meta={EvidenceOrigin.FILE: OriginMetadata()}, - ) - ).record - sends = [] - - def handler(request): - body = json.loads(request.content) - sends.append(body) - assert old.verbatim not in request.content.decode() - if request.url.path.endswith("embeddings"): - return httpx.Response( - 200, - json={ - "object": "list", - "model": "test-embedding", - "data": [ - { - "index": i, - "object": "embedding", - "embedding": [1.0] + [0.0] * 383, - } - for i, _ in enumerate(body["input"]) - ], - "usage": {"prompt_tokens": 1, "total_tokens": 1}, - }, - ) - return httpx.Response( - 200, - json={ - "id": "fixture", - "object": "chat.completion", - "created": 1, - "model": "test-generation", - "choices": [ - { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": json.dumps( - [ - { - "op": "ADD", - "content": fresh.verbatim, - "layer": "L2_FACT", - "owner": "user", - } - ] - ), - }, - } - ], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, - }, - ) - - supervisor.permission_bridge_factory = permission_bridge_factory( - supervisor._bridge, http_transport=httpx.MockTransport(handler) - ) - try: - task = supervisor.prepare_conversation(fresh)[0] - result = await supervisor._create_runner().run(task.task_id) - assert result.status == "completed", result.last_error_code - memories = supervisor.governance.list(user_id="local") - assert sends and len(memories) == 1 - assert memories[0].content == fresh.verbatim - assert memories[0].evidence_ids == (fresh.evidence_id,) - assert permissions.receipt(user_id="local") == legacy - finally: - await supervisor.shutdown() - - asyncio.run(run()) diff --git a/memhub/apps/core/tests/test_continuous_processing_integration.py b/memhub/apps/core/tests/test_continuous_processing_integration.py deleted file mode 100644 index 5fc7d27..0000000 --- a/memhub/apps/core/tests/test_continuous_processing_integration.py +++ /dev/null @@ -1,1327 +0,0 @@ -"""Real temporary Core/stores with controlled probe and HY boundaries, never real data. - -Kernel-helper tests isolate the scheduling handoff while the public activation -route lands. They do not claim atomic activation; API tests require the real route. -""" - -import asyncio -import sqlite3 -from contextlib import asynccontextmanager, closing -from datetime import UTC, datetime, timedelta -from uuid import uuid4 - -import httpx -import pytest - -from one_memory import api, continuous_policy, ingest_journal -from one_memory.config import Settings -from one_memory.hy_memory_bridge import HyMemoryBridgeError, HyMemoryIngestResult, HyMemoryOutput -from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy -from one_memory.imports.sources.registry import RootCandidateKind -from one_memory.memory_processing import MemoryTaskRunner -from one_memory.memory_tasks import MemoryTaskStatus -from one_memory.model_probe import ModelProber -from one_memory.secrets import InMemorySecretStore - -API_ROOT = "/api/v1/continuous-processing-policy" -TEXT = "用户偏好本地优先的记忆处理,并保留可追溯的原始依据。" -OLD = datetime(2019, 1, 1, tzinfo=UTC) - - -class FakeSecretStore(InMemorySecretStore): - """Only the existing in-memory SecretStore protocol; no keychain calls.""" - - -class ControlledHyBridge: - def __init__(self): - self.requests = [] - self.leases = [] - self.before_return = lambda: None - self.closed = 0 - - async def ingest(self, request): - if request.source_id == "one-memory-manual": - pytest.fail("A note the person asked to keep must be imported, not re-extracted") - return await self.answer(request) - - async def ingest_extracted(self, request, contents): - if request.source_id != "one-memory-manual": - pytest.fail("Raw conversation must use HY ingest, not direct content import") - assert list(contents) == [turn.content for turn in request.turns] - return await self.answer(request) - - async def answer(self, request): - self.requests.append(request) - self.before_return() - return HyMemoryIngestResult( - success=True, - memories=( - HyMemoryOutput( - memory_id="integration-hy-" + request.evidence_ids[0], - content=TEXT, - layer="L2_FACT", - owner="user", - evidence_ids=request.evidence_ids, - ), - ), - ) - - async def profile_memories(self, **kwargs): - return () - - async def close(self): - self.closed += 1 - - -class IntegrationCore: - def __init__(self, root, monkeypatch): - self.root = root - self.data = root / "data" - self.data.mkdir() - self.home = root / "synthetic-home" - self.home.mkdir() - self.database = self.data / "one-memory.sqlite3" - self.secrets = FakeSecretStore() - self.bridge = ControlledHyBridge() - self.probe_requests = [] - self.profile = None - self.app = None - self.client = None - - async def no_network(*args, **kwargs): - pytest.fail("Real network transport forbidden in continuous integration") - - def no_sync_network(*args, **kwargs): - pytest.fail("Real synchronous network forbidden") - - def no_unguarded(*args, **kwargs): - pytest.fail("Production runner bypassed its permission-isolated HY boundary") - - @asynccontextmanager - async def controlled_context(app, lease, secret, directory): - assert directory.resolve() == self.data.resolve() - assert app.state.evidence_store.database_path.resolve() == self.database.resolve() - lease.recheck() - self.bridge.leases.append(lease) - try: - yield self.bridge - finally: - await self.bridge.close() - - monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", no_network) - monkeypatch.setattr(httpx.HTTPTransport, "handle_request", no_sync_network) - monkeypatch.setattr(MemoryTaskRunner, "_create_bridge", no_unguarded) - monkeypatch.setattr(api, "_permission_context", controlled_context) - - def probe_transport(self, request): - body = request.content.decode() if request.content else "" - assert TEXT not in body and "old-neighbor" not in body - self.probe_requests.append((request.url.path, body)) - if request.url.path.endswith("/models"): - return httpx.Response(200, json={"data": [{"id": "integration-model"}]}) - if request.url.path.endswith("/chat/completions"): - return httpx.Response( - 200, - json={ - "choices": [{"message": {"content": '{"memory_probe":true}'}}], - }, - ) - if request.url.path.endswith("/embeddings"): - return httpx.Response(200, json={"data": [{"embedding": [0.1, 0.2, 0.3]}]}) - pytest.fail("Unexpected synthetic probe path: " + request.url.path) - - @asynccontextmanager - async def running(self, *, enable_continuous=True, automatic_memory_processing=True): - capability = {"continuous_processing_enabled": True} if enable_continuous else {} - self.app = api.create_app( - Settings( - data_dir=self.data, - source_home=self.home, - api_token="integration-only-token", - instance_id="continuous-integration", - imports_enabled=False, - live_sync_enabled=False, - automatic_memory_processing=automatic_memory_processing, - ), - source_catalog={}, - secret_store=self.secrets, - **capability, - ) - async with self.app.router.lifespan_context(self.app): - - async def skip_read_bridge_refresh(_snapshot): - # These tests replace only the permission-isolated write - # boundary. A post-write local read-client refresh is covered - # separately and must not invoke the forbidden write factory. - return None - - self.app.state.memory_processing_supervisor._refresh_read_bridge = ( - skip_read_bridge_refresh - ) - self.app.state.model_prober = ModelProber( - store=self.app.state.model_profile_store, - secrets=self.secrets, - transport=httpx.MockTransport(self.probe_transport), - ) - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=self.app), - base_url="http://test", - headers={"Authorization": "Bearer integration-only-token"}, - ) as client: - self.client = client - assert (await client.get("/api/v1/health")).status_code == 200 - for name in ( - "evidence_store", - "memory_governance_store", - "memory_task_store", - "model_profile_store", - "model_content_permissions", - ): - assert ( - getattr(self.app.state, name).database_path.resolve() - == self.database.resolve() - ) - yield self - self.client = None - - def grant_source(self, source="cursor", *, enabled=True): - root = self.home / source - root.mkdir(exist_ok=True) - receipt = self.app.state.source_grant_store.grant( - user_id="local", - source_id=source, - root=root, - candidate_kind=RootCandidateKind.DEFAULT, - ) - if enabled: - self.app.state.config_store.set_sync(source, True) - return receipt - - def put( - self, - key, - *, - source="cursor", - conversation="same-conversation", - created_at=OLD, - role="user", - written_by=WrittenBy.PERSON, - verbatim=None, - ): - return self.app.state.evidence_store.put( - EvidenceInput( - user_id="local", - source_id=source, - conversation_id=conversation, - message_id=key, - role=role, - written_by=written_by, - created_at=created_at, - verbatim=verbatim if verbatim is not None else TEXT + " " + key, - origin_meta={EvidenceOrigin.FILE: OriginMetadata()}, - ) - ).record - - def put_complete( - self, - key, - *, - source="cursor", - conversation="same-conversation", - created_at=OLD, - verbatim=None, - ): - """Store a complete user/assistant turn and return its user record.""" - user = self.put( - key, - source=source, - conversation=conversation, - created_at=created_at, - verbatim=verbatim, - ) - self.put( - key + "-reply", - source=source, - conversation=conversation, - created_at=created_at + timedelta(microseconds=1), - role="assistant", - written_by=WrittenBy.AGENT, - ) - return user - - def turn_records(self, seed): - """Return the complete turn beginning at ``seed`` from durable Evidence.""" - conversation = self.app.state.evidence_store.list_conversation(seed.evidence_id) - start = next(index for index, record in enumerate(conversation) if record == seed) - turn = [] - for record in conversation[start:]: - if turn and record.role == "user": - break - turn.append(record) - return tuple(turn) - - async def save_and_probe(self): - response = await self.client.post( - "/api/v1/model-profiles", - json={ - "name": "Controlled integration", - "provider_kind": "openai_compatible", - "base_url": "https://integration.invalid/v1", - "model": "integration-model", - "api_key": "synthetic-key-not-a-credential", - "disable_thinking": True, - "embedding_provider_kind": "openai_compatible", - "embedding_model": "integration-vector", - "embedding_base_url": "https://integration.invalid/v1", - "max_concurrency": 1, - }, - ) - assert response.status_code == 200, response.text - self.profile = response.json() - probe = await self.client.post(f"/api/v1/model-profiles/{self.profile['profile_id']}/probe") - assert probe.status_code == 200, probe.text - caps = probe.json()["capabilities"] - assert ( - caps["endpoint_reachable"] and caps["model_available"] and caps["embedding_available"] - ) - profiles = await self.client.get("/api/v1/model-profiles") - assert profiles.status_code == 200 - self.profile = next( - p for p in profiles.json() if p["profile_id"] == self.profile["profile_id"] - ) - assert self.profile["revision"] == probe.json()["profile_revision"] - assert self.probe_requests and not self.bridge.requests - assert self.app.state.model_profile_store.active_identity() is None - assert self.app.state.memory_task_store.list() == [] - - def policy_current(self): - with closing(sqlite3.connect(self.database)) as db: - exists = db.execute( - "SELECT 1 FROM sqlite_master WHERE name='continuous_policy_history'" - ).fetchone() - return continuous_policy.current(db) if exists else None - - def kernel_enable(self, mode="none"): - """Known helper path only; explicitly not the atomic public enable transaction.""" - store = self.app.state.model_profile_store - store.activate(self.profile["profile_id"], self.secrets) - profile = store.snapshot(self.profile["profile_id"], self.profile["revision"]) - with closing(sqlite3.connect(self.database)) as db, db: - db.execute("BEGIN IMMEDIATE") - ingest_journal.initialize(db) - continuous_policy.initialize(db) - return continuous_policy.enable( - db, - profile=profile, - expected_revision=0, - expected_legacy_revision=0, - operation_id=str(uuid4()), - backlog_mode=mode, - user_confirmed=True, - ) - - async def enable_api(self, mode="none"): - body = dict( - operation_id=str(uuid4()), - profile_id=self.profile["profile_id"], - profile_revision=self.profile["revision"], - expected_policy_revision=0, - expected_legacy_revision=0, - backlog_mode=mode, - user_confirmed=True, - ) - response = await self.client.post(API_ROOT + "/enable", json=body) - assert response.status_code in (200, 202), ( - f"Real enable route not integrated: {response.status_code} {response.text}" - ) - assert ( - "secret_ref" not in response.text - and "synthetic-key-not-a-credential" not in response.text - ) - receipt = self.policy_current() - assert receipt is not None and receipt.state == "enabled" - assert self.app.state.model_profile_store.active_identity() == ( - self.profile["profile_id"], - self.profile["revision"], - ) - return receipt, body - - async def run_prepared(self, record): - supervisor = self.app.state.memory_processing_supervisor - tasks = await asyncio.to_thread(supervisor.prepare_conversation, record) - assert tasks, "Policy accepted input but production prepare created no tasks" - return [await supervisor._create_runner().run(task.task_id) for task in tasks] - - async def wait_completed(self, timeout=3, *, minimum_tasks=1): - end = asyncio.get_running_loop().time() + timeout - while asyncio.get_running_loop().time() < end: - tasks = self.app.state.memory_task_store.list() - if len(tasks) >= minimum_tasks and all( - t.status - in { - MemoryTaskStatus.COMPLETED, - MemoryTaskStatus.FAILED, - MemoryTaskStatus.WAITING_FOR_MODEL, - } - for t in tasks - ): - assert all(t.status == MemoryTaskStatus.COMPLETED for t in tasks), [ - (t.status, t.last_error_code) for t in tasks - ] - return tasks - await asyncio.sleep(0.025) - tasks = self.app.state.memory_task_store.list() - pytest.fail( - "Continuous ingest/restart did not reach completed task within bounded wait: " - + repr([(task.status, task.last_error_code, task.input_ref) for task in tasks]) - ) - - async def assert_governed(self, record): - assert self.bridge.requests, ( - "No controlled HY invocation; governance alone is not completion" - ) - response = await self.client.get("/api/v1/memory-library", params={"status": "confirmed"}) - assert response.status_code == 200, response.text - items = response.json()["items"] - assert any(record.evidence_id in item["evidence_ids"] for item in items), items - - -@pytest.fixture -def core(tmp_path, monkeypatch): - return IntegrationCore(tmp_path, monkeypatch) - - -def test_save_probe_and_kernel_boundaries_are_real_without_auto_legacy_upgrade(core): - async def scenario(): - async with core.running(): - core.grant_source() - old = core.put_complete("old-neighbor") - await core.save_and_probe() - receipt = core.kernel_enable() - assert receipt.backlog_events == () - core.grant_source("workbuddy") - new = core.put("new-old-date", source="workbuddy") - with closing(sqlite3.connect(core.database)) as db, db: - db.execute("BEGIN") - assert ( - continuous_policy.check(db, receipt, evidence_ids=(new.evidence_id,)) == receipt - ) - with pytest.raises(continuous_policy.ContinuousPolicyError, match="outside_policy"): - continuous_policy.check(db, receipt, evidence_ids=(old.evidence_id,)) - assert core.app.state.model_content_permissions.receipt(user_id="local") is None - assert core.app.state.memory_task_store.list() == [] and not core.bridge.requests - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("backlog", ["none", "include"]) -def test_kernel_policy_reaches_real_guard_runner_hy_and_governance(core, backlog): - async def scenario(): - async with core.running(): - core.grant_source() - old = core.put_complete("old-neighbor") - await core.save_and_probe() - core.kernel_enable(backlog) - record = old if backlog == "include" else core.put_complete("new-permitted") - results = await core.run_prepared(record) - assert all(t.status == MemoryTaskStatus.COMPLETED for t in results), results - await core.assert_governed(record) - if backlog == "none": - for request in core.bridge.requests: - assert old.evidence_id not in request.evidence_ids - context = request.source_context - if context: - assert old.evidence_id not in { - e.evidence_id - for e in (*context.current, *context.history, *context.supporting) - } - - asyncio.run(scenario()) - - -def test_live_batch_drops_pre_policy_neighbor_without_cancelling_new_turn(core): - async def scenario(): - async with core.running(): - core.grant_source() - old = core.put("old-neighbor") - await core.save_and_probe() - core.kernel_enable("none") - user = core.put( - "new-user", - conversation=old.conversation_id, - created_at=datetime.now(UTC), - ) - assistant = core.put( - "new-assistant", - conversation=old.conversation_id, - created_at=datetime.now(UTC), - role="assistant", - written_by=WrittenBy.AGENT, - ) - - completed = await core.app.state.memory_processing_supervisor.process_records( - (old, user, assistant) - ) - - assert completed and all(task.status == "completed" for task in completed) - sent = { - evidence_id - for request in core.bridge.requests - for evidence_id in request.evidence_ids - } - assert old.evidence_id not in sent - assert {user.evidence_id, assistant.evidence_id} <= sent - - asyncio.run(scenario()) - - -def test_real_enable_api_atomic_active_policy_and_idempotence(core): - async def scenario(): - async with core.running(): - await core.save_and_probe() - policy, body = await core.enable_api() - repeated = await core.client.post(API_ROOT + "/enable", json=body) - assert repeated.status_code in (200, 202), repeated.text - assert core.policy_current() == policy - current = await core.client.get(API_ROOT) - assert current.status_code == 200 and "secret_ref" not in current.text - assert len(core.app.state.model_profile_store.list(core.secrets)) == 1 - - asyncio.run(scenario()) - - -def test_enable_future_new_source_old_dated_ingest_normal_notification(core): - async def scenario(): - async with core.running(): - await core.save_and_probe() - await core.enable_api() - core.grant_source("workbuddy") - record = core.put_complete("later-source-old-date", source="workbuddy") - core.app.state.memory_processing_supervisor.enqueue_evidence(record) - await core.wait_completed() - await core.assert_governed(record) - - asyncio.run(scenario()) - - -def test_explicit_backlog_api_positive_control(core): - async def scenario(): - async with core.running(): - core.grant_source() - old = core.put_complete("old-explicit-backlog") - await core.save_and_probe() - policy, _ = await core.enable_api("include") - assert policy.backlog_events == () - assert policy.backlog_through_seq is not None - assert policy.backlog_source_ids == ("cursor",) - await core.wait_completed() - await core.assert_governed(old) - - asyncio.run(scenario()) - - -def test_import_before_model_enable_and_later_append_both_reach_memory_within_ten_seconds(core): - async def scenario(): - async with core.running(): - core.grant_source() - historical = core.put_complete("before-model") - assert core.app.state.memory_task_store.list() == [] - assert core.app.state.memory_task_store.processing_metrics()["failed"] == 0 - - await core.save_and_probe() - policy, _body = await core.enable_api("include") - assert policy.backlog_mode == "include" - historical_tasks = await core.wait_completed(timeout=10) - assert any( - historical.evidence_id - in { - ref["evidence_id"] - for ref in core.app.state.memory_task_store.inputs(task.task_id) - } - for task in historical_tasks - ) - await core.assert_governed(historical) - - later = core.put("after-model", created_at=datetime.now(UTC)) - reply = core.put( - "after-model-reply", - created_at=datetime.now(UTC), - role="assistant", - written_by=WrittenBy.AGENT, - ) - core.app.state.memory_processing_supervisor.enqueue_evidence(reply) - deadline = asyncio.get_running_loop().time() + 10 - later_tasks = [] - while asyncio.get_running_loop().time() < deadline: - later_tasks = core.app.state.memory_task_store.list() - if any( - later.evidence_id - in { - ref["evidence_id"] - for ref in core.app.state.memory_task_store.inputs(task.task_id) - } - and task.status is MemoryTaskStatus.COMPLETED - for task in later_tasks - ): - break - await asyncio.sleep(0.025) - assert any( - later.evidence_id - in { - ref["evidence_id"] - for ref in core.app.state.memory_task_store.inputs(task.task_id) - } - for task in later_tasks - ) - await core.assert_governed(later) - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("revocation", ["source", "policy"]) -def test_revocation_after_hy_blocks_governance_publication(core, revocation): - async def scenario(): - async with core.running(): - core.grant_source() - await core.save_and_probe() - core.kernel_enable() - record = core.put_complete("revoked-in-flight") - - def revoke(): - if revocation == "source": - core.app.state.source_grant_store.revoke(user_id="local", source_id="cursor") - else: - with closing(sqlite3.connect(core.database)) as db, db: - db.execute("BEGIN IMMEDIATE") - current = continuous_policy.current(db) - continuous_policy.transition( - db, - expected_revision=current.revision, - action="revoke", - operation_id=str(uuid4()), - user_confirmed=True, - ) - - core.bridge.before_return = revoke - results = await core.run_prepared(record) - assert core.bridge.requests, "Revocation test requires positive send admission" - assert all(t.status != MemoryTaskStatus.COMPLETED for t in results) - assert core.app.state.memory_governance_store.list(user_id="local") == [] - - asyncio.run(scenario()) - - -def test_kernel_future_source_normal_enqueue_uses_existing_production_debounce(core): - async def scenario(): - async with core.running(): - await core.save_and_probe() - core.kernel_enable() - core.grant_source("workbuddy") - record = core.put("future-source", source="workbuddy") - core.put( - "future-source-reply", - source="workbuddy", - role="assistant", - written_by=WrittenBy.AGENT, - ) - core.app.state.memory_processing_supervisor.enqueue_evidence(record) - await core.wait_completed() - await core.assert_governed(record) - - asyncio.run(scenario()) - - -def test_kernel_restart_recovers_unnotified_ingest_without_legacy_grant(core): - async def scenario(): - async with core.running(): - core.grant_source() - await core.save_and_probe() - core.kernel_enable() - record = core.put_complete("committed-before-restart", conversation="restart") - assert not core.bridge.requests - async with core.running(): - await core.wait_completed() - await core.assert_governed(record) - assert core.app.state.model_content_permissions.receipt(user_id="local") is None - - asyncio.run(scenario()) - - -def test_no_backlog_old_conversation_is_rejected_before_bridge_with_live_positive_control(core): - async def scenario(): - async with core.running(): - core.grant_source() - old = core.put_complete("old-only", conversation="old-only") - await core.save_and_probe() - core.kernel_enable() - supervisor = core.app.state.memory_processing_supervisor - with pytest.raises(HyMemoryBridgeError, match="model_content_evidence_outside_policy"): - await asyncio.to_thread(supervisor.prepare_conversation, old) - assert not core.bridge.requests - new = core.put_complete("new-positive", conversation="new-positive") - results = await core.run_prepared(new) - assert all(t.status == MemoryTaskStatus.COMPLETED for t in results) - await core.assert_governed(new) - assert all( - old.evidence_id not in request.evidence_ids for request in core.bridge.requests - ) - - asyncio.run(scenario()) - - -def test_real_current_and_disabled_capability_preserve_no_side_effects(core): - async def scenario(): - async with core.running(enable_continuous=False): - await core.save_and_probe() - current = await core.client.get(API_ROOT) - assert current.status_code == 200, current.text - value = current.json() - assert value["available"] is True and value["policy"] is None - assert "secret_ref" not in current.text - assert value["capability"]["enable"] is False - body = dict( - operation_id=str(uuid4()), - profile_id=core.profile["profile_id"], - profile_revision=core.profile["revision"], - expected_policy_revision=0, - expected_legacy_revision=0, - backlog_mode="none", - user_confirmed=True, - ) - denied = await core.client.post(API_ROOT + "/enable", json=body) - assert denied.status_code == 409 - assert denied.json()["detail"] == "continuous_processing_unavailable" - resume = await core.client.post( - API_ROOT + "/resume", - json={ - "operation_id": str(uuid4()), - "expected_policy_revision": 1, - "user_confirmed": True, - }, - ) - assert resume.status_code == 409 - assert resume.json()["detail"] == "continuous_processing_unavailable" - assert core.policy_current() is None - assert core.app.state.model_profile_store.active_identity() is None - assert core.app.state.memory_task_store.list() == [] and not core.bridge.requests - - asyncio.run(scenario()) - - -def test_actual_activation_service_rejects_preprobe_revision_then_commits_exact_profile(core): - async def scenario(): - async with core.running(): - core.grant_source() - await core.save_and_probe() - service = core.app.state.model_policy_activation - assert core.profile["revision"] > 1 # Probe measured the embedding width. - body = dict( - operation_id=str(uuid4()), - profile_id=core.profile["profile_id"], - profile_revision=1, - expected_policy_revision=0, - expected_legacy_revision=0, - backlog_mode="none", - user_confirmed=True, - ) - with pytest.raises( - continuous_policy.ContinuousPolicyError, match="model_profile_changed" - ): - await asyncio.to_thread(service.enable, **body) - assert core.policy_current() is None - assert core.app.state.model_profile_store.active_identity() is None - body["profile_revision"] = core.profile["revision"] - result = await asyncio.to_thread(service.enable, **body) - assert result.created and result.policy.profile.revision == core.profile["revision"] - assert core.policy_current() == result.policy - record = core.put_complete("after-atomic-service") - tasks = await core.run_prepared(record) - assert all(t.status == MemoryTaskStatus.COMPLETED for t in tasks) - await core.assert_governed(record) - - asyncio.run(scenario()) - - -def test_actual_activation_conflict_does_not_partially_activate_or_create_policy(core): - async def scenario(): - async with core.running(): - await core.save_and_probe() - service = core.app.state.model_policy_activation - with pytest.raises( - continuous_policy.ContinuousPolicyError, match="policy_revision_changed" - ): - await asyncio.to_thread( - service.enable, - operation_id=str(uuid4()), - profile_id=core.profile["profile_id"], - profile_revision=core.profile["revision"], - expected_policy_revision=9, - expected_legacy_revision=0, - backlog_mode="none", - user_confirmed=True, - ) - assert core.app.state.model_profile_store.active_identity() is None - assert core.policy_current() is None - assert core.app.state.memory_task_store.list() == [] and not core.bridge.requests - - asyncio.run(scenario()) - - -def test_actual_current_and_enable_require_authentication(core): - async def scenario(): - async with core.running(): - await core.save_and_probe() - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=core.app), - base_url="http://test", - ) as anonymous: - assert (await anonymous.get(API_ROOT)).status_code == 401 - response = await anonymous.post( - API_ROOT + "/enable", - json={ - "operation_id": str(uuid4()), - "profile_id": core.profile["profile_id"], - "profile_revision": core.profile["revision"], - "expected_policy_revision": 0, - "expected_legacy_revision": 0, - "backlog_mode": "none", - "user_confirmed": True, - }, - ) - assert response.status_code == 401 - assert core.policy_current() is None and not core.bridge.requests - - asyncio.run(scenario()) - - -def test_legacy_revoked_policy_not_upgraded_on_restart(core): - async def scenario(): - async with core.running(): - core.grant_source() - record = core.put("legacy") - await core.save_and_probe() - store = core.app.state.model_profile_store - store.activate(core.profile["profile_id"], core.secrets) - permissions = core.app.state.model_content_permissions - receipt = permissions.grant( - permissions.snapshot( - user_id="local", - source_ids=("cursor",), - profile=store.snapshot(core.profile["profile_id"], core.profile["revision"]), - evidence=(record,), - include_future_additions=False, - ), - user_confirmed=True, - expected_revision=0, - ) - revoked = permissions.revoke( - user_id="local", expected_revision=receipt.revision, user_confirmed=True - ) - async with core.running(): - await core.app.state.memory_backfill - assert core.app.state.model_content_permissions.receipt(user_id="local") == revoked - assert core.policy_current() is None - assert core.app.state.memory_task_store.list() == [] and not core.bridge.requests - - asyncio.run(scenario()) - - -def test_restart_recovers_future_ingest_without_lost_notification(core): - async def scenario(): - async with core.running(): - core.grant_source() - await core.save_and_probe() - await core.enable_api() - # Offline arrival uses the same normal store; no synthetic queue/task is injected. - from one_memory.imports.storage import EvidenceStore - - record = ( - EvidenceStore(core.database) - .put( - EvidenceInput( - source_id="cursor", - conversation_id="offline", - message_id="offline-new", - role="user", - written_by=WrittenBy.PERSON, - created_at=OLD, - verbatim=TEXT, - origin_meta={EvidenceOrigin.FILE: OriginMetadata()}, - ) - ) - .record - ) - EvidenceStore(core.database).put( - EvidenceInput( - source_id="cursor", - conversation_id="offline", - message_id="offline-new-reply", - role="assistant", - written_by=WrittenBy.AGENT, - created_at=OLD + timedelta(microseconds=1), - verbatim=TEXT + " reply", - origin_meta={EvidenceOrigin.FILE: OriginMetadata()}, - ) - ) - async with core.running(): - await core.wait_completed() - await core.assert_governed(record) - - asyncio.run(scenario()) - - -def test_api_pause_resume_revoke_keeps_original_boundary_and_refuses_revoked_resume(core): - async def scenario(): - async with core.running(): - await core.save_and_probe() - original, _ = await core.enable_api() - for action, state in ( - ("pause", "paused"), - ("resume", "enabled"), - ("revoke", "revoked"), - ): - current = core.policy_current() - response = await core.client.post( - API_ROOT + "/" + action, - json={ - "operation_id": str(uuid4()), - "expected_policy_revision": current.revision, - "user_confirmed": True, - }, - ) - assert response.status_code in (200, 202), response.text - changed = core.policy_current() - assert changed.state == state and changed.after_seq == original.after_seq - denied = await core.client.post( - API_ROOT + "/resume", - json={ - "operation_id": str(uuid4()), - "expected_policy_revision": changed.revision, - "user_confirmed": True, - }, - ) - assert denied.status_code == 409 - - asyncio.run(scenario()) - - -def test_reauthorize_with_backlog_includes_conversations_received_while_revoked(core): - async def scenario(): - async with core.running(): - core.grant_source() - await core.save_and_probe() - original, _ = await core.enable_api("none") - revoked_response = await core.client.post( - API_ROOT + "/revoke", - json={ - "operation_id": str(uuid4()), - "expected_policy_revision": original.revision, - "user_confirmed": True, - }, - ) - assert revoked_response.status_code == 200, revoked_response.text - revoked = core.policy_current() - record = core.put_complete("received-while-revoked", conversation="revoked-backlog") - core.app.state.memory_processing_supervisor.enqueue_evidence(record) - await asyncio.sleep(1) - assert core.bridge.requests == [] - - response = await core.client.post( - API_ROOT + "/enable", - json={ - "operation_id": str(uuid4()), - "profile_id": core.profile["profile_id"], - "profile_revision": core.profile["revision"], - "expected_policy_revision": revoked.revision, - "expected_legacy_revision": revoked.legacy_revision, - "backlog_mode": "include", - "user_confirmed": True, - }, - ) - assert response.status_code in (200, 202), response.text - await core.wait_completed(timeout=4) - await core.assert_governed(record) - assert len(core.bridge.requests) == 1 - - asyncio.run(scenario()) - - -@pytest.mark.parametrize( - ("authority", "new_conversation"), - [ - ("continuous", "new-event-after-unknown"), - ("continuous", "unknown-outcome"), - ("legacy", "new-event-after-unknown"), - ], -) -def test_sdk_admitted_running_restart_preserves_unknown_without_blocking_new_event( - core, - monkeypatch, - authority, - new_conversation, -): - """Interrupt AFTER controlled SDK admission, not an unsent pending task. - - CancelledError emulates loss of the caller before the result is known; the - real runner has already claimed and bound the task. No status is forged. - Legacy is a positive control for the existing RUNNING -> PENDING behavior. - """ - - async def scenario(): - admitted = asyncio.Event() - never_return = asyncio.Event() - normal_ingest = core.bridge.ingest - - async def accepted_then_interrupted(request): - core.bridge.requests.append(request) - admitted.set() - await never_return.wait() - pytest.fail("Interrupted SDK invocation must not produce a result") - - async with core.running(): - core.grant_source() - await core.save_and_probe() - if authority == "continuous": - core.kernel_enable() - original = core.put_complete("sdk-admitted", conversation="unknown-outcome") - if authority == "legacy": - profiles = core.app.state.model_profile_store - profiles.activate(core.profile["profile_id"], core.secrets) - permissions = core.app.state.model_content_permissions - permissions.grant( - permissions.snapshot( - user_id="local", - source_ids=("cursor",), - profile=profiles.snapshot( - core.profile["profile_id"], core.profile["revision"] - ), - evidence=core.turn_records(original), - include_future_additions=True, - ), - user_confirmed=True, - expected_revision=0, - ) - supervisor = core.app.state.memory_processing_supervisor - prepared = await asyncio.to_thread(supervisor.prepare_conversation, original) - assert len(prepared) == 1 - task_id = prepared[0].task_id - monkeypatch.setattr(core.bridge, "ingest", accepted_then_interrupted) - running = asyncio.create_task(supervisor._create_runner().run(task_id)) - try: - await asyncio.wait_for(admitted.wait(), timeout=3) - task = core.app.state.memory_task_store.require(task_id) - assert task.status == MemoryTaskStatus.RUNNING and task.attempts == 1 - assert len(core.bridge.requests) == 1 - assert original.evidence_id in core.bridge.requests[0].evidence_ids - assert core.app.state.memory_governance_store.list(user_id="local") == [] - finally: - running.cancel() - with pytest.raises(asyncio.CancelledError): - await running - stranded = core.app.state.memory_task_store.require(task_id) - assert stranded.status == MemoryTaskStatus.RUNNING and stranded.attempts == 1 - monkeypatch.setattr(core.bridge, "ingest", normal_ingest) - - with closing(sqlite3.connect(core.database)) as db: - row = db.execute( - "SELECT status,attempts FROM memory_tasks WHERE task_id=?", (task_id,) - ).fetchone() - assert row == ("running", 1), "Fixture must preserve an actually admitted RUNNING task" - - async with core.running(): - await asyncio.wait_for(core.app.state.memory_backfill, timeout=4) - if authority == "legacy": - await core.wait_completed(timeout=4) - recovered = core.app.state.memory_task_store.require(task_id) - assert recovered.status == MemoryTaskStatus.COMPLETED - assert recovered.attempts == 2 and len(core.bridge.requests) == 2 - await core.assert_governed(original) - return - - recovered = core.app.state.memory_task_store.require(task_id) - assert recovered.last_error_code == "model_content_outcome_unknown", recovered - assert recovered.status not in { - MemoryTaskStatus.PENDING, - MemoryTaskStatus.RUNNING, - MemoryTaskStatus.COMPLETED, - }, recovered - assert recovered.attempts == 1 - assert len(core.bridge.requests) == 1, "Same admitted event was sent again on startup" - - # Repeated import observation and notification must not mint a new - # request identity for the already admitted event. - repeated = core.put("sdk-admitted", conversation="unknown-outcome") - assert repeated.evidence_id == original.evidence_id - core.app.state.memory_processing_supervisor.enqueue_evidence(repeated) - fresh = core.put_complete("new-event-positive", conversation=new_conversation) - core.app.state.memory_processing_supervisor.enqueue_evidence(fresh) - end = asyncio.get_running_loop().time() + 4 - completed = [] - while asyncio.get_running_loop().time() < end: - completed = [ - task - for task in core.app.state.memory_task_store.list() - if task.input_ref == fresh.evidence_id - and task.status == MemoryTaskStatus.COMPLETED - ] - if completed: - break - await asyncio.sleep(0.025) - assert completed, "Unknown task must not starve a newly permitted event" - # Both debounce callbacks have had a chance to run; new work is the - # positive control that the restarted queue is actually live. - await asyncio.sleep(1) - old_calls = [r for r in core.bridge.requests if original.evidence_id in r.evidence_ids] - new_calls = [r for r in core.bridge.requests if fresh.evidence_id in r.evidence_ids] - assert len(old_calls) == len(new_calls) == 1 - new_context = new_calls[0].source_context - if new_context: - assert original.evidence_id not in { - item.evidence_id - for item in ( - *new_context.current, - *new_context.history, - *new_context.supporting, - ) - }, "Unknown old event must not be resent as context for the new event" - unchanged = core.app.state.memory_task_store.require(task_id) - assert unchanged.attempts == 1 - assert unchanged.last_error_code == "model_content_outcome_unknown" - await core.assert_governed(fresh) - assert all( - original.evidence_id not in memory.evidence_ids - for memory in core.app.state.memory_governance_store.list(user_id="local") - ) - - asyncio.run(scenario()) - - -def test_public_none_policy_excludes_old_history_and_does_not_replay_legacy_startup(core): - async def scenario(): - async with core.running(): - core.grant_source() - old = core.put_complete("old-neighbor", conversation="mixed-public") - await core.save_and_probe() - await core.enable_api("none") - fresh = core.put_complete("public-future", conversation="mixed-public") - core.app.state.memory_processing_supervisor.enqueue_evidence(fresh) - await core.wait_completed(timeout=4) - await core.assert_governed(fresh) - assert all(old.evidence_id not in r.evidence_ids for r in core.bridge.requests) - for request in core.bridge.requests: - if request.source_context: - context = request.source_context - assert old.evidence_id not in { - e.evidence_id - for e in (*context.current, *context.history, *context.supporting) - } - calls = len(core.bridge.requests) - async with core.running(): - await core.app.state.memory_backfill - await asyncio.sleep(2.2) # Covers the coordinator's normal monitor interval. - assert len(core.bridge.requests) == calls - assert core.app.state.model_content_permissions.receipt(user_id="local") is None - assert all( - t.status == MemoryTaskStatus.COMPLETED - for t in core.app.state.memory_task_store.list() - ) - - asyncio.run(scenario()) - - -def test_public_revoke_after_sdk_admission_blocks_publish_and_future_work(core, monkeypatch): - async def scenario(): - admitted, release = asyncio.Event(), asyncio.Event() - original_ingest = core.bridge.ingest - - async def hold_response(request): - admitted.set() - await release.wait() - return await original_ingest(request) - - async with core.running(): - core.grant_source() - await core.save_and_probe() - policy, _ = await core.enable_api() - monkeypatch.setattr(core.bridge, "ingest", hold_response) - record = core.put_complete("public-in-flight", conversation="revoke-public") - core.app.state.memory_processing_supervisor.enqueue_evidence(record) - try: - await asyncio.wait_for(admitted.wait(), 4) - current = core.app.state.memory_task_store.list() - assert any(t.status == MemoryTaskStatus.RUNNING for t in current) - revoked = await core.client.post( - API_ROOT + "/revoke", - json={ - "operation_id": str(uuid4()), - "expected_policy_revision": policy.revision, - "user_confirmed": True, - }, - ) - assert revoked.status_code == 200, revoked.text - assert revoked.json()["state"] == "revoked" - finally: - release.set() - deadline = asyncio.get_running_loop().time() + 4 - while asyncio.get_running_loop().time() < deadline: - tasks = core.app.state.memory_task_store.list() - if tasks and all(t.status != MemoryTaskStatus.RUNNING for t in tasks): - break - await asyncio.sleep(0.025) - assert tasks and all(t.status == MemoryTaskStatus.CANCELLED for t in tasks) - assert len(core.bridge.requests) == 1 - assert core.app.state.memory_governance_store.list(user_id="local") == [] - next_record = core.put("after-revoke", conversation="after-revoke") - core.app.state.memory_processing_supervisor.enqueue_evidence(next_record) - await asyncio.sleep(2.2) - assert len(core.bridge.requests) == 1 - assert core.app.state.memory_governance_store.list(user_id="local") == [] - - asyncio.run(scenario()) - - -def test_public_pause_accumulation_resumes_through_actual_callback(core): - async def scenario(): - async with core.running(): - core.grant_source() - await core.save_and_probe() - policy, _ = await core.enable_api() - paused = await core.client.post( - API_ROOT + "/pause", - json={ - "operation_id": str(uuid4()), - "expected_policy_revision": policy.revision, - "user_confirmed": True, - }, - ) - assert paused.status_code == 200, paused.text - record = core.put_complete("while-paused", conversation="paused") - core.app.state.memory_processing_supervisor.enqueue_evidence(record) - await asyncio.sleep(1) - assert core.bridge.requests == [] - resumed = await core.client.post( - API_ROOT + "/resume", - json={ - "operation_id": str(uuid4()), - "expected_policy_revision": paused.json()["revision"], - "user_confirmed": True, - }, - ) - assert resumed.status_code == 200, resumed.text - assert resumed.json()["after_seq"] == policy.after_seq - await core.wait_completed(timeout=4) - await core.assert_governed(record) - assert len(core.bridge.requests) == 1 - - asyncio.run(scenario()) - - -def test_public_memory_note_auto_processes_under_continuous_policy_without_legacy_grant(core): - async def scenario(): - async with core.running(): - await core.save_and_probe() - policy, _ = await core.enable_api("none") - with closing(sqlite3.connect(core.database)) as db: - assert ( - db.execute( - "SELECT 1 FROM source_grants WHERE user_id='local' " - "AND source_id='one-memory-manual'" - ).fetchone() - is None - ) - note = {"request_id": str(uuid4()), "text": TEXT, "scope": "all-apps"} - response = await core.client.post("/api/v1/memory-notes", json=note) - assert response.status_code == 201, response.text - saved = response.json() - assert saved["state"] in { - "pending", - "running", - "waiting_for_model", - "ready", - }, saved - assert saved["error"] is None - record = core.app.state.evidence_store.get(saved["evidence_id"]) - assert record is not None and record.source_id == "one-memory-manual" - assert record.verbatim == TEXT and record.written_by == WrittenBy.PERSON - with closing(sqlite3.connect(core.database)) as db: - db.execute("BEGIN") - grant = db.execute( - "SELECT root_path,revoked_at FROM source_grants WHERE user_id='local' " - "AND source_id='one-memory-manual'" - ).fetchone() - assert grant == (str(core.data), None) - event = ingest_journal.current(db, "local", record.evidence_id) - assert event is not None and event.seq > policy.after_seq - assert event.generation_eligible and ingest_journal.matches_current(db, event) - continuous_policy.check(db, policy, evidence_ids=(record.evidence_id,)) - assert core.app.state.model_content_permissions.receipt(user_id="local") is None - # No /process request or second content consent: the normal POST owns scheduling. - await core.wait_completed(timeout=4) - await core.assert_governed(record) - final = await core.client.get(f"/api/v1/memory-notes/{record.evidence_id}") - assert final.status_code == 200, final.text - assert final.json()["state"] == "ready" and final.json()["memory_ids"] - assert final.json()["error"] is None - listing = await core.client.get("/api/v1/memory-notes") - assert listing.status_code == 200 - assert any( - item["evidence_id"] == record.evidence_id and item["state"] == "ready" - for item in listing.json() - ) - calls = [r for r in core.bridge.requests if record.evidence_id in r.evidence_ids] - assert len(calls) == 1 and calls[0].source_id == "one-memory-manual" - repeated = await core.client.post("/api/v1/memory-notes", json=note) - assert repeated.status_code == 201, repeated.text - assert repeated.json()["evidence_id"] == record.evidence_id - assert repeated.json()["state"] == "ready" - assert core.app.state.model_content_permissions.receipt(user_id="local") is None - assert core.policy_current() == policy - assert len(core.bridge.requests) == 1 - - asyncio.run(scenario()) - - -def test_the_summary_tells_running_rows_from_work_actually_being_done(core): - """`running` rows are not the current concurrency. - - A task is marked running before anything slow happens, so a worker that - gives up — a timeout, a crash between steps — leaves a row that still says - running. Reporting that as "what is running" is how a stalled queue looks - busy, so the count stays as `running` and rows nobody owns are broken out - as `stranded_running`. - """ - - async def scenario(): - async with core.running(): - core.grant_source() - await core.save_and_probe() - core.kernel_enable() - # Make the difference real: a row the store marks running that no - # worker owns. Without this the two numbers are both zero and a - # hardcoded zero would pass just as well. - stranded = core.app.state.memory_task_store - record = core.put("stranded-task") - task = stranded.create( - request_id=f"native:{record.evidence_id}:{record.revision}:strand", - user_id=record.user_id, - kind="extract", - input_ref=record.evidence_id, - profile=core.app.state.model_profile_store.list(core.secrets)[0], - ) - stranded.claim(task.task_id) - assert stranded.require(task.task_id).status == "running" - - response = await core.client.get("/api/v1/memory-processing") - assert response.status_code == 200, response.text - summary = response.json() - for field in ("running", "in_flight_count", "stranded_running"): - assert field in summary, (field, summary) - assert summary["stranded_running"] >= 0 - assert summary["stranded_running"] == max( - 0, summary["running"] - summary["in_flight_count"] - ) - # The strand is real here, so it must be visible: this is the - # assertion a hardcoded zero fails. - assert summary["stranded_running"] >= 1, summary - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_delete_no_conversation_freeze.py b/memhub/apps/core/tests/test_delete_no_conversation_freeze.py deleted file mode 100644 index 2617b18..0000000 --- a/memhub/apps/core/tests/test_delete_no_conversation_freeze.py +++ /dev/null @@ -1,223 +0,0 @@ -"""删掉一条记忆,不得让同一对话之后的新 turn 停止沉淀(RC10)。 - -沙箱真 HY 实验里实测过:人删掉一条记忆后,同一对话之后的新消息也不再成为记忆。 -处理一段对话时,每个 chunk 都要过一遍上下文预检;已处理过的旧 turn 的证据 -正托着被删的那条记忆,于是预检在旧 chunk 上抛 evidence_changed,整段对话中止, -新 turn 连同它一起被丢下。live 路径还把这个错误吞成一行日志,实时同步就此静默停住。 - -删除删的是那条记忆实体,不是"永远不许再说这件事": -新 turn 是新的陈述,应当照常沉淀;同一段旧原话(按证据 id)不得把已删记忆抽回。 - -三条入口各守一条:即时 prepare、live 到达、backfill 回放。 -全部走真实的临时 Core、真实的连续处理授权与守卫,只把 HY 换成可控的替身。 -""" - -import asyncio - -import pytest - -from one_memory.imports.models import WrittenBy -from one_memory.imports.runner import live_sync_arrival -from one_memory.memory_governance import MemoryStatus -from one_memory.memory_tasks import MemoryTaskStatus -from tests.test_continuous_processing_integration import IntegrationCore - - -@pytest.fixture -def core(tmp_path, monkeypatch): - return IntegrationCore(tmp_path, monkeypatch) - - -def _memory_for(core, record): - return core.app.state.memory_governance_store.get( - "integration-hy-" + record.evidence_id, user_id="local" - ) - - -async def _first_turn_then_delete(core): - """第一个 turn 沉淀成记忆,然后人把它删掉。返回那一轮的原话与连续授权。""" - core.grant_source() - await core.save_and_probe() - receipt = core.kernel_enable() - first = core.put_complete("first-turn") - results = await core.run_prepared(first) - assert [t.status for t in results] == [MemoryTaskStatus.COMPLETED] - memory = _memory_for(core, first) - # 正向前提:第一轮确实沉淀出了一条可删的记忆。 - assert memory is not None and memory.status is MemoryStatus.CONFIRMED - forgotten = [] - - async def forget_in_hy(memory_id): - # HY 那头的遗忘另有守护;这里只要治理侧的删除照常落地。 - forgotten.append(memory_id) - return {"deleted": True} - - core.app.state.memory_processing_supervisor.delete_hy_memory = forget_in_hy - deleted = await core.client.post( - f"/api/v1/memories/{memory.memory_id}/actions/delete", - json={"request_id": "delete-first", "expected_revision": memory.revision}, - ) - assert deleted.status_code == 200, deleted.text - assert _memory_for(core, first).status is MemoryStatus.DELETED - assert forgotten == [memory.memory_id] - return first, receipt - - -def test_prepare_after_a_deletion_still_settles_the_new_turn(core): - async def scenario(): - async with core.running(): - first, _receipt = await _first_turn_then_delete(core) - second = core.put_complete("second-turn") - supervisor = core.app.state.memory_processing_supervisor - - prepared = await asyncio.to_thread(supervisor.prepare_conversation, second) - # 已完成的旧 turn 在合并前剔除、不再预检重跑;只有新 turn 是待做的。 - by_input = {t.input_ref: t for t in prepared} - assert set(by_input) == {second.evidence_id} - pending = [t for t in prepared if t.status is not MemoryTaskStatus.COMPLETED] - assert [t.input_ref for t in pending] == [second.evidence_id] - for task in pending: - await supervisor._create_runner().run(task.task_id) - - memory = _memory_for(core, second) - assert memory is not None and memory.status is MemoryStatus.CONFIRMED - assert _memory_for(core, first).status is MemoryStatus.DELETED - # 新 turn 照常处理,但被删的记忆和它托着的原话都不进这一轮的上下文。 - sent = next(r for r in core.bridge.requests if second.evidence_id in r.evidence_ids) - context = sent.source_context - assert context is not None - assert first.evidence_id not in { - r.evidence_id for r in (*context.history, *context.supporting) - } - deleted_id = "integration-hy-" + first.evidence_id - assert deleted_id not in {m.governed_memory_id for m in context.known_memories} - - asyncio.run(scenario()) - - -async def _live_settled(supervisor, tasks): - """等防抖、准备与派发都走完,而不是等固定秒数:慢机器上也不会早判。""" - loop = asyncio.get_running_loop() - end = loop.time() + 30 - while loop.time() < end: - idle = not supervisor._debounces and not supervisor._preparations - busy = any( - t.status in {MemoryTaskStatus.PENDING, MemoryTaskStatus.RUNNING} for t in tasks.list() - ) - if idle and not busy: - return - await asyncio.sleep(0.05) - pytest.fail("live 到达的准备与派发没有在界限内走完") - - -def test_live_arrival_after_a_deletion_still_settles_the_new_turn(core): - async def scenario(): - async with core.running(): - first, _receipt = await _first_turn_then_delete(core) - second = core.put("second-turn") - reply = core.put( - "second-turn-reply", - conversation="same-conversation", - role="assistant", - written_by="agent", - ) - supervisor = core.app.state.memory_processing_supervisor - assert _memory_for(core, second) is None - - token = live_sync_arrival.set(True) - try: - supervisor.enqueue_evidence(second) - supervisor.enqueue_evidence(reply) - finally: - live_sync_arrival.reset(token) - await _live_settled(supervisor, core.app.state.memory_task_store) - - memory = _memory_for(core, second) - assert memory is not None and memory.status is MemoryStatus.CONFIRMED - assert _memory_for(core, first).status is MemoryStatus.DELETED - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("deleted", [True, False], ids=["deleted", "control-kept"]) -def test_reprocessing_the_same_old_evidence_does_not_bring_a_deleted_memory_back(core, deleted): - """按证据 id 挡住:同一段旧原话再交给 HY 一次,也不得重新长出被删的那条记忆。 - - 对照组不删:同一条重跑路径确实会把同一段原话再抽成一条新记忆—— - 证明挡住它的是删除,而不是这条路径本来就到不了写入。 - """ - - async def scenario(): - async with core.running(): - if deleted: - first, _receipt = await _first_turn_then_delete(core) - else: - core.grant_source() - await core.save_and_probe() - core.kernel_enable() - first = core.put_complete("first-turn") - await core.run_prepared(first) - original = core.bridge.ingest - - async def reextract(request): - # 真 HY 重抽同一段原话时给的是新 id,内容相同。 - result = await original(request) - return result.model_copy( - update={ - "memories": tuple( - m.model_copy(update={"memory_id": "reextracted-" + m.memory_id}) - for m in result.memories - ) - } - ) - - core.bridge.ingest = reextract - tasks = core.app.state.memory_task_store - done = next(t for t in tasks.list() if t.input_ref == first.evidence_id) - tasks.requeue_completed(done.task_id, reason="reprocess_same_evidence") - - finished = await core.app.state.memory_processing_supervisor._create_runner().run( - done.task_id - ) - - back = core.app.state.memory_governance_store.get( - "reextracted-integration-hy-" + first.evidence_id, user_id="local" - ) - if deleted: - assert back is None or back.status is not MemoryStatus.CONFIRMED - assert finished.status is not MemoryTaskStatus.COMPLETED - else: - assert finished.status is MemoryTaskStatus.COMPLETED, finished.last_error_code - assert back is not None and back.status is MemoryStatus.CONFIRMED - - asyncio.run(scenario()) - - -def test_backfill_after_a_deletion_still_settles_the_new_turn(core): - """backfill 修复前就不冻结:已完成的旧 chunk 在入队时跳过,出错的 chunk 也只丢它自己。 - - 只拿掉其中一道它仍是绿的;两道都拿掉时才红(实测)。 - 它守的是 backfill 这一路的结果,不单独守"已完成跳过"——那一道由 prepare 的守卫看住。 - """ - - async def scenario(): - async with core.running(): - first, receipt = await _first_turn_then_delete(core) - second = core.put_complete("second-turn") - core.put( - "second-turn-answer", - conversation=second.conversation_id, - role="assistant", - written_by=WrittenBy.AGENT, - ) - supervisor = core.app.state.memory_processing_supervisor - assert _memory_for(core, second) is None - - await supervisor.backfill_off_thread(expected_permission_revision=receipt.revision) - await core.wait_completed(minimum_tasks=2) - - memory = _memory_for(core, second) - assert memory is not None and memory.status is MemoryStatus.CONFIRMED - assert _memory_for(core, first).status is MemoryStatus.DELETED - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_detailed_logging.py b/memhub/apps/core/tests/test_detailed_logging.py index 26a6cd2..c095b51 100644 --- a/memhub/apps/core/tests/test_detailed_logging.py +++ b/memhub/apps/core/tests/test_detailed_logging.py @@ -1,10 +1,8 @@ import json import logging from importlib import import_module -from inspect import getsource from pathlib import Path -from one_memory.hy_permission_transport import HyPermissionTransport from one_memory.hy_runtime_config import ( configure_hy_runtime_environment, detailed_logging_enabled, @@ -66,13 +64,6 @@ def test_info_collects_hy_info_without_enabling_debug_pipeline_content( assert __import__("os").environ["MEMORY_LOG_LLM_CONTENT"] == "false" -def test_transport_does_not_reject_debug_wire_logging() -> None: - source = getsource(HyPermissionTransport) - - assert "_check_wire_logging" not in source - assert "unsafe_wire_logging" not in source - - def test_live_debug_switch_makes_sdk_pipeline_writer_keep_original_content( tmp_path: Path, ) -> None: diff --git a/memhub/apps/core/tests/test_evidence_revision_read_review.py b/memhub/apps/core/tests/test_evidence_revision_read_review.py deleted file mode 100644 index 6db6676..0000000 --- a/memhub/apps/core/tests/test_evidence_revision_read_review.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Read invalidation review: real guard/provenance, controlled HY, no model/network.""" - -import asyncio -import sqlite3 -from contextlib import closing - -import pytest - -from one_memory import context_routes -from one_memory.hy_memory_bridge import HyMemoryOutput -from one_memory.imports.models import WrittenBy -from one_memory.memory_governance import MemoryStatus -from one_memory.memory_tasks import evidence_ref -from tests.test_continuous_processing_integration import IntegrationCore -from tests.test_governance_authority_review import WORKSPACE, prepare, process, put, search - -OLD = "revisionneedle the project deploys only on Windows" -NEW = "revisionneedle the project deploys only on macOS" - - -async def derived(core): - await prepare(core) - original = put(core, "edited-turn", OLD, WrittenBy.PERSON) - put(core, "edited-turn-reply", "Acknowledged.", WrittenBy.AGENT) - outputs = await process( - core, - original, - [ - HyMemoryOutput( - memory_id="review-old-claim", - content=OLD, - layer="L2_FACT", - owner="user", - evidence_ids=(original.evidence_id,), - ) - ], - ) - output = outputs[0] - assert output.support_refs == (evidence_ref(original),) - assert output.memory_revision == 1 - before = await search(core, "revisionneedle", [WORKSPACE]) - assert before.status_code == 200 and len(before.json()["items"]) == 1 - assert before.json()["items"][0]["content"] == OLD - return original, output - - -@pytest.mark.parametrize("during_read", [False, True], ids=["before-query", "before-publish"]) -def test_revised_evidence_does_not_supply_old_derived_claim(tmp_path, monkeypatch, during_read): - async def scenario(): - core = IntegrationCore(tmp_path, monkeypatch) - async with core.running(): - original, output = await derived(core) - revised = None - - def revise(): - nonlocal revised - revised = put(core, "edited-turn", NEW, WrittenBy.PERSON) - assert revised.evidence_id == original.evidence_id - assert revised.revision == original.revision + 1 - assert revised.content_hash != original.content_hash - - if during_read: - read = context_routes.read_memory_context - - def revision_after_selection(*args, **kwargs): - result = read(*args, **kwargs) - assert len(result.items) == 1 - revise() - return result - - monkeypatch.setattr(context_routes, "read_memory_context", revision_after_selection) - else: - revise() - response = await search(core, "revisionneedle", [WORKSPACE]) - assert response.status_code == 200 - assert revised is not None - stored = core.app.state.memory_task_store.outputs(output.task_id)[0] - assert stored.support_refs == output.support_refs - memory = core.app.state.memory_governance_store.get(output.memory_id, user_id="local") - assert memory.status is MemoryStatus.CONFIRMED and memory.content == OLD - assert len(core.bridge.requests) == 1 - with closing(sqlite3.connect(core.database)) as db: - versions = db.execute( - "SELECT revision FROM evidence_versions WHERE evidence_id=? ORDER BY revision", - (original.evidence_id,), - ).fetchall() - events = db.execute( - "SELECT evidence_revision FROM evidence_ingest_events " - "WHERE evidence_id=? ORDER BY seq", - (original.evidence_id,), - ).fetchall() - assert versions == [(1,), (2,)] and events == [(1,), (2,)] - print("revision review:", during_read, output.support_refs, response.json()) - assert response.json()["items"] == [] - library = await core.client.get("/api/v1/memory-library") - # D22: the total skips the per-row freshness scan (product accepted a - # small drift), but the stale claim itself must never be listed. - assert library.status_code == 200 and library.json()["items"] == [] - home = await core.client.get("/api/v1/home-summary") - # Same D22 drift for the homepage count; the latest card must not be it. - assert home.status_code == 200 and home.json()["latest"] is None - assert (await core.client.get("/api/v1/memories")).json() == [] - detail = await core.client.get("/api/v1/memories/" + output.memory_id) - assert detail.status_code == 403 - - asyncio.run(scenario()) - - -def test_explicit_correction_is_distinguishable_from_unchanged_sdk_projection( - tmp_path, monkeypatch -): - async def scenario(): - core = IntegrationCore(tmp_path, monkeypatch) - async with core.running(): - original, output = await derived(core) - corrected = core.app.state.person_writes.correct( - output.memory_id, - user_id="local", - request_id="explicit-user-correction", - expected_revision=output.memory_revision, - content=NEW, - ) - assert corrected.revision > output.memory_revision - assert corrected.origin.value == "extracted" - assert corrected.evidence_ids == (original.evidence_id,) - with closing(sqlite3.connect(core.database)) as db: - operation = db.execute( - "SELECT operation FROM governed_memory_mutations WHERE request_id=?", - ("explicit-user-correction",), - ).fetchone() - version = db.execute( - "SELECT action,content FROM governed_memory_versions " - "WHERE memory_id=? AND revision=?", - (corrected.memory_id, corrected.revision), - ).fetchone() - assert operation == ("correct",) and version == ("correct", NEW) - stored = core.app.state.memory_task_store.outputs(output.task_id)[0] - assert stored.memory_revision == 1 and stored.support_refs == output.support_refs - # Inspect metadata only: no new policy that old refs authorize a manual correction. - print("correction distinction:", corrected.revision, stored.memory_revision, operation) - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_frozen_core_layout.py b/memhub/apps/core/tests/test_frozen_core_layout.py index c514240..06e3f44 100644 --- a/memhub/apps/core/tests/test_frozen_core_layout.py +++ b/memhub/apps/core/tests/test_frozen_core_layout.py @@ -79,9 +79,3 @@ def test_default_distribution_avoids_per_launch_self_extraction(monkeypatch): calls, _, _ = evaluate_spec(monkeypatch, None) assert calls["exe"][1]["exclude_binaries"] is True assert calls["collect"][1]["name"] == "one-memory-runtime" - - -@pytest.mark.parametrize("layout", ["folder", "single-file"]) -def test_runtime_permission_factory_is_explicitly_bundled(monkeypatch, layout): - calls, _, _ = evaluate_spec(monkeypatch, layout) - assert "one_memory.hy_permission_context" in calls["analysis"][1]["hiddenimports"] diff --git a/memhub/apps/core/tests/test_governance_authority_review.py b/memhub/apps/core/tests/test_governance_authority_review.py deleted file mode 100644 index f8af235..0000000 --- a/memhub/apps/core/tests/test_governance_authority_review.py +++ /dev/null @@ -1,201 +0,0 @@ -"""D04 authority and workspace scope through the real continuous permission guard. - -Only HY/model transport is controlled. These assert the documented boundary, -not that a confirmed machine projection constitutes human factual acceptance. -""" - -import asyncio -import json -from datetime import UTC, datetime - -import pytest - -from one_memory.hy_memory_bridge import HyMemoryIngestResult, HyMemoryOutput -from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy -from one_memory.memory_governance import MemoryDraft, MemoryKind, MemoryOrigin, MemoryStatus -from one_memory.memory_tasks import MemoryTaskStatus -from tests.test_continuous_processing_integration import IntegrationCore - -WORKSPACE = "workspace:v1:" + "a" * 64 -OTHER_WORKSPACE = "workspace:v1:" + "b" * 64 -AGENT_TEXT = "authorityneedle assistant proposes project delivery without user acceptance" -PROJECT_TEXT = "projectneedle this project requires a staging approval before deployment" - - -def envelope(scopes, tools=("om_recall",)): - # Simulated authenticated Broker envelope, not a real host installation/grant. - return { - "x-one-memory-read-context": "external", - "x-one-memory-installation-id": "00000000-0000-0000-0000-000000000001", - "x-one-memory-installation-revision": "1", - "x-one-memory-allowed-scopes": json.dumps(scopes), - "x-one-memory-allowed-tools": json.dumps(tools), - } - - -async def search(core, query, scopes, tools=("om_recall",)): - return await core.client.post( - "/api/v1/context/search", - json={"query": query, "kinds": ["memory"]}, - headers=envelope(scopes, tools), - ) - - -def put(core, key, text, author): - return core.app.state.evidence_store.put( - EvidenceInput( - user_id="local", - source_id="cursor", - conversation_id="authority-review", - message_id=key, - workspace=WORKSPACE, - role="assistant" if author is WrittenBy.AGENT else "user", - written_by=author, - created_at=datetime.now(UTC), - verbatim=text, - origin_meta={EvidenceOrigin.FILE: OriginMetadata()}, - ) - ).record - - -async def prepare(core): - core.grant_source() - core.app.state.config_store.set_sync("cursor", True) - await core.save_and_probe() - await core.enable_api() - runner = core.app.state.memory_processing_supervisor._create_runner() - assert runner._permission_guard is not None - - -async def process(core, record, outputs): - async def ingest(request): - core.bridge.requests.append(request) - assert record.evidence_id in request.evidence_ids - return HyMemoryIngestResult(success=True, memories=tuple(outputs)) - - core.bridge.ingest = ingest - results = await core.run_prepared(record) - assert results and all(task.status == MemoryTaskStatus.COMPLETED for task in results) - assert core.bridge.requests and core.bridge.leases - provenance = [ - output - for task in results - for output in core.app.state.memory_task_store.outputs(task.task_id) - ] - assert len(provenance) == len(outputs) - return provenance - - -@pytest.mark.parametrize("existing", [False, True]) -def test_agent_output_lands_usable_without_demoting_an_existing_memory( - tmp_path, monkeypatch, existing -): - async def scenario(): - core = IntegrationCore(tmp_path, monkeypatch) - async with core.running(): - await prepare(core) - person = put(core, "person", "Please review the project proposal", WrittenBy.PERSON) - agent = put(core, "agent", AGENT_TEXT, WrittenBy.AGENT) - governance = core.app.state.memory_governance_store - if existing: - previous = core.app.state.person_writes.create( - MemoryDraft( - user_id="local", - kind=MemoryKind.CHAT, - content=AGENT_TEXT, - scopes=(WORKSPACE,), - evidence_ids=(agent.evidence_id,), - origin=MemoryOrigin.AGENT, - ), - request_id="fixture-prior", - memory_id="prior-agent-memory", - ) - await process( - core, - person, - [ - HyMemoryOutput( - memory_id="hy-agent-proposal", - content=AGENT_TEXT, - layer="L2_FACT", - owner="agent", - evidence_ids=(agent.evidence_id,), - ) - ], - ) - # HY's id is mirrored as its own row; the ledger does not fold it - # into an older row that happens to share the words. - memory = governance.get("hy-agent-proposal", user_id="local") - assert memory is not None and memory.origin is MemoryOrigin.AGENT - assert memory.evidence_ids == (agent.evidence_id,) - assert memory.status is MemoryStatus.CONFIRMED - prior_visible = 0 - if existing: - prior = governance.get("prior-agent-memory", user_id="local") - assert prior.status is MemoryStatus.CONFIRMED - assert prior.revision == previous.revision - prior_visible = 1 - matching = await search(core, "authorityneedle", [WORKSPACE]) - assert matching.status_code == 200, matching.text - assert len(matching.json()["items"]) == 1 + prior_visible - assert memory.scopes == (WORKSPACE,) or WORKSPACE in memory.scopes - denied = await search(core, "authorityneedle", [WORKSPACE], ("om_conversations",)) - assert denied.status_code == 403 - - asyncio.run(scenario()) - - -def test_project_fact_is_shared_across_apps_and_leaves_with_its_grant(tmp_path, monkeypatch): - async def scenario(): - core = IntegrationCore(tmp_path, monkeypatch) - async with core.running(): - await prepare(core) - record = put(core, "project", PROJECT_TEXT, WrittenBy.PERSON) - put(core, "project-reply", "Acknowledged.", WrittenBy.AGENT) - await process( - core, - record, - [ - HyMemoryOutput( - memory_id="hy-project-rule", - content=PROJECT_TEXT, - layer="L2_FACT", - owner="user", - evidence_ids=(record.evidence_id,), - ), - *[ - HyMemoryOutput( - memory_id="hy-profile-" + layer, - content="profileneedle " + layer + " user prefers concise explanations", - layer=layer, - owner="user", - evidence_ids=(record.evidence_id,), - ) - for layer in ("L0_BASIC_INFO", "L6_SCHEMA") - ], - ], - ) - memory = core.app.state.memory_governance_store.get("hy-project-rule", user_id="local") - assert memory is not None and memory.status is MemoryStatus.CONFIRMED - assert memory.origin is MemoryOrigin.EXTRACTED - workspace = await search(core, "projectneedle", [WORKSPACE]) - assert workspace.status_code == 200 and len(workspace.json()["items"]) == 1 - unrelated = await search(core, "projectneedle", [OTHER_WORKSPACE]) - assert unrelated.status_code == 200 and unrelated.json()["items"] == [] - global_read = await search(core, "projectneedle", ["all-apps", OTHER_WORKSPACE]) - assert global_read.status_code == 200 and len(global_read.json()["items"]) == 1 - assert memory.scopes == ("all-apps", WORKSPACE) - profiles = await search(core, "profileneedle", ["all-apps"]) - assert profiles.status_code == 200 and len(profiles.json()["items"]) == 2 - denied = await search(core, "projectneedle", [WORKSPACE], ("om_conversations",)) - assert denied.status_code == 403 - core.app.state.source_grant_store.revoke(user_id="local", source_id="cursor") - revoked = await search(core, "projectneedle", [WORKSPACE]) - assert revoked.status_code == 200 and revoked.json()["items"] == [] - revoked_global = await search(core, "projectneedle", ["all-apps"]) - assert revoked_global.status_code == 200 and revoked_global.json()["items"] == [] - revoked_profile = await search(core, "profileneedle", ["all-apps"]) - assert revoked_profile.status_code == 200 and revoked_profile.json()["items"] == [] - assert len(core.bridge.requests) == 1 - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_health.py b/memhub/apps/core/tests/test_health.py index 46d28ca..b59bc1d 100644 --- a/memhub/apps/core/tests/test_health.py +++ b/memhub/apps/core/tests/test_health.py @@ -3,13 +3,10 @@ import logging import sqlite3 import threading import traceback -from collections.abc import Callable from contextlib import suppress import pytest from httpx import ASGITransport, AsyncClient, Response -from one_memory.model_content_permissions import ModelContentPermissionStore -from pydantic import SecretStr from one_memory.api import create_app from one_memory.config import Settings @@ -18,10 +15,10 @@ from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetad from one_memory.imports.sources.registry import RootCandidateKind from one_memory.imports.storage import EvidenceStore from one_memory.memory_governance import MemoryGovernanceStore -from one_memory.memory_processing import MemoryProcessingSupervisor +from one_memory.memory_server.durable_queue import DurableWriteQueue from one_memory.memory_server.unified_entry import UnifiedWriteIntake from one_memory.model_profiles import ModelCapabilities, ModelProfileInput, ModelProfileStore -from one_memory.secrets import InMemorySecretStore, SecretStoreUnavailableError +from one_memory.secrets import InMemorySecretStore async def request( @@ -86,45 +83,6 @@ def test_core_refuses_to_start_a_database_written_by_a_newer_build(tmp_path, mon asyncio.run(scenario()) -def test_core_stays_ready_when_legacy_policy_upgrade_cannot_read_keychain( - tmp_path, monkeypatch -) -> None: - monkeypatch.setattr("one_memory.model_presets._preset_data", lambda: None) - monkeypatch.setattr( - "one_memory.model_policy_activation.ModelPolicyActivation.upgrade_legacy_none_policy", - lambda _self: (_ for _ in ()).throw( - SecretStoreUnavailableError("Operating system secure storage read backoff") - ), - ) - settings = Settings( - _env_file=None, - data_dir=tmp_path / "data", - source_home=tmp_path / "source", - api_token=SecretStr("test"), - imports_enabled=False, - automatic_memory_processing=False, - live_sync_enabled=False, - ) - app = create_app(settings, secret_store=InMemorySecretStore()) - - async def scenario() -> None: - async with ( - app.router.lifespan_context(app), - AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - headers={"Authorization": "Bearer test"}, - ) as client, - ): - health = await client.get("/api/v1/health") - recall = await client.post("/api/v1/recall", json={"query": "still ready"}) - assert health.status_code == 200 - assert health.json()["status"] == "ok" - assert recall.status_code == 200 - - asyncio.run(scenario()) - - def test_lifespan_starts_the_content_candidate_backfill_and_exposes_running( tmp_path, monkeypatch ) -> None: @@ -211,7 +169,7 @@ def test_lifespan_drains_committed_pending_dispatch_through_enqueue(tmp_path, mo origin_meta={EvidenceOrigin.FILE: OriginMetadata()}, ) ).record - evidence.put( + _ = evidence.put( EvidenceInput( source_id="fixture", conversation_id="conversation", @@ -360,7 +318,7 @@ def test_diagnostic_log_level_allows_desktop_put_preflight() -> None: assert "PUT" in response.headers["access-control-allow-methods"] -def core(tmp_path, *, permission=True): +def core(tmp_path): database = tmp_path / "data/one-memory.sqlite3" root = tmp_path / "source" root.mkdir() @@ -369,7 +327,7 @@ def core(tmp_path, *, permission=True): user_id="local", source_id="fixture", root=root, candidate_kind=RootCandidateKind.DEFAULT ) evidence = EvidenceStore(database) - record = evidence.put( + _record = evidence.put( EvidenceInput( source_id="fixture", conversation_id="health", @@ -399,15 +357,6 @@ def core(tmp_path, *, permission=True): secrets=secrets, ) profiles.activate(profile.profile_id, secrets) - store = ModelContentPermissionStore(database) - if permission: - snapshot = store.snapshot( - user_id="local", - source_ids=("fixture",), - profile=profiles.snapshot(profile.profile_id, profile.revision), - evidence=(record,), - ) - store.grant(snapshot, user_confirmed=True, expected_revision=0) return create_app( Settings( data_dir=tmp_path / "data", @@ -420,25 +369,6 @@ def core(tmp_path, *, permission=True): ) -def test_configured_model_without_content_consent_does_not_start_backfill(tmp_path, monkeypatch): - started = [] - - def prepare(*args, **kwargs): - started.append(True) - yield [] - - monkeypatch.setattr(MemoryProcessingSupervisor, "_prepare_backfill_batches", prepare) - - async def scenario(): - app = core(tmp_path, permission=False) - async with app.router.lifespan_context(app): - await app.state.memory_backfill - assert started == [] - assert app.state.memory_task_store.list() == [] - - asyncio.run(scenario()) - - def test_startup_finishes_before_the_memory_backfill_does(tmp_path, monkeypatch) -> None: """Startup must not wait on work that grows with the corpus. @@ -451,16 +381,16 @@ def test_startup_finishes_before_the_memory_backfill_does(tmp_path, monkeypatch) release = threading.Event() def blocking( - _: MemoryProcessingSupervisor, - should_stop: Callable[[], bool] = lambda: False, + _: DurableWriteQueue, *, - expected_permission_revision: int | None = None, + limit: int = 512, + after_cursor: str | None = None, ): walking.set() assert release.wait(10) - yield [] + return () - monkeypatch.setattr(MemoryProcessingSupervisor, "_prepare_backfill_batches", blocking) + monkeypatch.setattr(DurableWriteQueue, "unsubmitted_evidence_ids", blocking) async def scenario() -> None: app = core(tmp_path) @@ -489,15 +419,14 @@ def test_startup_finishes_before_the_memory_backfill_does(tmp_path, monkeypatch) def test_a_backfill_that_fails_says_so_rather_than_disappearing(tmp_path, monkeypatch) -> None: def broken( - _: MemoryProcessingSupervisor, - should_stop: Callable[[], bool] = lambda: False, + _: DurableWriteQueue, *, - expected_permission_revision: int | None = None, + limit: int = 512, + after_cursor: str | None = None, ): raise RuntimeError("the catch-up broke") - yield [] - monkeypatch.setattr(MemoryProcessingSupervisor, "_prepare_backfill_batches", broken) + monkeypatch.setattr(DurableWriteQueue, "unsubmitted_evidence_ids", broken) async def scenario() -> None: app = core(tmp_path) @@ -521,16 +450,16 @@ def test_shutting_down_mid_backfill_leaves_nothing_running(tmp_path, monkeypatch started = threading.Event() def slow( - _: MemoryProcessingSupervisor, - should_stop: Callable[[], bool] = lambda: False, + _: DurableWriteQueue, *, - expected_permission_revision: int | None = None, + limit: int = 512, + after_cursor: str | None = None, ): started.set() threading.Event().wait(0.2) - yield [] + return () - monkeypatch.setattr(MemoryProcessingSupervisor, "_prepare_backfill_batches", slow) + monkeypatch.setattr(DurableWriteQueue, "unsubmitted_evidence_ids", slow) async def scenario() -> None: app = core(tmp_path) @@ -553,15 +482,14 @@ def test_a_failed_catch_up_leaves_a_traceback_behind(tmp_path, monkeypatch, capl """ def failing( - _: MemoryProcessingSupervisor, - should_stop: Callable[[], bool] = lambda: False, + _: DurableWriteQueue, *, - expected_permission_revision: int | None = None, + limit: int = 512, + after_cursor: str | None = None, ): raise sqlite3.OperationalError("database is locked") - yield [] - monkeypatch.setattr(MemoryProcessingSupervisor, "_prepare_backfill_batches", failing) + monkeypatch.setattr(DurableWriteQueue, "unsubmitted_evidence_ids", failing) async def scenario() -> dict: app = core(tmp_path) @@ -591,7 +519,7 @@ def test_a_failed_catch_up_leaves_a_traceback_behind(tmp_path, monkeypatch, capl if record.exc_info is not None for frame in traceback.extract_tb(record.exc_info[2]) ] - assert any(frame.name == "backfill_off_thread" for frame in frames) + assert any(frame.name == "_run_backfill" for frame in frames) assert any(frame.name == "failing" and frame.filename == __file__ for frame in frames), ( "栈里没有出事的那个调用" ) diff --git a/memhub/apps/core/tests/test_hy_coding_lease.py b/memhub/apps/core/tests/test_hy_coding_lease.py deleted file mode 100644 index 2d70c37..0000000 --- a/memhub/apps/core/tests/test_hy_coding_lease.py +++ /dev/null @@ -1,384 +0,0 @@ -"""HY's own coding/working route under the permission lease, on the pinned SDK. - -HY decides the route; scripted HTTP stands in for the model, not for HY. -""" - -import asyncio -import json -import sqlite3 -import traceback -from collections.abc import Mapping - -import httpx -from hy_memory.coding.types import CodingMemory - -from one_memory.memory_governance import MemoryKind, MemoryStatus -from tests.test_hy_permission_raw_context import ( - bind_oracle, - grant_records, - native_client, - on_native_loop, - put_turn, - raw_client, -) -from tests.test_hy_permission_store import Lease - -TOOL_TEXT = "[Bash] pytest -q\n12 passed" -TASK = "Run the synthetic test suite" -SEARCH_KEY = "pytest quiet run" -SOLUTION = "Run `pytest -q` from the repository root." -STATEMENT = "The agent ran pytest -q in the synthetic repository and 12 tests passed." -DENIED_EXPERIENCE = "denied-coding-experience" -DENIED_TASK = "DENIED_CODING_TASK_5d2e run the suite" -DENIED_SOLUTION = "DENIED_CODING_SOLUTION_7f10: skip the tests" - -JUDGE = "You are a single-label scene classifier." -EXTRACT = "You extract durable WORKING memories" -RECONCILE = "You decide how to merge new coding memory drafts" -FACT_RECONCILE = "You maintain session-level WORKING FACTS" - - -def coding_vector(): - # One shared direction: every stored experience and fact is a candidate. - return [float(index == 5) for index in range(384)] - - -class FixedEmbeddings: - async def embed_batch(self, texts): - return [coding_vector() for _ in texts] - - async def embed(self, text): - return coding_vector() - - -class CodingOracle: - def __init__( - self, - records, - *, - solution=SOLUTION, - statement=STATEMENT, - update_target=None, - forbidden=(), - after_response=None, - ): - self.records = records - self.solution, self.statement = solution, statement - self.update_target = update_target - self.forbidden = forbidden - self.after_response = after_response - self.requests = [] - self.stages = [] - self.reconcile_prompts = [] - self.errors = [] - self.revoked_at = None - - def __call__(self, request): - try: - return self.respond(request) - except Exception: - self.errors.append(traceback.format_exc()) - raise - - def respond(self, request): - body = json.loads(request.content) - self.requests.append(body) - assert self.revoked_at is None, "native child request escaped after revocation" - serialized = json.dumps(body, ensure_ascii=False) - for forbidden in self.forbidden: - assert forbidden not in serialized, f"unapproved coding row reached HTTP: {forbidden}" - if request.url.path == "/v1/embeddings": - texts = body["input"] if isinstance(body["input"], list) else [body["input"]] - self.stages.append("embedding") - return httpx.Response( - 200, - json={ - "object": "list", - "model": body["model"], - "data": [ - {"index": i, "object": "embedding", "embedding": coding_vector()} - for i in range(len(texts)) - ], - "usage": {"prompt_tokens": 1, "total_tokens": 1}, - }, - ) - prompt = body["messages"][-1]["content"] - if prompt.startswith(JUDGE): - result = {"is_coding": True, "reason": "synthetic tool run"} - stage = "judge" - elif prompt.startswith(EXTRACT): - for record in self.records: - assert record.verbatim in prompt, record.role - result = { - "experiences": [ - { - "task": TASK, - "search_keys": [SEARCH_KEY], - "solution": self.solution, - "boundary_envs": "", - "confidence": 0.9, - } - ], - "fact": { - "subject": "synthetic repository", - "statement": self.statement, - "files": [], - "details": "", - }, - } - stage = "extract" - elif prompt.startswith(RECONCILE): - self.reconcile_prompts.append(prompt) - op = ( - {"action": "UPDATE", "draft_idx": 0, "target_experience_id": self.update_target} - if self.update_target - else {"action": "ADD", "draft_idx": 0} - ) - result = {"ops": [op]} - stage = "reconcile" - elif prompt.startswith(FACT_RECONCILE): - result = {"ops": []} - stage = "fact_reconcile" - else: - raise AssertionError(f"unexpected native prompt: {prompt[:80]!r}") - self.stages.append(stage) - response = httpx.Response( - 200, - json={ - "id": "synthetic-coding-response", - "object": "chat.completion", - "created": 1, - "model": body["model"], - "choices": [ - { - "index": 0, - "finish_reason": "stop", - "message": {"role": "assistant", "content": json.dumps(result)}, - } - ], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, - }, - ) - if self.after_response is not None and self.after_response(stage): - self.revoked_at = len(self.requests) - return response - - -def proof_ids(supervisor): - with sqlite3.connect(supervisor.data_dir / "one-memory.sqlite3") as db: - return {row[0] for row in db.execute("SELECT node_id FROM hy_node_permissions")} - - -async def run_coding_turn(supervisor, seed, oracle): - # Renewed consent also re-queues earlier turns; run only the seed's turn. - (task,) = ( - task - for task in supervisor.prepare_conversation(seed) - if seed.evidence_id in {ref["evidence_id"] for ref in supervisor.tasks.inputs(task.task_id)} - ) - bind_oracle(supervisor, oracle) - result = await supervisor._create_runner().run(task.task_id) - assert not oracle.errors, oracle.errors - return task, result - - -def by_kind(supervisor): - memories = supervisor.governance.list(user_id="local") - grouped = {} - for memory in memories: - grouped.setdefault(memory.kind, []).append(memory) - return grouped - - -def test_hy_routes_a_tool_turn_to_coding_and_both_layers_are_kept(tmp_path): - async def scenario(): - async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, _): - oracle = CodingOracle(records) - task, result = await run_coding_turn(supervisor, records[0], oracle) - assert result.status == "completed", (result.last_error_code, oracle.stages) - assert oracle.stages[:2] == ["judge", "extract"] - assert "reconcile" in oracle.stages - kinds = by_kind(supervisor) - assert set(kinds) == {MemoryKind.CODING, MemoryKind.WORKING} - (coding,), (working,) = kinds[MemoryKind.CODING], kinds[MemoryKind.WORKING] - assert coding.content == f"{TASK}\n{SOLUTION}" - assert working.content == STATEMENT - every_record = {record.evidence_id for record in records} - for memory in (coding, working): - assert set(memory.evidence_ids) == every_record - assert memory.status is MemoryStatus.CONFIRMED - outputs = supervisor.tasks.outputs(task.task_id) - assert {output.layer for output in outputs} == {"coding_experience", "working_fact"} - assert {output.hy_memory_id for output in outputs} <= proof_ids(supervisor) - # HY's pipeline log copies every coding prompt it is handed a cache for. - (log_dir,) = tmp_path.rglob("hy-memory/*/logs") - for path in log_dir.rglob("*"): - if path.is_file(): - for text in (records[0].verbatim, TASK): - assert text.encode() not in path.read_bytes(), (path.name, text) - - asyncio.run(scenario()) - - -def test_an_unapproved_experience_never_reaches_the_model_or_changes(tmp_path): - async def scenario(): - async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, _): - task = supervisor.prepare_conversation(records[0])[0] - _, request, _ = supervisor._create_runner()._prepare_permission_lease(task) - client = await native_client(supervisor, task) - - async def seed(): - await client._get_coding_writer() - store = client._coding_store - native_embeddings = store.embed_service - store.embed_service = FixedEmbeddings() - try: - await store.insert( - CodingMemory( - experience_id=DENIED_EXPERIENCE, - user_id=request.user_id, - agent_id=request.agent_id, - session_id=request.session_id, - task=DENIED_TASK, - solution=DENIED_SOLUTION, - ) - ) - finally: - store.embed_service = native_embeddings - found = await store.search_by_query_embedding( - coding_vector(), user_id=request.user_id - ) - assert [hit["experience_id"] for hit in found] == [DENIED_EXPERIENCE] - return (await store.get_by_id(DENIED_EXPERIENCE)).to_dict() - - before = await on_native_loop(client, seed()) - oracle = CodingOracle( - records, forbidden=(DENIED_EXPERIENCE, DENIED_TASK, DENIED_SOLUTION) - ) - bind_oracle(supervisor, oracle) - result = await supervisor._create_runner().run(task.task_id) - assert not oracle.errors, oracle.errors - assert result.status == "completed", (result.last_error_code, oracle.stages) - assert oracle.reconcile_prompts - after = await on_native_loop(client, client._coding_store.get_by_id(DENIED_EXPERIENCE)) - assert after.to_dict() == before - assert DENIED_EXPERIENCE not in proof_ids(supervisor) - contents = {memory.content for memory in supervisor.governance.list(user_id="local")} - assert contents == {f"{TASK}\n{SOLUTION}", STATEMENT} - - asyncio.run(scenario()) - - -def test_a_later_turn_updating_an_experience_updates_the_same_memory(tmp_path): - async def scenario(): - async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, permission): - _, first = await run_coding_turn(supervisor, records[0], CodingOracle(records)) - assert first.status == "completed", first.last_error_code - (original,) = by_kind(supervisor)[MemoryKind.CODING] - question = put_turn( - supervisor.evidence, "user-again", "user", "Run it again verbosely.", 4 - ) - tool = put_turn( - supervisor.evidence, "tool-again", "tool", "[Bash] pytest -v\n12 passed", 5 - ) - everything = (*records, question, tool) - grant_records(supervisor, everything, permission.revision) - verbose = "Run `pytest -v` from the repository root for per-test output." - oracle = CodingOracle( - (question, tool), - solution=verbose, - statement="The agent re-ran the suite verbosely; 12 passed.", - update_target=original.memory_id, - ) - _, second = await run_coding_turn(supervisor, question, oracle) - assert second.status == "completed", (second.last_error_code, oracle.stages) - assert original.memory_id in oracle.reconcile_prompts[0] - kinds = by_kind(supervisor) - (updated,) = kinds[MemoryKind.CODING] - assert updated.memory_id == original.memory_id - assert updated.content == f"{TASK}\n{verbose}" - assert updated.revision > original.revision - assert {question.evidence_id, tool.evidence_id} <= set(updated.evidence_ids) - assert len(kinds[MemoryKind.WORKING]) == 2 - - asyncio.run(scenario()) - - -def test_revoking_during_coding_extraction_stops_the_route(tmp_path): - async def scenario(): - async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, permission): - - def revoke(stage): - if stage != "extract": - return False - supervisor.model_content_permissions.revoke( - user_id="local", expected_revision=permission.revision, user_confirmed=True - ) - return True - - oracle = CodingOracle(records, after_response=revoke) - task, result = await run_coding_turn(supervisor, records[0], oracle) - assert result.status == "cancelled" - assert result.last_error_code == "model_content_permission_revoked", result - assert oracle.revoked_at is not None and len(oracle.requests) == oracle.revoked_at - assert not supervisor.governance.list(user_id="local") - assert not supervisor.tasks.outputs(task.task_id) - - asyncio.run(scenario()) - - -def test_exposed_coding_config_is_a_redacted_projection_not_the_native_handle(tmp_path): - """Public SDK 2.0.2 reads `config` off the coding store; it must not hand out secrets. - - The wrapper answers `config` so the SDK's routing works, but the native - MemoryConfig is a live backend handle carrying api keys and passwords. This - asserts callers get a plain projection: not the native object, and no - credential value survives anywhere inside it. - """ - - async def scenario(): - async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, _): - task = supervisor.prepare_conversation(records[0])[0] - client = await native_client(supervisor, task) - await on_native_loop(client, client._get_coding_writer()) - return client._coding_store - - native = asyncio.run(scenario()) - from one_memory.hy_permission_coding_store import HyPermissionCodingStore - - native.config.llm.api_key = "DENIED_LLM_KEY_2f31" - native.config.embedder.api_key = "DENIED_EMBED_KEY_9c04" - native.config.graph_store.password = "DENIED_GRAPH_PW_77aa" - store = HyPermissionCodingStore( - native, - Lease(), - agent_id="agent", - session_id="session", - ) - - exposed = store.config - assert exposed is not native.config - assert not isinstance(exposed, type(native.config)) - # Attribute access still works: that is how the public SDK reads it. - assert isinstance(exposed["llm"], Mapping) - assert exposed["llm"]["model"] == native.config.llm.model - assert exposed.llm.model == native.config.llm.model - - def contains_secret(value, depth=0): - if depth > 5: - return False - if isinstance(value, str): - return "DENIED_" in value - if isinstance(value, Mapping): - return any(contains_secret(item, depth + 1) for item in value.values()) - if isinstance(value, (list, tuple)): - return any(contains_secret(item, depth + 1) for item in value) - return False - - assert not contains_secret(exposed) - assert exposed["llm"]["api_key"] is None - assert exposed["embedder"]["api_key"] is None - assert exposed["graph_store"]["password"] is None - # No native handle, connection or callable escapes the projection. - assert not hasattr(exposed, "_native") - assert "native" not in exposed diff --git a/memhub/apps/core/tests/test_hy_lease_responsiveness.py b/memhub/apps/core/tests/test_hy_lease_responsiveness.py deleted file mode 100644 index 12bd739..0000000 --- a/memhub/apps/core/tests/test_hy_lease_responsiveness.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Blocked lease checks, local failing doubles only; no network or credentials.""" - -import asyncio -import threading -from contextlib import asynccontextmanager -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - -from one_memory.hy_memory_bridge import HyMemoryBridge, HyMemoryBridgeError -from one_memory.hy_permission_context import _LeasedBridge, permission_bridge_factory -from one_memory.hy_permission_transport import HyPermissionTransportError - - -class ControlledLease: - def __init__(self, *, blocked_call=1, error=None): - self.receipt = SimpleNamespace(user_id="local") - self.profile = object() - self.task_id = "synthetic-task" - self.records = (SimpleNamespace(evidence_id="synthetic-evidence"),) - self.blocked_call = blocked_call - self.error = error - self.calls = [] - self.entered = threading.Event() - self.release = threading.Event() - self.exited = threading.Event() - self.revoked = False - - def recheck(self): - self.calls.append(threading.get_ident()) - if len(self.calls) == self.blocked_call: - self.entered.set() - try: - if not self.release.wait(2): - raise HyMemoryBridgeError("test_check_was_not_released") - if self.revoked: - raise HyMemoryBridgeError("model_content_permission_revoked") - if self.error is not None: - raise self.error - finally: - self.exited.set() - return self.receipt - - -class ControlledLoop: - def __init__(self): - self.calls = [] - - def run(self, coroutine): - self.calls.append(threading.get_ident()) - return asyncio.run(coroutine) - - -class ControlledWiring: - def __init__(self, *, error=None): - self.client = SimpleNamespace(_loop_thread=ControlledLoop()) - self.error = error - self.finished = 0 - self.closed = 0 - - async def finish(self): - self.finished += 1 - if self.error is not None: - raise self.error - return {} - - async def exit(self): - self.closed += 1 - - -REQUEST = SimpleNamespace( - user_id="local", - evidence_ids=("synthetic-evidence",), - agent_id="agent", - session_id="session", - request_id="synthetic-request", -) - - -@asynccontextmanager -async def blocked_operation(lease, operation): - main_thread = threading.get_ident() - pending = asyncio.create_task(operation()) - try: - # A timer on this loop must run while the synchronous check is held. - async with asyncio.timeout(0.5): - while not lease.entered.is_set(): - await asyncio.sleep(0.005) - ticked = asyncio.Event() - asyncio.get_running_loop().call_later(0.02, ticked.set) - await ticked.wait() - assert not lease.exited.is_set(), "lease check blocked the main event loop" - assert main_thread not in lease.calls, "lease check executed on the main thread" - yield pending - finally: - lease.release.set() - if not pending.done(): - pending.cancel() - await asyncio.gather(pending, return_exceptions=True) - assert lease.exited.is_set() - - -@pytest.mark.parametrize("operation", ["ingest", "ingest_extracted"]) -def test_cancelled_start_joins_check_without_later_sdk_entry(operation): - async def scenario(): - lease = ControlledLease() - bridge = SimpleNamespace( - _get_client=AsyncMock(), ingest=AsyncMock(), ingest_extracted=AsyncMock() - ) - bound = _LeasedBridge(bridge, lease, object(), None) - - async def start(): - try: - if operation == "ingest": - await bound.ingest(REQUEST) - else: - await bound.ingest_extracted(REQUEST, ("Synthetic original",)) - finally: - await bound.close() - - async with blocked_operation(lease, start) as pending: - pending.cancel() - await asyncio.sleep(0.02) - pending.cancel() - await asyncio.sleep(0.02) - assert not pending.done(), "cancel abandoned the active lease check" - assert not bridge._get_client.called - lease.release.set() - with pytest.raises(asyncio.CancelledError): - await pending - assert not bridge._get_client.called - assert not bridge.ingest.called and not bridge.ingest_extracted.called - assert bound.wiring is None - - asyncio.run(scenario()) - - -def test_revoked_start_preserves_error_and_never_opens_sdk(): - async def scenario(): - lease = ControlledLease() - bridge = SimpleNamespace(_get_client=AsyncMock()) - bound = _LeasedBridge(bridge, lease, object(), None) - async with blocked_operation(lease, lambda: bound._start(REQUEST)) as pending: - lease.revoked = True - lease.release.set() - with pytest.raises(HyMemoryBridgeError, match="^model_content_permission_revoked$"): - await pending - await bound.close() - assert not bridge._get_client.called and bound.wiring is None - - asyncio.run(scenario()) - - -def test_released_start_reaches_client_once_and_preserves_client_error(): - async def scenario(): - lease = ControlledLease() - expected = HyMemoryBridgeError("hy_memory_init_failed") - bridge = SimpleNamespace(_get_client=AsyncMock(side_effect=expected)) - bound = _LeasedBridge(bridge, lease, object(), None) - async with blocked_operation(lease, lambda: bound._start(REQUEST)) as pending: - assert not bridge._get_client.called - lease.release.set() - with pytest.raises(HyMemoryBridgeError) as raised: - await pending - assert raised.value is expected - bridge._get_client.assert_awaited_once_with() - await bound.close() - assert len(lease.calls) == 1 and bound.wiring is None - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("transport_failure", [False, True]) -@pytest.mark.parametrize("cancel", [False, True]) -def test_postcheck_and_transport_error_recheck_are_joined(transport_failure, cancel): - async def scenario(): - expected = HyMemoryBridgeError("model_content_evidence_changed") - lease = ControlledLease(blocked_call=2 if transport_failure else 1, error=expected) - bound = _LeasedBridge(object(), lease, object(), None) - wiring = ControlledWiring( - error=HyPermissionTransportError("controlled_transport_failure") - if transport_failure - else None - ) - bound.wiring = wiring - - async def checked(): - try: - await bound._check() - finally: - await bound.close() - - async with blocked_operation(lease, checked) as pending: - if cancel: - pending.cancel() - await asyncio.sleep(0.02) - assert not pending.done() and wiring.closed == 0 - lease.release.set() - if cancel: - with pytest.raises(asyncio.CancelledError): - await pending - else: - with pytest.raises(HyMemoryBridgeError) as raised: - await pending - assert raised.value is expected - assert wiring.finished == int(transport_failure) - assert wiring.closed == 1 and bound.finished - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("revoked", [False, True]) -@pytest.mark.parametrize("cancel", [False, True]) -def test_factory_error_check_holds_exclusive_access_until_joined(tmp_path, revoked, cancel): - async def scenario(): - lease = ControlledLease() - bridge = HyMemoryBridge( - profile=lease.profile, - api_key=None, - data_dir=tmp_path, - client=SimpleNamespace(close=lambda: None), - ) - factory = permission_bridge_factory(lambda *_: bridge) - expected = HyPermissionTransportError("controlled_transport_failure") - - async def failed_context(): - async with factory(lease, None, tmp_path): - raise expected - - async with blocked_operation(lease, failed_context) as pending: - assert bridge._access_lock.locked() - lease.revoked = revoked - if cancel: - pending.cancel() - await asyncio.sleep(0.02) - assert not pending.done() and bridge._access_lock.locked() - lease.release.set() - if cancel: - with pytest.raises(asyncio.CancelledError): - await pending - else: - code = "model_content_permission_revoked" if revoked else expected.code - with pytest.raises(HyMemoryBridgeError, match=f"^{code}$"): - await pending - assert bridge._access_owner is None and not bridge._access_lock.locked() - await bridge.close() - assert bridge._client is None - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_hy_lifecycle_sync.py b/memhub/apps/core/tests/test_hy_lifecycle_sync.py deleted file mode 100644 index c19eefe..0000000 --- a/memhub/apps/core/tests/test_hy_lifecycle_sync.py +++ /dev/null @@ -1,145 +0,0 @@ -"""HY decides deletions; the ledger mirrors them. Pinned SDK, scripted HTTP.""" - -import asyncio -import json - -import httpx -from hy_memory.models.memory import MemoryStatus as HyStatus - -from one_memory.memory_governance import DeletionStatus, MemoryKind, MemoryStatus -from tests.test_hy_coding_lease import ( - TOOL_TEXT, - CodingOracle, - by_kind, - run_coding_turn, -) -from tests.test_hy_permission_raw_context import ( - AGENT_TEXT, - USER_TEXT, - ScriptedOracle, - assert_outputs, - bind_oracle, - grant_records, - native_client, - on_native_loop, - put_turn, - raw_client, -) - - -class DeletingOracle(ScriptedOracle): - """HY's reconciler withdraws the user's claim and keeps the agent's.""" - - def respond(self, request): - response = super().respond(request) - if self.stages[-1] != "reconcile": - return response - body = json.loads(response.content) - ops = json.loads(body["choices"][0]["message"]["content"]) - for op in ops: - if op["content"] == USER_TEXT and "memory_id" in op: - op.clear() - op.update({"op": "DELETE", "memory_id": self.withdrawn}) - body["choices"][0]["message"]["content"] = json.dumps(ops) - return httpx.Response(200, json=body) - - -def test_a_memory_hy_deletes_is_deleted_in_the_ledger_and_the_rest_stay(tmp_path): - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, permission): - first = supervisor.prepare_conversation(records[0])[0] - client = await native_client(supervisor, first) - bind_oracle(supervisor, ScriptedOracle(records)) - assert (await supervisor._create_runner().run(first.task_id)).status == "completed" - await assert_outputs(supervisor, first, records, client) - by_content = {m.content: m for m in supervisor.governance.list(user_id="local")} - withdrawn, kept = by_content[USER_TEXT], by_content[AGENT_TEXT] - - added = put_turn(supervisor.evidence, "user-withdraw", "user", USER_TEXT, 3) - records = (*records, added) - grant_records(supervisor, records, permission.revision) - second = supervisor.prepare_conversation(added)[0] - oracle = DeletingOracle(records) - oracle.withdrawn = withdrawn.memory_id - bind_oracle(supervisor, oracle) - result = await supervisor._create_runner().run(second.task_id) - assert not oracle.errors, oracle.errors - assert result.status == "completed", (result.last_error_code, oracle.stages) - - native = await on_native_loop( - client, client._vector_store.get_by_id(withdrawn.memory_id) - ) - assert native is not None and native.status == HyStatus.SHADOW - mirrored = supervisor.governance.get(withdrawn.memory_id, user_id="local") - assert mirrored.status is MemoryStatus.DELETED - deletion = supervisor.governance.deletion(withdrawn.memory_id, user_id="local") - assert deletion is not None and deletion.status is DeletionStatus.COMPLETED - untouched = supervisor.governance.get(kept.memory_id, user_id="local") - assert untouched.status is MemoryStatus.CONFIRMED - - asyncio.run(scenario()) - - -class RetiringCodingOracle(CodingOracle): - def __init__(self, records, *, retire, **kwargs): - super().__init__(records, **kwargs) - self.retire = retire - - def respond(self, request): - response = super().respond(request) - if self.stages[-1] != "reconcile": - return response - body = json.loads(response.content) - body["choices"][0]["message"]["content"] = json.dumps( - { - "ops": [ - {"action": "ADD", "draft_idx": 0}, - { - "action": "DELETE", - "draft_idx": 0, - "target_experience_id": self.retire, - "reason": "the new run explicitly replaces the old procedure", - }, - ] - } - ) - return httpx.Response(200, json=body) - - -def test_an_experience_hy_deletes_is_deleted_in_the_ledger(tmp_path): - async def scenario(): - async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, permission): - _, first = await run_coding_turn(supervisor, records[0], CodingOracle(records)) - assert first.status == "completed", first.last_error_code - (original,) = by_kind(supervisor)[MemoryKind.CODING] - question = put_turn(supervisor.evidence, "user-again", "user", "Use tox now.", 4) - tool = put_turn(supervisor.evidence, "tool-again", "tool", "[Bash] tox\nok", 5) - grant_records(supervisor, (*records, question, tool), permission.revision) - oracle = RetiringCodingOracle( - (question, tool), - retire=original.memory_id, - solution="Run `tox` from the repository root.", - statement="The agent switched the synthetic suite to tox.", - ) - _, second = await run_coding_turn(supervisor, question, oracle) - assert second.status == "completed", (second.last_error_code, oracle.stages) - client = await native_client(supervisor, second) - assert ( - await on_native_loop(client, client._coding_store.get_by_id(original.memory_id)) - is None - ) - - assert ( - supervisor.governance.get(original.memory_id, user_id="local").status - is MemoryStatus.DELETED - ) - live = [ - m - for m in by_kind(supervisor)[MemoryKind.CODING] - if m.status is MemoryStatus.CONFIRMED - ] - assert [m.content for m in live] == [ - "Run the synthetic test suite\nRun `tox` from the repository root." - ] - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_hy_memory_bridge.py b/memhub/apps/core/tests/test_hy_memory_bridge.py index 527fafe..1ecd68d 100644 --- a/memhub/apps/core/tests/test_hy_memory_bridge.py +++ b/memhub/apps/core/tests/test_hy_memory_bridge.py @@ -18,10 +18,12 @@ from one_memory.hy_memory_bridge import ( HyMemoryBridge, HyMemoryBridgeError, HyMemoryIngestRequest, + HyMemoryIngestResult, HyMemoryTurn, UnsupportedHyMemoryProviderError, _basic_info_memories, _collection_name, + _with_native_mutation_delta, build_hy_memory_config, configure_hy_memory_environment, reattach_legacy_vector_collection, @@ -1507,3 +1509,58 @@ def test_the_client_mode_follows_the_graph_store_switch(tmp_path, monkeypatch) - hy_memory_bridge.create_hy_memory_client(profile, api_key=None, data_dir=tmp_path).close() assert seen == ["pro", HY_MEMORY_MODE] + + +def test_native_mutation_delta_reports_coding_deletes_and_supersedes() -> None: + raw = { + "ops": [ + { + "action": "DELETE", + "target_experience_id": "old-experience", + } + ], + "fact_ops": [ + { + "action": "SUPERSEDE", + "fact_id": "old-fact", + "superseded_by": "new-fact", + } + ], + } + + result = asyncio.run( + _with_native_mutation_delta( + SimpleNamespace(), + HyMemoryIngestResult(success=True), + raw, + (), + ) + ) + + assert result.retired_memory_ids == ("old-experience", "old-fact") + assert result.superseded_memory_ids == {"old-fact": ("new-fact",)} + + +def test_native_mutation_delta_reads_chat_status_from_the_sdk() -> None: + class Client: + def get(self, memory_id: str) -> Mapping[str, object]: + assert memory_id == "old-chat" + return { + "memory_id": memory_id, + "content": "old", + "status": "superseded", + "superseded_by": ["new-chat"], + } + + guard = SimpleNamespace(mutation_target_ids={"old-chat"}) + result = asyncio.run( + _with_native_mutation_delta( + Client(), + HyMemoryIngestResult(success=True), + {}, + (guard,), + ) + ) + + assert result.retired_memory_ids == ("old-chat",) + assert result.superseded_memory_ids == {"old-chat": ("new-chat",)} diff --git a/memhub/apps/core/tests/test_hy_native_write_chain.py b/memhub/apps/core/tests/test_hy_native_write_chain.py deleted file mode 100644 index 0e3c03d..0000000 --- a/memhub/apps/core/tests/test_hy_native_write_chain.py +++ /dev/null @@ -1,208 +0,0 @@ -"""HY writes with its own prompts and judgment; the product adds no audit of its own.""" - -import asyncio -import json -from types import SimpleNamespace - -import pytest -from hy_memory.agent.reconciler import ReconcileOp, ReconcileResult - -from one_memory.hy_response_guard import HyReconcileGuard -from one_memory.hy_source_context import HySourceContext, HySupportedMemory, HyWriteBindings -from one_memory.hy_write_verification import HyExtractionGuard -from one_memory.imports.storage import EvidenceStore -from tests.test_hy_permission_raw_context import ( - ScriptedOracle, - assert_outputs, - bind_oracle, - native_client, - raw_client, -) -from tests.test_hy_source_context import source - -PRODUCT_RULE_MARKERS = ("# 来源与语气保真", "# 原始发言主体规则", "owner记录命题的陈述权威") - - -def test_a_raw_turn_reaches_hy_with_its_native_prompts_and_no_product_audit(tmp_path): - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, _): - task = supervisor.prepare_conversation(records[0])[0] - oracle = ScriptedOracle(records) - bind_oracle(supervisor, oracle) - result = await supervisor._create_runner().run(task.task_id) - assert not oracle.errors, oracle.errors - assert result.status == "completed", (result.last_error_code, oracle.stages) - assert "extract" in oracle.stages - assert "review" not in oracle.stages - assert "citations" not in oracle.stages - sent = json.dumps([body for _path, body in oracle.requests], ensure_ascii=False) - for marker in PRODUCT_RULE_MARKERS: - assert marker not in sent, marker - # Each memory still points at the words of its own author in this turn. - client = await native_client(supervisor, task) - await assert_outputs(supervisor, task, records, client) - - asyncio.run(scenario()) - - -def test_an_imported_memory_keeps_the_owner_hy_gives_it(): - class Native: - received = None - - async def reconcile(self, **kwargs): - self.received = kwargs["new_memories_with_meta"] - return ReconcileResult( - ops=[ReconcileOp(op="ADD", content="用户偏好简洁表达。", owner="user")] - ) - - native = Native() - guard = HyReconcileGuard(native) - guard.imported_contents = frozenset({"用户偏好简洁表达。"}) - result = asyncio.run( - guard.reconcile( - new_memories=["用户偏好简洁表达。"], - new_memories_with_meta=[{"content": "用户偏好简洁表达。", "layer": "L2_FACT"}], - ) - ) - assert "owner" not in native.received[0] - assert result.ops[0].owner == "user" - assert guard.failures == [] - - -def bound(tmp_path, *, known=False): - store = EvidenceStore(tmp_path / "evidence.db") - old = source(store, "old", "user", "Atlas用本地存储。") - current = source(store, "now", "user", "Atlas也要能离线同步。") - memories = ( - HySupportedMemory( - hy_memory_id="prior", - governed_memory_id="prior", - revision=1, - content="用户要求Atlas使用本地存储。", - owner="user", - evidence_ids=(old.evidence_id,), - ), - ) - context = HySourceContext( - current=(current,), history=(old,), known_memories=memories if known else () - ) - return HyWriteBindings(context), old, current - - -def test_the_turn_hy_extracts_must_be_the_turn_that_was_sent(tmp_path): - bindings, _old, current = bound(tmp_path) - calls = [] - - class Native: - async def extract(self, content, context, **kwargs): - calls.append(content) - return SimpleNamespace(success=True, info={"memory": []}) - - guard = HyExtractionGuard(Native()) - guard.bindings = bindings - sent = json.dumps([{"role": "user", "content": current.verbatim}], ensure_ascii=False) - assert asyncio.run(guard.extract(sent, {})).success and calls == [sent] - with pytest.raises(ValueError, match="input_mismatch"): - asyncio.run(guard.extract(json.dumps([{"role": "user", "content": "别的内容"}]), {})) - assert calls == [sent] - assert guard.failures == ["hy_memory_write_verification_input_mismatch"] - - -class PriorStore: - async def get_by_id(self, key): - return SimpleNamespace( - user_id="local", agent_id="atlas", owner="user", content="用户要求Atlas使用本地存储。" - ) - - -def merge_into_prior(content): - class Native: - async def reconcile(self, **kwargs): - return ReconcileResult( - ops=[ReconcileOp(op="UPDATE", memory_id="prior", owner="user", content=content)] - ) - - return Native() - - -ARGS = {"user_id": "local", "agent_id": "atlas", "vector_store": PriorStore()} - - -def test_hy_may_only_change_a_memory_this_write_was_allowed_to_see(tmp_path): - allowed, _old, _current = bound(tmp_path / "allowed", known=True) - guard = HyReconcileGuard(merge_into_prior("用户要求Atlas本地存储并支持离线同步。")) - guard.bindings = allowed - assert asyncio.run(guard.reconcile(**ARGS)).success - - unseen, _old, _current = bound(tmp_path / "unseen") - guard = HyReconcileGuard(merge_into_prior("用户要求Atlas本地存储并支持离线同步。")) - guard.bindings = unseen - with pytest.raises(ValueError, match="target_unbound"): - asyncio.run(guard.reconcile(**ARGS)) - - -def test_a_memory_hy_merges_cites_this_turn_and_what_it_already_cited(tmp_path): - bindings, old, current = bound(tmp_path, known=True) - merged = "用户要求Atlas本地存储并支持离线同步。" - guard = HyReconcileGuard(merge_into_prior(merged)) - guard.bindings = bindings - assert asyncio.run(guard.reconcile(**ARGS)).success - assert bindings.support_for(merged, "user") == (current.evidence_id, old.evidence_id) - - -HY_USER_ONLY_LINE = "只提取 NEW user messages 中首次出现的新信息。" - - -def _section(prompt, heading): - _, found, rest = prompt.partition(heading) - assert found, heading - return rest.partition("\n---\n")[0] - - -def _served_extract_prompts(tmp_path): - from one_memory.hy_memory_bridge import build_hy_memory_config - from tests.test_memory_language import _profile - - config = build_hy_memory_config(_profile(), api_key=None, data_dir=tmp_path) - return config.llm.extract_system_prompt_zh, config.llm.extract_system_prompt_en - - -def test_hys_native_extraction_only_reads_what_the_user_said(): - from hy_memory.agent.prompts_zh import EXTRACT_SYSTEM_PROMPT_ZH - - assert HY_USER_ONLY_LINE in EXTRACT_SYSTEM_PROMPT_ZH - # HY already knows how to keep an assistant's words apart from the person's. - assert '`"agent"` —— AI 助手' in _section(EXTRACT_SYSTEM_PROMPT_ZH, "# OWNER(归属)\n") - - -def test_hy_extracts_both_speakers_and_keeps_its_own_authorship_rules(tmp_path): - from hy_memory.agent.prompts_zh import EXTRACT_SYSTEM_PROMPT_ZH as native - - for served in _served_extract_prompts(tmp_path): - assert HY_USER_ONLY_LINE not in served - principle = _section(served, "# 提取原则\n") - assert "用户" in principle and "AI 助手" in principle - assert "OWNER" in principle - for heading in ("# OWNER(归属)\n", "# 主体规则\n"): - assert _section(served, heading) == _section(native, heading), heading - for marker in PRODUCT_RULE_MARKERS: - assert marker not in served, marker - - -def test_a_reworded_extraction_line_still_asks_for_both_speakers(tmp_path, monkeypatch, caplog): - from hy_memory.agent.prompts_zh import EXTRACT_SYSTEM_PROMPT_ZH as native - - from one_memory import hy_memory_bridge - - class Reworded: - EXTRACT_SYSTEM_PROMPT_ZH = native.replace(HY_USER_ONLY_LINE, "HY 换了提取原则的措辞。") - - real_import = hy_memory_bridge.import_module - monkeypatch.setattr( - hy_memory_bridge, - "import_module", - lambda name: Reworded if name == "hy_memory.agent.prompts_zh" else real_import(name), - ) - served, _ = _served_extract_prompts(tmp_path) - assert "AI 助手提供的" in served - assert "hy_extraction_scope_rule_not_found" in caplog.text diff --git a/memhub/apps/core/tests/test_hy_permission_context.py b/memhub/apps/core/tests/test_hy_permission_context.py deleted file mode 100644 index 931141e..0000000 --- a/memhub/apps/core/tests/test_hy_permission_context.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Whole native HY client with synthetic content and HTTP MockTransport, never cloud.""" - -import asyncio -import json -from contextlib import contextmanager -from pathlib import Path - -import httpx -import pytest - -from one_memory.hy_permission_context import permission_bridge_factory -from one_memory.imports.grants import SourceGrantStore -from one_memory.imports.models import ( - EvidenceContentKind, - EvidenceInput, - EvidenceOrigin, - OriginMetadata, - WrittenBy, -) -from one_memory.imports.sources.registry import RootCandidateKind -from one_memory.imports.storage import EvidenceStore -from one_memory.memory_governance import MemoryGovernanceStore -from one_memory.memory_processing import MemoryProcessingSupervisor -from one_memory.memory_tasks import MemoryTaskStore -from one_memory.model_content_permissions import ModelContentPermissionStore -from one_memory.model_profiles import ( - ModelCapabilities, - ModelProfileInput, - ModelProfileStore, - ProviderKind, -) -from one_memory.secrets import InMemorySecretStore - - -@contextmanager -def whole_client(tmp_path: Path): - root = tmp_path / "source" - root.mkdir() - database = tmp_path / "one-memory.sqlite3" - grants = SourceGrantStore(database, home=tmp_path) - grants.grant( - user_id="local", source_id="fixture", root=root, candidate_kind=RootCandidateKind.DEFAULT - ) - evidence = EvidenceStore(database) - text = "Synthetic supplied memory: review permissions before release." - record = evidence.put( - EvidenceInput( - source_id="fixture", - conversation_id="session", - message_id="1", - role="assistant", - written_by=WrittenBy.UNKNOWN, - content_kind=EvidenceContentKind.MEMORY_CONTENT, - verbatim=text, - origin_meta={EvidenceOrigin.MANUAL_IMPORT: OriginMetadata()}, - ) - ).record - profiles, secrets = ModelProfileStore(database), InMemorySecretStore() - profile = profiles.create( - ModelProfileInput( - name="Stub only", - provider_kind=ProviderKind.OLLAMA, - base_url="http://127.0.0.1:1/v1", - model="test-generation", - embedding_provider_kind=ProviderKind.OLLAMA, - embedding_base_url="http://127.0.0.1:2/v1", - embedding_model="test-embedding", - ), - secrets, - ) - profiles.record_capabilities( - profile.profile_id, - expected_revision=profile.revision, - capabilities=ModelCapabilities( - endpoint_reachable=True, model_available=True, embedding_available=True - ), - secrets=secrets, - ) - profiles.activate(profile.profile_id, secrets) - permissions = ModelContentPermissionStore(database) - snapshot = permissions.snapshot( - user_id="local", - source_ids=("fixture",), - profile=profiles.snapshot(profile.profile_id, profile.revision), - evidence=(record,), - ) - permission = permissions.grant(snapshot, user_confirmed=True, expected_revision=0) - supervisor = MemoryProcessingSupervisor( - tasks=MemoryTaskStore(database), - profiles=profiles, - secrets=secrets, - evidence=evidence, - governance=MemoryGovernanceStore(database), - data_dir=tmp_path, - model_content_permissions=permissions, - ) - yield supervisor, record, permissions, permission - - -def test_native_memory_content_uses_guarded_sdk_then_returns_real_native_memory(tmp_path): - async def scenario(): - with whole_client(tmp_path) as (supervisor, record, _, _): - sends = [] - - def handler(request: httpx.Request): - body = json.loads(request.content) - sends.append({"path": request.url.path, "body": body}) - if request.url.path.endswith("embeddings"): - return httpx.Response( - 200, - json={ - "object": "list", - "model": "test-embedding", - "data": [ - { - "index": i, - "object": "embedding", - "embedding": [1.0] + [0.0] * 383, - } - for i, _ in enumerate(body["input"]) - ], - "usage": {"prompt_tokens": 1, "total_tokens": 1}, - }, - ) - return httpx.Response( - 200, - json={ - "id": "fixture-response", - "object": "chat.completion", - "created": 1, - "model": "test-generation", - "choices": [ - { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": json.dumps( - [ - { - "op": "ADD", - "content": record.verbatim, - "layer": "L2_FACT", - "owner": "agent", - } - ] - ), - }, - } - ], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, - }, - ) - - supervisor.permission_bridge_factory = permission_bridge_factory( - supervisor._bridge, http_transport=httpx.MockTransport(handler) - ) - tasks = supervisor.prepare_conversation(record) - try: - result = await supervisor._create_runner().run(tasks[0].task_id) - assert result.status == "completed", result.last_error_code - assert sends and all( - item["path"].endswith(("embeddings", "chat/completions")) for item in sends - ) - memories = supervisor.governance.list(user_id="local") - assert len(memories) == 1 and memories[0].content == record.verbatim - assert memories[0].status == "confirmed" - assert supervisor.evidence.get(record.evidence_id).verbatim == record.verbatim - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_revoked_lease_never_creates_an_sdk_client_or_sends(tmp_path): - async def scenario(): - with whole_client(tmp_path) as (supervisor, record, permissions, permission): - tasks = supervisor.prepare_conversation(record) - permissions.revoke( - user_id="local", expected_revision=permission.revision, user_confirmed=True - ) - supervisor.permission_bridge_factory = permission_bridge_factory( - supervisor._bridge, - http_transport=httpx.MockTransport(lambda _: pytest.fail("unauthorized request")), - ) - result = await supervisor._create_runner().run(tasks[0].task_id) - assert result.last_error_code == "model_content_permission_revoked" - assert supervisor._bridges == {} - assert not supervisor.governance.list(user_id="local") - await supervisor.shutdown() - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_hy_permission_context_races.py b/memhub/apps/core/tests/test_hy_permission_context_races.py deleted file mode 100644 index a95bb2f..0000000 --- a/memhub/apps/core/tests/test_hy_permission_context_races.py +++ /dev/null @@ -1,380 +0,0 @@ -"""Bounded factory races with a temporary native HY client and no model calls.""" - -import asyncio -import json -import threading - -import httpx -import pytest -from hy_memory.models.memory import MemoryLayer, MemoryNode - -from one_memory.hy_memory_bridge import HyMemoryBridgeError -from one_memory.hy_permission_coding_store import HyPermissionCodingStore -from one_memory.hy_permission_context import _Wiring, permission_bridge_factory -from one_memory.hy_permission_store import HyPermissionVectorStore -from one_memory.hy_permission_transport import HyPermissionTransportError -from one_memory.memory_governance import MemoryDraft, MemoryKind -from tests.person_door import as_person -from tests.test_hy_permission_context import whole_client - - -def test_cancelled_start_cannot_install_wiring_after_factory_releases_client(tmp_path): - async def scenario(): - with whole_client(tmp_path) as (supervisor, record, _, _): - task = supervisor.prepare_conversation(record)[0] - lease, request, _ = supervisor._create_runner()._prepare_permission_lease(task) - bridge = supervisor._bridge(lease.profile, None, tmp_path) - client = await bridge._get_client() - native_vector, native_graph = client._vector_store, client._graph_store - original_coding = client._config.coding.enable - run = client._loop_thread.run - submitted, release, completed = (threading.Event() for _ in range(3)) - worker_errors = [] - bound_contexts = [] - sends = [] - - def mock_send(request): - sends.append(request) - pytest.fail("This startup-only race must not make any model request") - - def delayed_run(coroutine): - if coroutine.cr_code is not _Wiring.enter.__code__: - return run(coroutine) - # Model a worker being descheduled just before submitting to HY. - submitted.set() - try: - if not release.wait(5): - coroutine.close() - raise TimeoutError("test did not release startup worker") - return run(coroutine) - except BaseException as error: - worker_errors.append(error) - raise - finally: - completed.set() - - client._loop_thread.run = delayed_run - factory = permission_bridge_factory( - supervisor._bridge, http_transport=httpx.MockTransport(mock_send) - ) - - async def ingest(): - async with factory(lease, None, tmp_path) as bound: - bound_contexts.append(bound) - await bound.ingest_extracted(request, (record.verbatim,)) - - pending = asyncio.create_task(ingest()) - try: - assert await asyncio.to_thread(submitted.wait, 5) - pending.cancel() - # A fixed factory may keep cancellation pending until it has - # joined startup. Let both that behavior and early exit proceed. - await asyncio.wait({pending}, timeout=0.1) - released_before_start = not bridge._access_lock.locked() - release.set() - assert await asyncio.to_thread(completed.wait, 5) - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(pending, 5) - assert not worker_errors - assert not sends - assert bridge._access_owner is None - assert not bridge._access_lock.locked() - assert not isinstance(client._vector_store, HyPermissionVectorStore), ( - "Cancelled startup installed a live permission wrapper after the " - "factory exited and released exclusive ownership; " - f"released_before_start={released_before_start}" - ) - assert client._vector_store is native_vector - assert client._graph_store is native_graph - assert client._config.coding.enable == original_coding - finally: - release.set() - if not pending.done(): - pending.cancel() - await asyncio.gather(pending, return_exceptions=True) - assert await asyncio.to_thread(completed.wait, 5) - client._loop_thread.run = run - # The regression leaves finished=True with live wiring; force - # cleanup directly so the failing test leaks no SDK resources. - for bound in bound_contexts: - if bound.wiring is not None: - await asyncio.to_thread(run, bound.wiring.exit()) - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_completed_start_restores_client_and_revokes_saved_vector_reference(tmp_path): - async def scenario(): - with whole_client(tmp_path) as (supervisor, record, _, _): - task = supervisor.prepare_conversation(record)[0] - lease, request, _ = supervisor._create_runner()._prepare_permission_lease(task) - bridge = supervisor._bridge(lease.profile, None, tmp_path) - client = await bridge._get_client() - vector, graph = client._vector_store, client._graph_store - factory = permission_bridge_factory( - supervisor._bridge, - http_transport=httpx.MockTransport(lambda _: pytest.fail("unexpected send")), - ) - try: - async with factory(lease, None, tmp_path) as bound: - await bound._start(request) - wrapped = client._vector_store - assert isinstance(wrapped, HyPermissionVectorStore) - assert client._graph_store is None - coding = client._coding_store - assert isinstance(coding, HyPermissionCodingStore) - assert client._coding_writer.reconciler.store is coding - assert client._cache is None - assert client._vector_store is vector - assert client._graph_store is graph - assert client._coding_writer.reconciler.store is client._coding_store - assert not isinstance(client._coding_store, HyPermissionCodingStore) - assert client._cache is not None - with pytest.raises(HyMemoryBridgeError, match="hy_store_closed"): - wrapped.recheck() - with pytest.raises(HyMemoryBridgeError, match="hy_coding_store_closed"): - coding.recheck() - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_graph_disabled_keeps_graph_absent_even_for_cross_evidence_operations(tmp_path): - async def scenario(): - with whole_client(tmp_path) as (supervisor, record, _, _): - task = supervisor.prepare_conversation(record)[0] - lease, request, _ = supervisor._create_runner()._prepare_permission_lease(task) - bridge = supervisor._bridge(lease.profile, None, tmp_path) - client = await bridge._get_client() - native_graph = client._graph_store - assert native_graph is None - factory = permission_bridge_factory( - supervisor._bridge, - http_transport=httpx.MockTransport(lambda _: pytest.fail("unexpected send")), - ) - try: - async with factory(lease, None, tmp_path) as bound: - await bound._start(request) - assert client._graph_store is None - assert bound.wiring is not None and bound.wiring.graph is None - assert client._graph_store is native_graph - - async with factory(lease, None, tmp_path) as digest_bound: - await digest_bound._start_operation( - user_id=request.user_id, - agent_id=request.agent_id, - request_id="digest-test", - session_id="digest:test", - cross_evidence_scope=True, - ) - assert client._graph_store is None - assert digest_bound.wiring is not None - assert digest_bound.wiring.graph is None - assert client._graph_store is native_graph - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_persisted_native_proof_does_not_authorize_deleted_governed_memory(tmp_path): - async def scenario(): - with whole_client(tmp_path) as (supervisor, record, permissions, permission): - runner = supervisor._create_runner() - task = supervisor.prepare_conversation(record)[0] - lease, request, _ = runner._prepare_permission_lease(task) - factory = permission_bridge_factory( - supervisor._bridge, - http_transport=httpx.MockTransport(lambda _: pytest.fail("unexpected send")), - ) - bridge = supervisor._bridge(lease.profile, None, tmp_path) - client = await bridge._get_client() - run = client._loop_thread.run - native = MemoryNode( - node_id="synthetic-governed-memory", - user_id=request.user_id, - agent_id=request.agent_id, - session_id=request.session_id, - content=record.verbatim, - owner="agent", - layer=MemoryLayer.L2_FACT, - embedding=[1.0] + [0.0] * 383, - ) - try: - # Seed only through the guarded native store; profile readback - # persists the factory's own proof ledger, as in a completed run. - async with factory(lease, None, tmp_path) as first: - await first._start(request) - store = first.wiring.vector - assert await asyncio.to_thread(run, store.upsert(native)) == native.node_id - positive = await asyncio.to_thread(run, store.get_by_id(native.node_id)) - assert positive.content == record.verbatim - await first.profile_memories(user_id=request.user_id, agent_id=request.agent_id) - governed = as_person(supervisor.governance).create( - MemoryDraft( - user_id=request.user_id, - kind=MemoryKind.CHAT, - content=record.verbatim, - scopes=("synthetic-scope",), - evidence_ids=(record.evidence_id,), - ), - request_id="synthetic-publication", - memory_id=native.node_id, - ) - rejected = as_person(supervisor.governance).delete( - governed.memory_id, - user_id=request.user_id, - request_id="synthetic-rejection", - expected_revision=governed.revision, - ) - assert rejected.status == "deleted" - # Explicit renewed consent permits the unchanged source text, - # but must not reinstate a deleted derived native memory. - snapshot = permissions.snapshot( - user_id=request.user_id, - source_ids=(record.source_id,), - profile=lease.profile, - evidence=(record,), - ) - permissions.grant( - snapshot, user_confirmed=True, expected_revision=permission.revision - ) - next_task = supervisor.prepare_conversation(record)[0] - assert next_task.task_id != task.task_id - next_lease, next_request, _ = runner._prepare_permission_lease(next_task) - assert next_lease.source_context is None - assert next_lease.recheck() - async with factory(next_lease, None, tmp_path) as second: - await second._start(next_request) - reused = await asyncio.to_thread( - run, second.wiring.vector.get_by_id(native.node_id) - ) - assert reused is None, ( - "Evidence-only proof reload exposed a deleted governed memory " - "as native context in a new permitted task" - ) - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("rejection_point", ["after_start", "after_proof_load"]) -def test_deletion_during_proof_binding_blocks_next_physical_send(tmp_path, rejection_point): - async def scenario(): - with whole_client(tmp_path) as (supervisor, record, _, _): - task = supervisor.prepare_conversation(record)[0] - lease, request, _ = supervisor._create_runner()._prepare_permission_lease(task) - bridge = supervisor._bridge(lease.profile, None, tmp_path) - client = await bridge._get_client() - run = client._loop_thread.run - sends = [] - - def handler(outbound): - body = json.loads(outbound.content) - sends.append(body) - return httpx.Response( - 200, - json={ - "object": "list", - "model": "test-embedding", - "data": [ - { - "object": "embedding", - "index": i, - "embedding": [1.0] + [0.0] * 383, - } - for i, _ in enumerate(body["input"]) - ], - "usage": {"prompt_tokens": 1, "total_tokens": 1}, - }, - ) - - factory = permission_bridge_factory( - supervisor._bridge, http_transport=httpx.MockTransport(handler) - ) - native = MemoryNode( - node_id="synthetic-rejection-race", - user_id=request.user_id, - agent_id=request.agent_id, - session_id=request.session_id, - content=record.verbatim, - owner="agent", - layer=MemoryLayer.L2_FACT, - embedding=[1.0] + [0.0] * 383, - ) - try: - async with factory(lease, None, tmp_path) as first: - await first._start(request) - await asyncio.to_thread(run, first.wiring.vector.upsert(native)) - permitted = await asyncio.to_thread( - run, first.wiring.vector.get_by_id(native.node_id) - ) - assert permitted is not None - await asyncio.to_thread( - run, client._embed_service.embed(permitted.content, use_cache=False) - ) - assert len(sends) == 1 and sends[0]["input"] == [record.verbatim] - await first.profile_memories(user_id=request.user_id, agent_id=request.agent_id) - - governed = as_person(supervisor.governance).create( - MemoryDraft( - user_id=request.user_id, - kind=MemoryKind.CHAT, - content=record.verbatim, - scopes=("synthetic-scope",), - evidence_ids=(record.evidence_id,), - ), - request_id="synthetic-race-publication", - memory_id=native.node_id, - ) - - def reject(): - result = as_person(supervisor.governance).delete( - governed.memory_id, - user_id=request.user_id, - request_id="synthetic-race-rejection", - expected_revision=governed.revision, - ) - assert result.status == "deleted" - - try: - async with factory(lease, None, tmp_path) as second: - if rejection_point == "after_proof_load": - load = second.proofs.load - - def reject_after_load(current_lease): - approved = load(current_lease) - assert native.node_id in approved - # A governance write can commit between the two - # separately awaited load/guard database reads. - reject() - return approved - - second.proofs.load = reject_after_load - await second._start(request) - if rejection_point == "after_start": - reject() - candidate = await asyncio.to_thread( - run, second.wiring.vector.get_by_id(native.node_id) - ) - if candidate is not None: - await asyncio.to_thread( - run, - client._embed_service.embed(candidate.content, use_cache=False), - ) - except (HyMemoryBridgeError, HyPermissionTransportError) as error: - assert str(error).startswith("model_content_") - assert ( - supervisor.governance.get(native.node_id, user_id="local").status == "deleted" - ) - assert len(sends) == 1, ( - f"Deleted proof context reached the physical transport at {rejection_point}; " - f"expected one approved control send, observed {len(sends)} sends" - ) - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_hy_permission_raw_context.py b/memhub/apps/core/tests/test_hy_permission_raw_context.py deleted file mode 100644 index 237684f..0000000 --- a/memhub/apps/core/tests/test_hy_permission_raw_context.py +++ /dev/null @@ -1,834 +0,0 @@ -"""Real pinned HY SDK/Chroma and guards; scripted HTTP is not model acceptance.""" - -import asyncio -import json -import sqlite3 -import traceback -from contextlib import asynccontextmanager -from datetime import UTC, datetime, timedelta - -import httpx -import pytest -from hy_memory.client import HyMemoryClient -from hy_memory.data.vector_store_chroma import ChromaVectorStore -from hy_memory.models.memory import MemoryLayer, MemoryNode -from hy_memory.pipelines._retrieval.reconcile_retrieval import ReconcileHybridRetriever - -from one_memory.hy_permission_context import permission_bridge_factory -from one_memory.hy_response_guard import HyReconcileGuard, HyResponseGuard -from one_memory.hy_runtime_config import hy_memory_version -from one_memory.hy_write_verification import HyExtractionGuard -from one_memory.imports.grants import SourceGrantStore -from one_memory.imports.models import ( - EvidenceContentKind, - EvidenceInput, - EvidenceOrigin, - OriginMetadata, - WrittenBy, -) -from one_memory.imports.sources.registry import RootCandidateKind -from one_memory.imports.storage import EvidenceStore -from one_memory.memory_governance import MemoryGovernanceStore -from one_memory.memory_processing import MemoryProcessingSupervisor -from one_memory.memory_tasks import MemoryTaskStore, evidence_ref -from one_memory.model_content_permissions import ModelContentPermissionStore -from one_memory.model_profiles import ( - ModelCapabilities, - ModelProfileInput, - ModelProfileStore, - ProviderKind, -) -from one_memory.secrets import InMemorySecretStore - -USER_TEXT = "The user requires a permission review before the synthetic release." -AGENT_TEXT = "The agent proposes a checklist for the synthetic release." -CLAIMS = {USER_TEXT: "user", AGENT_TEXT: "agent"} -DENIED_FACT = "DENIED_NATIVE_FACT_63a1: omit the permission review." -DENIED_HISTORY = "DENIED_NATIVE_HISTORY_86b2: publish without asking." -DENIED_TAG = "DENIED_NATIVE_TAG_91c3" -DENIED_IDS = ("denied-native-fact", "denied-native-history") -NOW = datetime(2026, 9, 13, 8, tzinfo=UTC) - - -def vector(text): - # Orthogonal synthetic vectors keep the two claims distinct under native dedup. - axis = 1 if text == USER_TEXT else 2 if text == AGENT_TEXT else 0 - return [float(index == axis) for index in range(384)] - - -def prompt_json(prompt, heading): - payload = prompt_section(prompt, heading) - starts = [index for marker in ("[", "{") if (index := payload.find(marker)) >= 0] - assert starts, (heading, payload) - payload = payload[min(starts) :] - try: - return json.JSONDecoder().raw_decode(payload)[0] - except json.JSONDecodeError as error: - raise AssertionError(f"expected JSON after {heading!r}, got {payload[:500]!r}") from error - - -def prompt_section(prompt: str, heading: str) -> str: - assert prompt.count(heading) == 1, heading - section = prompt.split(heading, 1)[1] - return section.split("\n---\n", 1)[0].strip() - - -class ScriptedOracle: - """Only the two fixture claims receive support, with exact supplied Evidence IDs.""" - - def __init__(self, records, *, after_response=None): - self.records = {record.evidence_id: record for record in records} - self.requests = [] - self.stages = [] - self.reconciles = [] - self.errors = [] - self.after_response = after_response - self.revoked_at = None - self.limit = 24 - - def __call__(self, request): - try: - return self.respond(request) - except Exception: - self.errors.append(traceback.format_exc()) - raise - - def respond(self, request): - body = json.loads(request.content) - self.requests.append((request.url.path, body)) - assert len(self.requests) <= self.limit, "scripted HTTP response budget exceeded" - assert self.revoked_at is None, "native child request escaped after revocation" - assert request.method == "POST" - serialized = json.dumps(body) - for forbidden in (DENIED_FACT, DENIED_HISTORY, DENIED_TAG, *DENIED_IDS): - assert forbidden not in serialized, f"denied native context reached HTTP: {forbidden}" - if request.url.path == "/v1/embeddings": - assert str(request.url) == "http://127.0.0.1:2/v1/embeddings" - assert body["model"] == "raw-fixture-embedding" - texts = body["input"] - assert isinstance(texts, list) and texts - lines = [f"[{record.role}]: {record.verbatim}" for record in self.records.values()] - # HY stores one raw copy per add, and each add is one turn. - turns = { - "\n".join(lines[start:end]) - for start in range(len(lines)) - for end in range(start + 1, len(lines) + 1) - } - for text in texts: - if text not in CLAIMS: - assert text in turns - self.stages.append("embedding") - return httpx.Response( - 200, - json={ - "object": "list", - "model": body["model"], - "data": [ - {"index": i, "object": "embedding", "embedding": vector(text)} - for i, text in enumerate(texts) - ], - "usage": {"prompt_tokens": 1, "total_tokens": 1}, - }, - ) - assert str(request.url) == "http://127.0.0.1:1/v1/chat/completions" - assert body["model"] == "raw-fixture-generation" - prompt = body["messages"][-1]["content"] - if prompt.startswith("You are a single-label scene classifier."): - result = {"is_coding": False, "reason": "synthetic chat"} - stage = "judge" - elif "## New Messages\n" in prompt: - self.check_turns(prompt_json(prompt, "## New Messages\n")) - history_section = prompt_section(prompt, "## Last k Messages\n") - history = ( - [] if history_section == "(none)" else prompt_json(prompt, "## Last k Messages\n") - ) - for source in history: - record = self.records[source["evidence_id"]] - assert source["content"] == record.verbatim - assert source["role"] == record.role - assert source["revision"] == record.revision - assert '"memory"' in prompt - assert ('"intentions"' in prompt) is ("never" not in prompt) - result = { - "memory": [ - {"content": text, "owner": owner, "tags": []} for text, owner in CLAIMS.items() - ], - "basic_info": {}, - } - if '"intentions"' in prompt: - result["intentions"] = [] - stage = "extract" - elif "## Existing memories\n" in prompt: - existing = prompt_json(prompt, "## Existing memories\n") - incoming = prompt_json(prompt, "## New memories\n") - assert existing and incoming - for item in (*existing, *incoming): - assert CLAIMS[item["content"]] == item["owner"] - assert not item.get("tags") - self.reconciles.append(existing) - by_content = {item["content"]: item for item in existing} - result = [ - { - "op": "UPDATE" if item["content"] in by_content else "ADD", - "content": item["content"], - "owner": item["owner"], - "layer": "L2_FACT", - "tags": [], - **( - {"memory_id": by_content[item["content"]]["memory_id"]} - if item["content"] in by_content - else {} - ), - } - for item in incoming - ] - stage = "reconcile" - else: - if not prompt.lstrip().startswith("{"): - raise AssertionError(f"unknown native child prompt: {prompt[:300]!r}") - data = json.loads(prompt) - if set(data) == {"items", "sources", "before"}: - result = self.review(data) - stage = "review" - else: - assert set(data) == {"proofs"}, "unknown native child prompt" - result = self.cited_review(data) - stage = "citations" - self.stages.append(stage) - response = httpx.Response( - 200, - json={ - "id": "synthetic-raw-response", - "object": "chat.completion", - "created": 1, - "model": body["model"], - "choices": [ - { - "index": 0, - "finish_reason": "stop", - "message": {"role": "assistant", "content": json.dumps(result)}, - } - ], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, - }, - ) - if self.after_response is not None and self.after_response(stage): - self.revoked_at = len(self.requests) - return response - - def check_turns(self, turns): - assert turns and isinstance(turns, list) - allowed = {(record.role, record.verbatim) for record in self.records.values()} - assert all(set(turn) == {"role", "content"} for turn in turns) - assert {(turn["role"], turn["content"]) for turn in turns} <= allowed - - def review(self, data): - assert data["items"] and data["sources"] - for source in data["sources"]: - record = self.records[source["id"]] - assert source["role"] == record.role - assert source["revision"] == record.revision - assert source["created_at"] == record.created_at.isoformat() - assert "".join(span["text"] for span in source["spans"]) == record.verbatim - reviews = [] - for item in data["items"]: - assert CLAIMS[item["content"]] == item["owner"] - role = "user" if item["owner"] == "user" else "assistant" - source = next( - source - for source in data["sources"] - if source["role"] == role and self.records[source["id"]].verbatim == item["content"] - ) - reviews.append( - { - "id": item["id"], - "clauses": [ - { - "text": item["content"], - "verdict": "supported", - "authority": item["owner"], - "citations": [ - { - "source_id": source["id"], - "span_ids": [span["id"] for span in source["spans"]], - } - ], - "reason": "exact synthetic fixture claim", - } - ], - } - ) - for before in data["before"]: - assert any( - (item["content"], item["owner"]) == (before["content"], before["owner"]) - for item in data["items"] - ) - return { - "reviews": reviews, - "coverage": [ - {"id": item["id"], "verdict": "preserved", "reason": "exact fixture match"} - for item in data["before"] - ], - } - - def cited_review(self, data): - assert data["proofs"] - for proof in data["proofs"]: - assert CLAIMS[proof["claim"]] == proof["owner"] - assert proof["citations"] and not proof["context_citations"] - for citation in proof["citations"]: - record = self.records[citation["source_id"]] - assert citation["text"] == record.verbatim == proof["claim"] - assert citation["role"] == record.role - assert citation["revision"] == record.revision - assert citation["created_at"] == record.created_at.isoformat() - return { - "proofs": [ - {"id": proof["id"], "verdict": "supported", "reason": "exact fixture match"} - for proof in data["proofs"] - ] - } - - -def put_turn(evidence, message_id, role, text, ordinal, *, conversation_id="raw-session"): - return evidence.put( - EvidenceInput( - user_id="local", - source_id="raw-fixture", - conversation_id=conversation_id, - message_id=message_id, - source_ordinal=str(ordinal), - role=role, - written_by={"user": WrittenBy.PERSON, "tool": WrittenBy.TOOL}.get( - role, WrittenBy.AGENT - ), - content_kind=EvidenceContentKind.RAW_CONVERSATION, - created_at=NOW + timedelta(seconds=ordinal), - verbatim=text, - origin_meta={EvidenceOrigin.MANUAL_IMPORT: OriginMetadata()}, - ) - ).record - - -def grant_records(supervisor, records, expected_revision=0): - profile = supervisor.profiles.active(supervisor.secrets) - snapshot = supervisor.model_content_permissions.snapshot( - user_id="local", - source_ids=("raw-fixture",), - profile=supervisor.profiles.snapshot(profile.profile_id, profile.revision), - evidence=records, - ) - return supervisor.model_content_permissions.grant( - snapshot, user_confirmed=True, expected_revision=expected_revision - ) - - -@asynccontextmanager -async def raw_client(tmp_path, *, tool=None, conversation_id="raw-session"): - assert hy_memory_version() == "2.0.2" - root = tmp_path / "source" - root.mkdir() - database = tmp_path / "one-memory.sqlite3" - grants = SourceGrantStore(database, home=tmp_path) - grants.grant( - user_id="local", - source_id="raw-fixture", - root=root, - candidate_kind=RootCandidateKind.DEFAULT, - ) - evidence = EvidenceStore(database) - records = ( - put_turn( - evidence, - "user-original", - "user", - USER_TEXT, - 1, - conversation_id=conversation_id, - ), - put_turn( - evidence, - "agent-original", - "assistant", - AGENT_TEXT, - 2, - conversation_id=conversation_id, - ), - *( - ( - put_turn( - evidence, - "tool-original", - "tool", - tool, - 3, - conversation_id=conversation_id, - ), - ) - if tool - else () - ), - ) - profiles, secrets = ModelProfileStore(database), InMemorySecretStore() - profile = profiles.create( - ModelProfileInput( - name="Synthetic HTTP only", - provider_kind=ProviderKind.OLLAMA, - base_url="http://127.0.0.1:1/v1", - model="raw-fixture-generation", - embedding_provider_kind=ProviderKind.OLLAMA, - embedding_base_url="http://127.0.0.1:2/v1", - embedding_model="raw-fixture-embedding", - embedding_dimensions=384, - ), - secrets, - ) - profiles.record_capabilities( - profile.profile_id, - expected_revision=profile.revision, - capabilities=ModelCapabilities( - endpoint_reachable=True, model_available=True, embedding_available=True - ), - secrets=secrets, - ) - profiles.activate(profile.profile_id, secrets) - supervisor = MemoryProcessingSupervisor( - tasks=MemoryTaskStore(database), - profiles=profiles, - secrets=secrets, - evidence=evidence, - governance=MemoryGovernanceStore(database), - data_dir=tmp_path, - model_content_permissions=ModelContentPermissionStore(database), - ) - try: - permission = grant_records(supervisor, records) - assert supervisor.governance.list(user_id="local") == [] - yield supervisor, records, permission - finally: - await supervisor.shutdown() - - -def test_search_after_first_wb20_task_does_not_poison_the_second_task(tmp_path): - async def scenario(): - async with raw_client(tmp_path, conversation_id="wb-20") as ( - supervisor, - records, - permission, - ): - first = supervisor.prepare_conversation(records[0])[0] - _, request, _ = supervisor._create_runner()._prepare_permission_lease(first) - client = await native_client(supervisor, first) - duplicate = MemoryNode( - node_id="older-identical-memory", - user_id=request.user_id, - agent_id=request.agent_id, - session_id="wb-19", - layer=MemoryLayer.L2_FACT, - content=USER_TEXT, - owner="user", - embedding=vector(USER_TEXT), - gmt_created=NOW - timedelta(days=1), - gmt_modified=NOW - timedelta(days=1), - ) - await on_native_loop(client, client._vector_store.upsert(duplicate)) - - first_oracle = ScriptedOracle(records) - bind_oracle(supervisor, first_oracle) - first_result = await supervisor._create_runner().run(first.task_id) - assert first_result.status == "completed", first_result.last_error_code - - current = next( - memory - for memory in supervisor.governance.list(user_id="local") - if memory.content == USER_TEXT - ) - client._spawn_search_dedup( - [ - { - "memory_id": duplicate.node_id, - "content": duplicate.content, - "layer": duplicate.layer.value, - "gmt_created": int(duplicate.gmt_created.timestamp()), - }, - { - "memory_id": current.memory_id, - "content": current.content, - "layer": MemoryLayer.L2_FACT.value, - "gmt_created": int(NOW.timestamp()), - }, - ], - request_id="wb-20-search", - user_id=request.user_id, - agent_id=request.agent_id, - ) - await asyncio.sleep(0.2) - - added = put_turn( - supervisor.evidence, - "wb-20-second", - "user", - USER_TEXT, - 3, - conversation_id="wb-20", - ) - records = (*records, added) - grant_records(supervisor, records, permission.revision) - second = supervisor.prepare_conversation(added)[0] - second_oracle = ScriptedOracle(records) - bind_oracle(supervisor, second_oracle) - second_result = await supervisor._create_runner().run(second.task_id) - - assert second_result.status == "completed", second_result.last_error_code - assert second_result.last_error_code is None - - asyncio.run(scenario()) - - -async def native_client(supervisor, task): - bridge = supervisor._bridge(task.profile_snapshot, None, supervisor.data_dir) - client = await bridge._get_client() - assert isinstance(client, HyMemoryClient) - assert isinstance(client._vector_store, ChromaVectorStore) - guards = client._one_memory_write_guards - assert isinstance(guards[0], HyResponseGuard) - assert isinstance(guards[2], HyReconcileGuard) - assert isinstance(guards[3], HyExtractionGuard) - return client - - -def test_governance_write_runs_after_shared_hy_bridge_is_released(tmp_path): - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, _permission): - task = supervisor.prepare_conversation(records[0])[0] - bind_oracle(supervisor, ScriptedOracle(records)) - await native_client(supervisor, task) - bridge = next( - value - for key, value in supervisor._bridges.items() - if key[:2] == (task.profile_id, task.profile_revision) - ) - runner = supervisor._create_runner() - original = runner._record_outputs - observed = [] - - def checked(*args, **kwargs): - observed.append(bridge._access_owner) - return original(*args, **kwargs) - - runner._record_outputs = checked - result = await runner.run(task.task_id) - - assert result.status == "completed" - assert observed == [None], "governance writes must not hold the shared HY bridge" - - asyncio.run(scenario()) - - -async def on_native_loop(client, operation): - return await asyncio.to_thread(client._loop_thread.run, operation) - - -async def seed_denied(client, request): - store = client._vector_store - nodes = ( - MemoryNode( - node_id=DENIED_IDS[0], - user_id=request.user_id, - agent_id=request.agent_id, - session_id=request.session_id, - layer=MemoryLayer.L2_FACT, - content=DENIED_FACT, - owner="user", - embedding=vector(USER_TEXT), - tags=[DENIED_TAG], - ), - MemoryNode( - node_id=DENIED_IDS[1], - user_id=request.user_id, - agent_id=request.agent_id, - session_id=request.session_id, - layer=MemoryLayer.L1_RAW, - content=f"[user]: {DENIED_HISTORY}", - owner="user", - embedding=vector("raw"), - tags=[DENIED_TAG], - ), - ) - await on_native_loop(client, store.upsert_batch(list(nodes))) - retrieved = await on_native_loop( - client, - store.search( - query_embedding=vector(USER_TEXT), - user_id=request.user_id, - agent_ids=[request.agent_id], - layers=[MemoryLayer.L2_FACT], - ), - ) - assert {hit["node_id"] for hit in retrieved} == {DENIED_IDS[0]} - history = await on_native_loop( - client, - store.list_by_user( - user_id=request.user_id, - agent_id=request.agent_id, - layers=[MemoryLayer.L1_RAW], - allow_raw_layer=True, - ), - ) - assert [node.node_id for node in history] == [DENIED_IDS[1]] - candidates = await on_native_loop( - client, - ReconcileHybridRetriever().search_candidates( - USER_TEXT, - vector(USER_TEXT), - vector_store=store, - user_id=request.user_id, - agent_id=request.agent_id, - layers=[MemoryLayer.L2_FACT], - ), - ) - assert [hit["node"].node_id for hit in candidates] == [DENIED_IDS[0]] - pipeline = await on_native_loop(client, client._registry.get_system2_writer()) - native_history = await on_native_loop( - client, - pipeline._writer._get_recent_history(store, request.user_id, request.agent_id), - ) - native_tags = await on_native_loop( - client, - pipeline._writer._collect_existing_tags(store, request.user_id, request.agent_id), - ) - assert DENIED_HISTORY in native_history - assert DENIED_TAG in native_tags - return await node_snapshots(client, DENIED_IDS) - - -async def node_snapshots(client, identifiers): - result = {} - for identifier in identifiers: - node = await on_native_loop(client, client._vector_store.get_by_id(identifier)) - assert node is not None, identifier - result[identifier] = node.to_dict() - return result - - -def bind_oracle(supervisor, oracle): - supervisor.permission_bridge_factory = permission_bridge_factory( - supervisor._bridge, http_transport=httpx.MockTransport(oracle) - ) - - -async def assert_outputs(supervisor, task, records, client, *, raw_records=None): - outputs = supervisor.tasks.outputs(task.task_id) - assert len(outputs) == 2 - memories = supervisor.governance.list(user_id="local") - assert {memory.content for memory in memories} == set(CLAIMS) - by_content = {memory.content: memory for memory in memories} - for record in records[:2]: - memory = by_content[record.verbatim] - output = next(output for output in outputs if output.memory_id == memory.memory_id) - assert output.owner == CLAIMS[record.verbatim] - assert output.engine == "hy-memory" and output.engine_version == "2.0.2" - assert output.hy_memory_id == memory.memory_id - assert output.memory_revision == memory.revision - assert output.support_refs == (evidence_ref(record),) - assert memory.evidence_ids == (record.evidence_id,) - # Usable either way; origin is what keeps an agent report from passing as the person. - assert memory.status == "confirmed" - assert memory.origin == ("agent" if output.owner == "agent" else "extracted") - stored = await on_native_loop(client, client._vector_store.get_by_id(output.hy_memory_id)) - assert stored is not None - assert stored.content == record.verbatim and stored.owner == output.owner - for record in records: - assert supervisor.evidence.get(record.evidence_id) == record - raw = await on_native_loop( - client, - client._vector_store.list_by_user( - user_id="local", - layers=[MemoryLayer.L1_RAW], - allow_raw_layer=True, - ), - ) - allowed_raw = [node for node in raw if node.node_id not in DENIED_IDS] - assert allowed_raw - # One raw copy per turn; together they hold every record. - for record in records if raw_records is None else raw_records: - line = f"[{record.role}]: {record.verbatim}" - assert any(line in node.content for node in allowed_raw), line - with sqlite3.connect(supervisor.data_dir / "one-memory.sqlite3") as db: - proofs = db.execute("SELECT node_id, evidence_json FROM hy_node_permissions").fetchall() - assert {output.hy_memory_id for output in outputs} <= {key for key, _ in proofs} - assert not set(DENIED_IDS) & {key for key, _ in proofs} - allowed_refs = [evidence_ref(record) for record in records] - for _, refs in proofs: - assert json.loads(refs) and all(ref in allowed_refs for ref in json.loads(refs)) - return {output.hy_memory_id for output in outputs} - - -def record_receipt(test_node, supervisor, task, oracle, *, phase, hidden): - test_node.user_properties.append( - ( - phase, - json.dumps( - { - "sdk": hy_memory_version(), - "transport": "scripted-http-oracle-not-real-model", - "request_count": len(oracle.requests), - "response_limit": oracle.limit, - "stages": oracle.stages, - "oracle_errors": oracle.errors, - "requests_after_revocation": len(oracle.requests) - oracle.revoked_at - if oracle.revoked_at is not None - else None, - "hidden_native_fixture_checked": hidden, - "status": task.status, - "error": task.last_error_code, - "outputs": [ - output.model_dump(mode="json") - for output in supervisor.tasks.outputs(task.task_id) - ], - "governance_count": len(supervisor.governance.list(user_id="local")), - } - ), - ) - ) - - -@pytest.mark.parametrize("hidden", [False, True], ids=["fresh-raw", "denied-native-context"]) -def test_native_raw_input_runs_extraction_guards_and_governance(tmp_path, hidden, request): - test_node = request.node - - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, _): - tasks = supervisor.prepare_conversation(records[0]) - assert len(tasks) == 1 - task = tasks[0] - runner = supervisor._create_runner() - _, request, _ = runner._prepare_permission_lease(task) - client = await native_client(supervisor, task) - before = await seed_denied(client, request) if hidden else {} - oracle = ScriptedOracle(records) - bind_oracle(supervisor, oracle) - result = await supervisor._create_runner().run(task.task_id) - assert not oracle.errors, oracle.errors - if hidden: - assert await node_snapshots(client, DENIED_IDS) == before - assert result.status == "completed", (result.last_error_code, oracle.stages) - assert oracle.stages.count("extract") == 1 - assert "review" not in oracle.stages and "citations" not in oracle.stages - assert "embedding" in oracle.stages - assert "reconcile" not in oracle.stages - await assert_outputs(supervisor, task, records, client) - record_receipt(test_node, supervisor, result, oracle, phase="raw", hidden=hidden) - - asyncio.run(scenario()) - - -def test_a_tool_turn_hy_judges_as_chat_is_extracted_without_its_tool_messages(tmp_path): - async def scenario(): - async with raw_client(tmp_path, tool="[Bash] pytest -q\n12 passed") as ( - supervisor, - records, - _, - ): - tasks = supervisor.prepare_conversation(records[0]) - assert len(tasks) == 1 - oracle = ScriptedOracle(records) - bind_oracle(supervisor, oracle) - result = await supervisor._create_runner().run(tasks[0].task_id) - assert not oracle.errors, oracle.errors - assert result.status == "completed", (result.last_error_code, oracle.stages) - assert oracle.stages[0] == "judge" - prompt = next( - body["messages"][-1]["content"] - for _path, body in oracle.requests - if "messages" in body and "## New Messages\n" in body["messages"][-1]["content"] - ) - turns = prompt_json(prompt, "## New Messages\n") - assert [(turn["role"], turn["content"]) for turn in turns] == [ - (record.role, record.verbatim) for record in records[:2] - ] - client = await native_client(supervisor, tasks[0]) - await assert_outputs(supervisor, tasks[0], records, client, raw_records=records[:2]) - - asyncio.run(scenario()) - - -def test_revoke_during_native_extractor_response_stops_children_and_governance(tmp_path, request): - test_node = request.node - - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, permission): - task = supervisor.prepare_conversation(records[0])[0] - _, request, _ = supervisor._create_runner()._prepare_permission_lease(task) - client = await native_client(supervisor, task) - before = await seed_denied(client, request) - - def revoke(stage): - if stage != "extract": - return False - supervisor.model_content_permissions.revoke( - user_id="local", expected_revision=permission.revision, user_confirmed=True - ) - return True - - oracle = ScriptedOracle(records, after_response=revoke) - bind_oracle(supervisor, oracle) - result = await supervisor._create_runner().run(task.task_id) - assert not oracle.errors, oracle.errors - assert result.status == "cancelled" - assert oracle.revoked_at is not None and len(oracle.requests) == oracle.revoked_at - assert oracle.stages.count("extract") == 1 - assert "review" not in oracle.stages and "citations" not in oracle.stages - assert not supervisor.governance.list(user_id="local") - assert not supervisor.tasks.outputs(task.task_id) - assert await node_snapshots(client, DENIED_IDS) == before - for record in records: - assert supervisor.evidence.get(record.evidence_id) == record - record_receipt(test_node, supervisor, result, oracle, phase="revoked", hidden=True) - assert result.last_error_code == "model_content_permission_revoked", result - - asyncio.run(scenario()) - - -def test_approved_native_candidates_are_reused_without_disclosing_denied_nodes(tmp_path, request): - test_node = request.node - - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, permission): - first = supervisor.prepare_conversation(records[0])[0] - _, request, _ = supervisor._create_runner()._prepare_permission_lease(first) - client = await native_client(supervisor, first) - before = await seed_denied(client, request) - first_oracle = ScriptedOracle(records) - bind_oracle(supervisor, first_oracle) - first_result = await supervisor._create_runner().run(first.task_id) - assert not first_oracle.errors, first_oracle.errors - assert first_result.status == "completed", first_result.last_error_code - ids = await assert_outputs(supervisor, first, records, client) - record_receipt( - test_node, supervisor, first_result, first_oracle, phase="initial", hidden=True - ) - added = put_turn(supervisor.evidence, "user-repeat", "user", USER_TEXT, 3) - records = (*records, added) - grant_records(supervisor, records, permission.revision) - second = supervisor.prepare_conversation(added)[0] - assert second.task_id != first.task_id - oracle = ScriptedOracle(records) - bind_oracle(supervisor, oracle) - result = await supervisor._create_runner().run(second.task_id) - assert not oracle.errors, oracle.errors - assert await node_snapshots(client, DENIED_IDS) == before - assert result.status == "completed", (result.last_error_code, oracle.stages) - # SDK 2.0.2 keeps the `limit=1` pre-check removed, so - # skip reconcile, so the second write now compares against the - # already-approved candidates instead of blindly ADDing them. The - # contract is the reuse itself, not the absence of the call: one - # reconcile pass, the returning ids are the ones already governed, - # and no third/fourth memory appears. - assert oracle.stages.count("reconcile") == 1 - assert {item["memory_id"] for item in oracle.reconciles[0]} == ids - assert await assert_outputs(supervisor, second, records, client) == ids - assert len(supervisor.governance.list(user_id="local")) == 2 - record_receipt(test_node, supervisor, result, oracle, phase="reuse", hidden=True) - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_hy_permission_store.py b/memhub/apps/core/tests/test_hy_permission_store.py deleted file mode 100644 index 0631c54..0000000 --- a/memhub/apps/core/tests/test_hy_permission_store.py +++ /dev/null @@ -1,1210 +0,0 @@ -"""Native HY node/algorithm tests with synthetic data and an in-memory backend only.""" - -import asyncio -import math -from copy import deepcopy -from datetime import UTC, datetime, timedelta -from types import SimpleNamespace - -import pytest -from hy_memory.agent.tools.basic_profile import upsert_basic_profile -from hy_memory.data.vector_store_chroma import ChromaVectorStore -from hy_memory.models.memory import MemoryLayer, MemoryNode, MemoryStatus -from hy_memory.pipelines._retrieval.dedup import DedupItem, execute_dedup -from hy_memory.pipelines._retrieval.evolution import _trace_full_chain -from hy_memory.pipelines.writer import MemoryWriter - -from one_memory.hy_memory_bridge import HyMemoryBridgeError -from one_memory.hy_permission_store import ( - HyNodePermission, - HyPermissionVectorStore, - _native_write_lock, - hy_node_fingerprint, - native_store_read_slot, -) -from one_memory.hy_source_context import HySourceContext, HySupportedMemory -from one_memory.imports.models import EvidenceContentKind, EvidenceRecord, WrittenBy - - -def test_native_semantic_read_yields_immediately_to_an_active_write() -> None: - async def scenario() -> tuple[bool, bool]: - async with _native_write_lock(), native_store_read_slot() as during_write: - pass - async with native_store_read_slot() as after_write: - pass - return during_write, after_write - - assert asyncio.run(scenario()) == (False, True) - - -def evidence(key): - now = datetime(2026, 9, 13, tzinfo=UTC) - return EvidenceRecord( - evidence_id=key, - user_id="local", - source_id="cursor", - workspace=None, - project=None, - conversation_id="session", - message_id=key, - correlation_id=None, - source_ordinal=None, - content_kind=EvidenceContentKind.RAW_CONVERSATION, - role="user", - written_by=WrittenBy.PERSON, - created_at=now, - verbatim=f"Synthetic evidence {key}", - origin_meta={}, - content_hash="a" * 64, - revision=1, - imported_at=now, - updated_at=now, - ) - - -def node(key, **kwargs): - return MemoryNode( - **{ - "node_id": key, - "user_id": "local", - "agent_id": "agent", - "session_id": "session", - "content": f"Synthetic {key}", - "owner": "user", - "layer": MemoryLayer.L2_FACT, - "embedding": [1.0, 0.0], - **kwargs, - } - ) - - -class Lease: - def __init__(self, known=()): - self.task_id = "task" - self.receipt = SimpleNamespace(user_id="local") - self.records = (evidence("current"), evidence("support")) - self.source_context = HySourceContext( - current=self.records[:1], - supporting=self.records[1:], - known_memories=known, - ) - self.revoked = False - self.checks = 0 - - def recheck(self): - self.checks += 1 - if self.revoked: - raise HyMemoryBridgeError("model_content_permission_revoked") - return self.receipt - - -class Backend: - def __init__(self, *nodes): - self.nodes = {n.node_id: deepcopy(n) for n in nodes} - self.calls = [] - self.after_read = lambda: None - self.corrupt_write = lambda _: None - - async def get_by_id(self, key): - self.calls.append(("get", key)) - result = deepcopy(self.nodes.get(key)) - self.after_read() - return result - - async def list_by_user(self, **kwargs): - self.calls.append(("list", kwargs)) - # Deliberately return foreign/unsupported rows to exercise defense in depth. - return deepcopy(list(self.nodes.values())) - - async def search(self, **kwargs): - self.calls.append(("search", kwargs)) - hits = [] - for n in self.nodes.values(): - candidate = deepcopy(n) - candidate.embedding = None # Native Chroma search omits the vector. - hits.append( - { - "node_id": n.node_id, - "node": candidate, - "score": 0.9, - "untrusted_extra": "DENIED_HIT_METADATA", - } - ) - return hits - - async def upsert(self, value): - self.calls.append(("upsert", value.node_id)) - self.nodes[value.node_id] = deepcopy(value) - self.corrupt_write(self.nodes[value.node_id]) - return value.node_id - - async def upsert_batch(self, values): - return [await self.upsert(n) for n in values] - - async def update_payload(self, key, updates): - self.calls.append(("update", key, deepcopy(updates))) - self.nodes[key] = MemoryNode.from_dict({**self.nodes[key].to_dict(), **deepcopy(updates)}) - self.corrupt_write(self.nodes[key]) - return True - - async def update_embedding(self, key, embedding): - self.calls.append(("embedding", key)) - self.nodes[key].embedding = deepcopy(embedding) - return True - - async def delete(self, key): - self.calls.append(("delete", key)) - self.nodes.pop(key, None) - return True - - -def permission(value, ids=("current", "support")): - return HyNodePermission(fingerprint=hy_node_fingerprint(value), evidence_ids=ids) - - -def wrapper(backend, *, lease=None, approved=(), refresh_permission=None): - return HyPermissionVectorStore( - backend, - lease or Lease(), - agent_id="agent", - session_id="session", - node_permissions={n.node_id: permission(n) for n in approved}, - refresh_permission=refresh_permission, - ) - - -def test_cross_evidence_scope_is_opt_in_and_default_leases_stay_session_bound(): - lease = Lease() - mismatched = lease.records[1].model_copy(update={"conversation_id": "other-conversation"}) - lease.records = (lease.records[0], mismatched) - lease.source_context = HySourceContext(current=lease.records) - - with pytest.raises(HyMemoryBridgeError, match="scope"): - HyPermissionVectorStore( - Backend(), - lease, - agent_id="agent", - session_id="session", - ) - - store = HyPermissionVectorStore( - Backend(), - lease, - agent_id="agent", - session_id="digest:task", - cross_evidence_scope=True, - ) - assert store is not None - - -def test_cross_evidence_scope_does_not_relax_user_or_evidence_integrity(): - lease = Lease() - foreign = lease.records[1].model_copy(update={"user_id": "someone-else"}) - lease.records = (lease.records[0], foreign) - with pytest.raises(HyMemoryBridgeError, match="scope"): - HyPermissionVectorStore( - Backend(), - lease, - agent_id="agent", - session_id="digest:task", - cross_evidence_scope=True, - ) - - duplicate = Lease() - duplicate.records = (duplicate.records[0], duplicate.records[0]) - with pytest.raises(HyMemoryBridgeError, match="scope"): - HyPermissionVectorStore( - Backend(), - duplicate, - agent_id="agent", - session_id="digest:task", - cross_evidence_scope=True, - ) - - -def test_cross_evidence_scope_allows_digest_to_read_other_sessions_of_the_same_agent(): - lease = Lease() - lease.source_context = HySourceContext(current=lease.records) - existing = node("digest-source", session_id="note:one") - backend = Backend(existing) - approved = {existing.node_id: permission(existing)} - regular = wrapper(backend, approved=(existing,)) - digest = HyPermissionVectorStore( - backend, - lease, - agent_id="agent", - session_id="digest:task", - cross_evidence_scope=True, - node_permissions=approved, - ) - - async def scenario(): - assert await regular.get_by_id(existing.node_id) is None - found = await digest.get_by_id(existing.node_id) - assert found is not None and found.node_id == existing.node_id - - asyncio.run(scenario()) - - -def test_only_digest_scope_may_advance_the_system2_evidence_counter_once(): - approved = node("digest-source", custom={}) - lease = Lease() - lease.source_context = HySourceContext(current=lease.records) - backend = Backend(approved) - permissions = {approved.node_id: permission(approved)} - regular = wrapper(backend, approved=(approved,)) - digest = HyPermissionVectorStore( - backend, - lease, - agent_id="agent", - session_id="digest:task", - cross_evidence_scope=True, - node_permissions=permissions, - ) - - async def scenario(): - with pytest.raises(HyMemoryBridgeError, match="update_fields"): - await regular.update_payload(approved.node_id, {"custom": {"s2_evidence_count": 1}}) - assert await digest.update_payload(approved.node_id, {"custom": {"s2_evidence_count": 1}}) - with pytest.raises(HyMemoryBridgeError, match="update_fields"): - await digest.update_payload( - approved.node_id, - {"custom": {"s2_evidence_count": 3, "extra": "DENIED"}}, - ) - - asyncio.run(scenario()) - - -def test_known_content_alone_cannot_authorize_native_extras_or_mutation(): - allowed = node("known", tags=["DENIED_TAG"], custom={"note": "DENIED_CUSTOM"}) - known = HySupportedMemory( - hy_memory_id=allowed.node_id, - governed_memory_id="governed", - revision=1, - content=allowed.content, - owner="user", - evidence_ids=("current",), - ) - backend = Backend(allowed) - store = wrapper(backend, lease=Lease((known,))) - - async def scenario(): - assert await store.get_by_id("known") is None - assert await store.list_by_user("local") == [] - with pytest.raises(HyMemoryBridgeError, match="target_denied"): - await store.update_payload("known", {"content": "changed"}) - - asyncio.run(scenario()) - assert backend.nodes["known"].custom == allowed.custom - assert not any(call[0] == "update" for call in backend.calls) - - -def test_all_native_read_shapes_keep_allowed_ids_and_filter_foreign_payloads(): - allowed, denied = node("allowed"), node("denied", content="DENIED_CONTENT") - foreign = node("foreign", user_id="someone-else") - backend = Backend(allowed, denied, foreign) - store = wrapper(backend, approved=(allowed, foreign)) - - async def scenario(): - assert await store.get_by_id("denied") is None - assert [n.node_id for n in await store.get_by_ids(["allowed", "denied"])] == ["allowed"] - assert await store.get_embeddings(["denied", "allowed"]) == {"allowed": [1.0, 0.0]} - assert [n.node_id for n in await store.list_by_user("local")] == ["allowed"] - hits = await store.search([1.0, 0.0], user_id="local", agent_ids=["agent"]) - assert len(hits) == 1 and hits[0]["node_id"] == "allowed" - assert set(hits[0]) == {"node_id", "node", "score"} - hits[0]["node"].content = "caller mutation" - assert (await store.get_by_id("allowed")).content == allowed.content - - asyncio.run(scenario()) - assert backend.nodes["allowed"].content == allowed.content - - -@pytest.mark.parametrize( - "kwargs", - [{"user_id": "other"}, {"agent_ids": ["other"]}, {"isolation_key": "other::agent::session"}], -) -def test_search_cannot_override_the_bound_namespace(kwargs): - backend = Backend() - with pytest.raises(HyMemoryBridgeError, match="scope"): - asyncio.run(wrapper(backend).search([1.0, 0.0], **kwargs)) - assert backend.calls == [] - - -def test_exact_proof_still_requires_approved_evidence_and_known_content_match(): - allowed = node("known") - with pytest.raises(HyMemoryBridgeError, match="provenance"): - HyPermissionVectorStore( - Backend(allowed), - Lease(), - agent_id="agent", - session_id="session", - node_permissions={"known": permission(allowed, ("not-approved",))}, - ) - known = HySupportedMemory( - hy_memory_id="known", - governed_memory_id="governed", - revision=1, - content="Different confirmed content", - owner="user", - evidence_ids=("current",), - ) - store = wrapper(Backend(allowed), lease=Lease((known,)), approved=(allowed,)) - with pytest.raises(HyMemoryBridgeError, match="node_changed"): - asyncio.run(store.get_by_id("known")) - - -@pytest.mark.parametrize( - "field,value", - [ - ("tags", ["changed"]), - ("custom", {"other": "secret"}), - ("embedding", [0.0, 1.0]), - ("supersedes", ["foreign"]), - ], -) -def test_full_fingerprint_detects_changes_not_just_content(field, value): - allowed = node("allowed") - backend = Backend(allowed) - store = wrapper(backend, approved=(allowed,)) - setattr(backend.nodes["allowed"], field, value) - with pytest.raises(HyMemoryBridgeError, match="node_changed"): - asyncio.run(store.get_by_id("allowed")) - - -def test_concurrent_authorized_write_can_refresh_a_stale_full_proof(): - allowed = node("allowed") - backend = Backend(allowed) - refreshed: list[str] = [] - - async def refresh(node_id: str): - refreshed.append(node_id) - return permission(backend.nodes[node_id]) - - store = wrapper(backend, approved=(allowed,), refresh_permission=refresh) - backend.nodes["allowed"].embedding = [0.0, 1.0] - - assert asyncio.run(store.get_by_id("allowed")) is not None - assert refreshed == ["allowed"] - - -def test_refresh_cannot_authorize_a_node_that_was_not_in_the_original_lease(): - allowed = node("allowed") - foreign = node("foreign") - backend = Backend(allowed, foreign) - - async def refresh(_node_id: str): - return permission(foreign, ("other-evidence",)) - - store = wrapper(backend, approved=(allowed,), refresh_permission=refresh) - - assert asyncio.run(store.get_by_id("foreign")) is None - - -@pytest.mark.parametrize( - "field", ["supersedes", "superseded_by", "evidence_chain", "source_raw_memory_id"] -) -def test_full_proof_does_not_expand_permission_along_foreign_links(field): - value = "foreign" if field == "source_raw_memory_id" else ["foreign"] - head = node("head", **{field: value}) - backend = Backend(head, node("foreign", user_id="other")) - store = wrapper(backend, approved=(head,)) - assert asyncio.run(store.get_by_id("head")) is None - assert ("get", "foreign") not in backend.calls - - -def test_approved_cyclic_evolution_chain_uses_native_algorithm_without_broadening(): - old = node("old", is_latest=False, status=MemoryStatus.SUPERSEDED, superseded_by=["head"]) - head = node("head", supersedes=["old"]) - store = wrapper(Backend(old, head), approved=(old, head)) - - async def scenario(): - start = await store.get_by_id("head") - chain = await _trace_full_chain(store, start) - assert {n.node_id for n in chain} == {"old", "head"} - - asyncio.run(scenario()) - - -def test_new_raw_write_is_verified_before_exposure_and_tracks_full_lease_footprint(): - raw = node("new-raw", owner=None, layer=MemoryLayer.L1_RAW, content="[user]: Synthetic input") - backend = Backend() - lease = Lease() - store = wrapper(backend, lease=lease) - - async def scenario(): - assert await store.get_by_id(raw.node_id) is None - assert await store.upsert(raw) == raw.node_id - assert (await store.get_by_id(raw.node_id)).content == raw.content - proof = store.written_nodes[raw.node_id] - assert proof.evidence_ids == tuple(r.evidence_id for r in lease.records) - assert proof.fingerprint == hy_node_fingerprint(backend.nodes[raw.node_id]) - with pytest.raises(TypeError): - store.written_nodes["forged"] = proof - raw.status = MemoryStatus.SHADOW - assert await store.upsert(raw) == raw.node_id - assert (await store.get_by_id(raw.node_id)).status is MemoryStatus.SHADOW - - asyncio.run(scenario()) - - -def test_native_history_read_exposes_only_registered_raw_ids(): - raw = node("raw", layer=MemoryLayer.L1_RAW, owner=None, content="[user]: APPROVED_RAW") - denied = node("denied", layer=MemoryLayer.L1_RAW, owner=None, content="[user]: DENIED_RAW") - backend = Backend(denied) - store = wrapper(backend) - - async def scenario(): - await store.upsert(raw) - history = await MemoryWriter._get_recent_history(None, store, "local", "agent") - assert "APPROVED_RAW" in history and "DENIED_RAW" not in history - - asyncio.run(scenario()) - - -def test_native_chroma_nullable_chain_roundtrip_preserves_exact_readback_proof(): - class SerializedBackend(Backend): - async def upsert(self, value): - result = await super().upsert(value) - payload = ChromaVectorStore._node_to_payload(value) - metadata = ChromaVectorStore._payload_to_chroma_metadata(payload) - restored = ChromaVectorStore._chroma_metadata_to_payload(metadata) - stored = ChromaVectorStore._payload_to_node(restored) - stored.embedding = deepcopy(value.embedding) - self.nodes[result] = stored - return result - - backend = SerializedBackend() - store = wrapper(backend) - original = node("chroma-roundtrip", owner="agent") - - async def scenario(): - assert await store.upsert(original) == original.node_id - actual = await store.get_by_id(original.node_id) - assert actual.supersedes == actual.superseded_by == "" - assert original.supersedes is original.superseded_by is None - assert actual.content == original.content and actual.owner == "agent" - assert hy_node_fingerprint(actual) != hy_node_fingerprint(original) - assert store.written_nodes[original.node_id].fingerprint == hy_node_fingerprint(actual) - - asyncio.run(scenario()) - - -def test_real_cosine_chroma_accepts_dense_vector_roundtrip_without_loosening_node_proof(tmp_path): - from one_memory.hy_memory_bridge import build_hy_memory_config - from tests.test_memory_language import _profile - - config = build_hy_memory_config( - _profile().model_copy(update={"embedding_dimensions": 512}), - api_key=None, - data_dir=tmp_path, - ) - native = ChromaVectorStore(config) - values = [math.sin(index + 1) / (index + 1) for index in range(512)] - norm = math.sqrt(sum(value * value for value in values)) - original = node("dense-roundtrip", owner="user", embedding=[value / norm for value in values]) - - async def scenario(): - await native.initialize() - store = wrapper(native) - try: - assert await store.upsert(original) == original.node_id - actual = await store.get_by_id(original.node_id) - assert actual.content == original.content and actual.owner == original.owner - assert store.written_nodes[actual.node_id].fingerprint == hy_node_fingerprint(actual) - changed = deepcopy(actual) - changed.embedding[20] += 0.001 - await native.upsert(changed) - with pytest.raises(HyMemoryBridgeError, match="hy_store"): - await store.get_by_id(original.node_id) - finally: - await store.close() - await native.close() - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("mutation", ["upsert", "update_payload", "update_embedding", "delete"]) -def test_guessed_existing_id_is_never_authorized_as_a_new_write(mutation): - foreign = node("guess", content="DENIED_EXISTING") - backend = Backend(foreign) - store = wrapper(backend) - - async def scenario(): - with pytest.raises(HyMemoryBridgeError, match="target_denied"): - if mutation == "upsert": - await store.upsert(node("guess", content="replacement")) - elif mutation == "update_payload": - await store.update_payload("guess", {"content": "replacement"}) - elif mutation == "update_embedding": - await store.update_embedding("guess", [0.0, 1.0]) - else: - await store.delete("guess") - - asyncio.run(scenario()) - assert backend.nodes["guess"].content == foreign.content - assert all(call[0] == "get" for call in backend.calls) - - -@pytest.mark.parametrize( - "changes", - [ - {"user_id": "other"}, - {"agent_id": "other"}, - {"session_id": "other"}, - {"layer": MemoryLayer.L6_SCHEMA}, - {"supersedes": ["guessed"]}, - ], -) -def test_new_node_cannot_claim_other_scope_or_unapproved_chain(changes): - backend = Backend() - with pytest.raises(HyMemoryBridgeError): - asyncio.run(wrapper(backend).upsert(node("new", **changes))) - assert backend.nodes == {} - - -def test_batch_checks_all_targets_before_any_native_write(): - denied = node("existing") - backend = Backend(denied) - with pytest.raises(HyMemoryBridgeError, match="target_denied"): - asyncio.run(wrapper(backend).upsert_batch([node("new"), node("existing")])) - assert set(backend.nodes) == {"existing"} - - -def test_approved_native_update_preserves_opaque_metadata_and_refreshes_fingerprint(): - allowed = node("allowed", custom={"opaque": {"source": "approved"}}, tags=["before"]) - backend = Backend(allowed) - store = wrapper(backend, approved=(allowed,)) - - async def scenario(): - assert await store.update_payload("allowed", {"content": "revised", "tags": ["after"]}) - assert await store.update_embedding("allowed", [0.0, 1.0]) - result = await store.get_by_id("allowed") - assert result.content == "revised" and result.tags == ["after"] - assert result.custom == allowed.custom and result.embedding == [0.0, 1.0] - assert store.written_nodes["allowed"].fingerprint == hy_node_fingerprint(result) - - asyncio.run(scenario()) - - -def test_update_cannot_retarget_identity_or_add_denied_chain(): - for updates in ( - {"user_id": "other"}, - {"node_id": "other"}, - {"supersedes": ["outside"]}, - # `custom` remains an allowed field name for SDK 2.0.2; the field it - # must not smuggle through is the digest-only System 2 counter. - {"custom": {"s2_evidence_count": 1}}, - {"tag_ners": "not-a-list"}, - ): - allowed = node("allowed") - backend = Backend(allowed) - with pytest.raises(HyMemoryBridgeError): - asyncio.run(wrapper(backend, approved=(allowed,)).update_payload("allowed", updates)) - assert backend.nodes["allowed"].to_dict() == allowed.to_dict() - - -def test_native_dedup_can_delete_approved_target_but_not_guessed_cascade(): - async def scenario(approved): - keep, remove = node("keep"), node("remove") - backend = Backend(keep, remove) - store = wrapper(backend, approved=(keep, remove) if approved else (keep,)) - await execute_dedup( - [ - DedupItem("keep", [1.0, 0.0], gmt_created=1), - DedupItem("remove", [1.0, 0.0], gmt_created=2), - ], - vector_store=store, - trigger="reconcile", - threshold=0.95, - ) - if approved: - assert backend.nodes["remove"].status is MemoryStatus.SHADOW - assert backend.nodes["remove"].is_latest is False - assert backend.nodes["remove"].custom["hy_dedup"] == { - "reason": "dedup:reconcile", - "kept_id": "keep", - } - assert any(call[:2] == ("update", "remove") for call in backend.calls) - else: - assert "remove" in backend.nodes - with pytest.raises(HyMemoryBridgeError, match="target_denied"): - store.recheck() # Native execute_dedup swallowed the failure. - - asyncio.run(scenario(True)) - asyncio.run(scenario(False)) - - -def test_native_dedup_shadows_head_without_destroying_its_history(): - keep = node("keep") - head = node("remove-head", supersedes=["old"]) - old = node("old", superseded_by=[head.node_id], is_latest=False) - backend = Backend(keep, head, old) - store = wrapper(backend, approved=(keep, head, old)) - - async def scenario(): - await execute_dedup( - [ - DedupItem("keep", [1.0, 0.0], gmt_created=1, is_chain_head=True), - DedupItem( - head.node_id, - [1.0, 0.0], - gmt_created=2, - is_chain_head=True, - chain_node_ids=[head.node_id, "old"], - ), - ], - vector_store=store, - trigger="reconcile", - threshold=0.95, - ) - store.recheck() - assert set(backend.nodes) == {"keep", "remove-head", "old"} - assert backend.nodes["remove-head"].status is MemoryStatus.SHADOW - assert backend.nodes["remove-head"].is_latest is False - assert backend.nodes["old"].status is MemoryStatus.SHADOW - assert backend.nodes["old"].is_latest is False - - asyncio.run(scenario()) - - -def test_forged_dedup_cascade_never_deletes_a_foreign_member(): - keep, head, foreign = node("keep"), node("head"), node("foreign", user_id="other") - backend = Backend(keep, head, foreign) - store = wrapper(backend, approved=(keep, head)) - - async def scenario(): - await execute_dedup( - [ - DedupItem("keep", [1.0, 0.0], gmt_created=1, is_chain_head=True), - DedupItem( - "head", - [1.0, 0.0], - gmt_created=2, - is_chain_head=True, - chain_node_ids=["head", "foreign"], - ), - ], - vector_store=store, - trigger="reconcile", - threshold=0.95, - ) - assert "foreign" in backend.nodes - with pytest.raises(HyMemoryBridgeError, match="target_denied"): - store.recheck() - - asyncio.run(scenario()) - - -def test_basic_profile_native_write_and_supersede_stay_on_same_backend(): - old = node( - "old-profile", - owner=None, - layer=MemoryLayer.L0_BASIC_INFO, - content="name: Before", - custom={"basic_info_kv": {"name": "Before"}}, - ) - backend = Backend(old) - store = wrapper(backend, approved=(old,)) - - class Embedder: - async def embed_queued(self, text): - return [1.0, 0.0] - - async def scenario(): - result = await upsert_basic_profile( - user_id="local", - kv={"name": "After"}, - vector_store=store, - embed_service=Embedder(), - allowed_fields=["name"], - ) - assert result.success and result.changed - assert set(backend.nodes) == {"old-profile", result.node_id} - assert (await store.get_by_id(result.node_id)).supersedes == ["old-profile"] - assert backend.nodes["old-profile"].superseded_by == [result.node_id] - - asyncio.run(scenario()) - - -def test_open05_can_migrate_an_approved_legacy_agent_profile_to_user_level(): - old = node( - "old-profile", - agent_id="legacy-agent", - owner=None, - layer=MemoryLayer.L0_BASIC_INFO, - content="name: Before", - custom={"basic_info_kv": {"name": "Before"}}, - ) - backend = Backend(old) - store = wrapper(backend, approved=(old,)) - - class Embedder: - async def embed_queued(self, text): - return [1.0, 0.0] - - async def scenario(): - result = await upsert_basic_profile( - user_id="local", - kv={"name": "After"}, - vector_store=store, - embed_service=Embedder(), - allowed_fields=["name"], - ) - assert result.success and result.changed - migrated = await store.get_by_id(result.node_id) - assert migrated is not None - assert migrated.agent_id == "" and migrated.session_id == "" - assert migrated.supersedes == ["old-profile"] - - asyncio.run(scenario()) - - -def test_revocation_during_read_cannot_release_payload_or_perform_write(): - for mutation in (False, True): - allowed = node("allowed") - backend, lease = Backend(allowed), Lease() - store = wrapper(backend, lease=lease, approved=(allowed,)) - backend.after_read = lambda lease=lease: setattr(lease, "revoked", True) - with pytest.raises(HyMemoryBridgeError, match="permission_revoked"): - asyncio.run( - store.update_payload("allowed", {"content": "changed"}) - if mutation - else store.get_by_id("allowed") - ) - assert backend.nodes["allowed"].content == allowed.content - - -def test_multi_node_read_rechecks_permission_at_public_boundary_not_per_node(): - nodes = tuple(node(f"allowed-{index}") for index in range(8)) - backend, lease = Backend(*nodes), Lease() - store = wrapper(backend, lease=lease, approved=nodes) - checks_after_construction = lease.checks - - result = asyncio.run(store.list_by_user("local")) - - assert {item.node_id for item in result} == {item.node_id for item in nodes} - assert lease.checks - checks_after_construction == 2 - - -def test_corrupt_native_write_is_not_registered_or_exposed(): - backend = Backend() - backend.corrupt_write = lambda n: setattr(n, "custom", {"unknown": "DENIED"}) - store = wrapper(backend) - with pytest.raises(HyMemoryBridgeError, match="write_mismatch"): - asyncio.run(store.upsert(node("new"))) - with pytest.raises(HyMemoryBridgeError, match="write_mismatch"): - asyncio.run(store.get_by_id("new")) - - -def test_successful_batch_and_deleted_ids_cannot_be_recreated_under_new_permission(): - backend = Backend() - store = wrapper(backend) - - async def scenario(): - assert await store.upsert_batch([node("first"), node("second")]) == ["first", "second"] - assert set(store.written_nodes) == {"first", "second"} - await store.delete("first") - with pytest.raises(HyMemoryBridgeError): - await store.upsert(node("first")) - assert set(backend.nodes) == {"second"} - - asyncio.run(scenario()) - - -def test_invalid_node_fingerprint_latches_even_if_native_caller_swallows_exception(): - backend = Backend() - store = wrapper(backend) - invalid = node("invalid", custom={"unsupported_object": object()}) - with pytest.raises(HyMemoryBridgeError, match="node_shape"): - asyncio.run(store.upsert(invalid)) - with pytest.raises(HyMemoryBridgeError, match="node_shape"): - store.recheck() - assert backend.nodes == {} - - -@pytest.mark.parametrize( - ("expected_tags", "stored_tags"), - [([], "[]"), (["release", "permission"], '["release", "permission"]')], -) -def test_public_sdk_tag_ners_chroma_roundtrip_is_allowed(expected_tags, stored_tags): - expected = node("new") - expected.tag_ners = expected_tags - backend = Backend() - backend.corrupt_write = lambda n: setattr(n, "tag_ners", stored_tags) - store = wrapper(backend) - - assert asyncio.run(store.upsert(expected)) == "new" - assert set(store.written_nodes) == {"new"} - - -def test_malformed_tag_ners_remains_a_write_mismatch(): - expected = node("new") - expected.tag_ners = ["release"] - backend = Backend() - backend.corrupt_write = lambda n: setattr(n, "tag_ners", "not-json") - store = wrapper(backend) - - with pytest.raises(HyMemoryBridgeError, match="write_mismatch"): - asyncio.run(store.upsert(expected)) - - -def test_public_sdk_list_options_stay_inside_the_leased_user_and_agent(): - allowed = node("allowed") - backend = Backend(allowed) - store = wrapper(backend, approved=(allowed,)) - - rows = asyncio.run( - store.list_by_user( - user_id="local", - agent_id="agent", - user_ids=["local"], - agent_ids=["agent"], - only_latest=True, - with_vectors=False, - allow_raw_layer=True, - ) - ) - - assert [value.node_id for value in rows] == ["allowed"] - assert next(call for call in backend.calls if call[0] == "list") == ( - "list", - { - "user_id": "local", - "agent_id": "agent", - "limit": 10000, - "status_filter": None, - "layers": None, - "user_ids": ["local"], - "agent_ids": ["agent"], - "only_latest": True, - "with_vectors": False, - "allow_raw_layer": True, - }, - ) - - with pytest.raises(HyMemoryBridgeError, match="scope"): - asyncio.run(store.list_by_user(user_ids=["other-user"])) - - -def test_two_wrappers_never_share_new_node_permissions(): - backend = Backend() - first, second = wrapper(backend), wrapper(backend) - - async def scenario(): - await first.upsert(node("first-only")) - assert await second.get_by_id("first-only") is None - assert (await first.get_by_id("first-only")).node_id == "first-only" - - asyncio.run(scenario()) - - -@pytest.mark.parametrize( - "action", - [ - "id", - "ids", - "embeddings", - "list", - "search", - "upsert", - "batch", - "payload", - "embedding", - "delete", - "initialize", - ], -) -def test_revocation_is_checked_before_every_native_action_even_empty_reads(action): - approved = node("approved") - backend, lease = Backend(approved), Lease() - store = wrapper(backend, lease=lease, approved=(approved,)) - lease.revoked = True - - async def scenario(): - calls = { - "id": lambda: store.get_by_id("approved"), - "ids": lambda: store.get_by_ids([]), - "embeddings": lambda: store.get_embeddings([]), - "list": lambda: store.list_by_user("local"), - "search": lambda: store.search([1.0, 0.0]), - "upsert": lambda: store.upsert(node("new")), - "batch": lambda: store.upsert_batch([]), - "payload": lambda: store.update_payload("approved", {}), - "embedding": lambda: store.update_embedding("approved", [0.0, 1.0]), - "delete": lambda: store.delete("approved"), - "initialize": store.initialize, - } - with pytest.raises(HyMemoryBridgeError, match="permission_revoked"): - await calls[action]() - lease.revoked = False - with pytest.raises(HyMemoryBridgeError, match="permission_revoked"): - store.recheck() - - asyncio.run(scenario()) - assert backend.calls == [] - - -def test_identical_text_and_vector_never_authorize_a_different_native_id(): - allowed = node("allowed") - denied = node("denied", content=allowed.content, embedding=allowed.embedding) - backend = Backend(allowed, denied) - store = wrapper(backend, approved=(allowed,)) - - async def scenario(): - assert [hit["node_id"] for hit in await store.search([1.0, 0.0])] == ["allowed"] - assert await store.get_by_id("denied") is None - - asyncio.run(scenario()) - - -def test_invalid_embedding_is_rejected_before_native_mutation(): - for mutation in ("payload", "embedding"): - approved = node("approved") - backend = Backend(approved) - store = wrapper(backend, approved=(approved,)) - with pytest.raises(HyMemoryBridgeError, match="node_shape"): - asyncio.run( - store.update_payload("approved", {"embedding": [1e200]}) - if mutation == "payload" - else store.update_embedding("approved", [1e200]) - ) - assert not any(call[0] in {"update", "embedding"} for call in backend.calls) - - -def test_distinct_permission_wrappers_serialize_native_store_mutations(): - class ContendedBackend(Backend): - def __init__(self): - super().__init__() - self.active = 0 - self.peak = 0 - - async def upsert(self, value): - self.active += 1 - self.peak = max(self.peak, self.active) - try: - await asyncio.sleep(0.02) - return await super().upsert(value) - finally: - self.active -= 1 - - async def scenario(): - backend = ContendedBackend() - first = wrapper(backend) - second = wrapper(backend) - await asyncio.gather(first.upsert(node("first")), second.upsert(node("second"))) - assert backend.peak == 1 - assert set(backend.nodes) == {"first", "second"} - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("initial", ["new", "preexisting"]) -@pytest.mark.parametrize("mutation", ["upsert", "upsert_batch", "payload", "embedding"]) -def test_latest_write_proof_replaces_prior_snapshot_for_every_mutation(initial, mutation): - original = node("changing") - backend = Backend(original) if initial == "preexisting" else Backend() - store = wrapper(backend, approved=(original,) if initial == "preexisting" else ()) - - async def scenario(): - if initial == "new": - await store.upsert(original) - else: - await store.update_payload(original.node_id, {"tags": ["first-update"]}) - before = store.written_nodes - prior_fingerprint = before[original.node_id].fingerprint - changed = await store.get_by_id(original.node_id) - changed.content = "Synthetic revised content" - if mutation == "upsert": - await store.upsert(changed) - elif mutation == "upsert_batch": - await store.upsert_batch([changed]) - elif mutation == "payload": - await store.update_payload(original.node_id, {"content": changed.content}) - else: - await store.update_embedding(original.node_id, [0.0, 1.0]) - - latest = store.written_nodes[original.node_id] - assert latest.fingerprint != prior_fingerprint - assert latest.fingerprint == hy_node_fingerprint(backend.nodes[original.node_id]) - assert latest.evidence_ids == ("current", "support") - assert before[original.node_id].fingerprint == prior_fingerprint - assert hy_node_fingerprint(await store.get_by_id(original.node_id)) == latest.fingerprint - - asyncio.run(scenario()) - - -def test_embedding_write_accepts_native_modified_time_and_refreshes_full_proof(): - original = node("modified-at") - backend = Backend(original) - - async def update_embedding(key, embedding): - backend.nodes[key].embedding = deepcopy(embedding) - backend.nodes[key].gmt_modified += timedelta(seconds=1) - return True - - backend.update_embedding = update_embedding - store = wrapper(backend, approved=(original,)) - - async def scenario(): - await store.update_embedding(original.node_id, [0.0, 1.0]) - assert store.written_nodes[original.node_id].fingerprint == hy_node_fingerprint( - backend.nodes[original.node_id] - ) - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("initial", ["new", "preexisting"]) -def test_deletion_delta_removes_persisted_proof_without_replacing_unmodified_rows(initial): - removed, untouched = node("removed"), node("untouched") - backend = Backend(removed, untouched) if initial == "preexisting" else Backend(untouched) - supplied = {untouched.node_id: permission(untouched)} - if initial == "preexisting": - supplied[removed.node_id] = permission(removed) - store = HyPermissionVectorStore( - backend, - Lease(), - agent_id="agent", - session_id="session", - node_permissions=supplied, - ) - - async def scenario(): - if initial == "new": - await store.upsert(removed) - previous_deleted = store.deleted_node_ids - ledger = {**supplied, **store.written_nodes} - await store.delete(removed.node_id) - assert store.deleted_node_ids == frozenset({removed.node_id}) - assert previous_deleted == frozenset() - assert removed.node_id not in store.written_nodes - assert await store.get_by_id(removed.node_id) is None - - ledger.update(store.written_nodes) - for key in store.deleted_node_ids: - ledger.pop(key, None) - assert set(ledger) == {untouched.node_id} - await store.close() - reopened = HyPermissionVectorStore( - backend, - Lease(), - agent_id="agent", - session_id="session", - node_permissions=ledger, - ) - assert (await reopened.get_by_id(untouched.node_id)).node_id == untouched.node_id - assert await reopened.get_by_id(removed.node_id) is None - await reopened.close() - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("snapshot", ["written_nodes", "deleted_node_ids"]) -def test_proof_snapshots_are_permission_checked_before_export(snapshot): - lease = Lease() - store = wrapper(Backend(), lease=lease) - lease.revoked = True - with pytest.raises(HyMemoryBridgeError, match="permission_revoked"): - getattr(store, snapshot) - - -def test_close_after_parent_commit_never_rechecks_invalidated_context_or_backend(): - class CommitSensitiveLease(Lease): - committed = False - - def recheck(self): - if self.committed: - self.checks += 1 - raise HyMemoryBridgeError("model_content_context_changed") - return super().recheck() - - backend, lease = Backend(), CommitSensitiveLease() - store = wrapper(backend, lease=lease) - - async def scenario(): - await store.upsert(node("new")) - snapshot = store.written_nodes - deleted = store.deleted_node_ids - lease.committed = True - checks_before_close, calls_before_close = lease.checks, list(backend.calls) - await store.close() - await store.close() - assert lease.checks == checks_before_close - assert backend.calls == calls_before_close - assert set(snapshot) == {"new"} and not deleted - with pytest.raises(HyMemoryBridgeError, match="closed"): - await store.get_by_id("new") - assert lease.checks == checks_before_close - - asyncio.run(scenario()) - - -def test_wrapper_api_is_closed_and_close_does_not_close_or_modify_native_store(): - backend, lease = Backend(node("kept")), Lease() - store = wrapper(backend, lease=lease) - for attribute in ( - "_collection", - "_client", - "native", - "backend", - "_run", - "get_node", - "upsert_entity", - "search_entities", - "list_entities", - "sweep", - "digest", - "delete_by_metadata", - "delete_by_isolation_key", - ): - assert not hasattr(store, attribute), attribute - - async def scenario(): - await store.initialize() - with pytest.raises(NotImplementedError): - await store.has_tag_embedding("local", "tag") - await store.close() - with pytest.raises(HyMemoryBridgeError, match="closed"): - await store.get_by_id("kept") - - asyncio.run(scenario()) - assert set(backend.nodes) == {"kept"} and backend.calls == [] - - -def test_allowing_owner_and_custom_does_not_reach_another_users_node(): - """The pinned public SDK's reconcile UPDATE carries `owner` / `custom`. - - Allowing those two field names is only safe while the store still resolves - the target through the lease's own permissions and user scope. A node that - belongs to a different user_id must remain unreachable, so the update is - refused as a denied target rather than being applied. - """ - foreign = node("foreign", user_id="someone-else") - backend = Backend(foreign) - store = wrapper(backend, approved=(foreign,)) - - async def scenario(): - assert await store.get_by_id(foreign.node_id) is None - with pytest.raises(HyMemoryBridgeError, match="target_denied"): - await store.update_payload(foreign.node_id, {"owner": "agent", "custom": {}}) - with pytest.raises(HyMemoryBridgeError, match="target_denied"): - await store.update_payload(foreign.node_id, {"custom": {"note": "DENIED"}}) - - asyncio.run(scenario()) - assert backend.nodes[foreign.node_id] == foreign - assert not any(call[0] == "update" for call in backend.calls) - - -def test_owner_and_custom_updates_are_allowed_for_an_approved_own_node(): - """The two public SDK fields apply when the node is this lease's own.""" - owned = node("owned", custom={}) - backend = Backend(owned) - store = wrapper(backend, approved=(owned,)) - - async def scenario(): - assert await store.update_payload(owned.node_id, {"owner": "agent", "custom": {"n": 1}}) - - asyncio.run(scenario()) - assert backend.nodes[owned.node_id].owner == "agent" - assert backend.nodes[owned.node_id].custom == {"n": 1} diff --git a/memhub/apps/core/tests/test_hy_permission_transport.py b/memhub/apps/core/tests/test_hy_permission_transport.py deleted file mode 100644 index 1af0168..0000000 --- a/memhub/apps/core/tests/test_hy_permission_transport.py +++ /dev/null @@ -1,1039 +0,0 @@ -"""Native HY entry points, synthetic inputs and MockTransport only.""" - -import asyncio -import copy -import inspect -import json -import logging -import threading -from dataclasses import dataclass -from datetime import UTC, datetime -from types import SimpleNamespace - -import httpx -import pytest -from hy_memory.agent.llm_provider import LLMProvider, LLMResponse, LLMRetryError -from hy_memory.config import MemoryConfig -from hy_memory.core.embed_service import EmbedService -from one_memory.model_content_permissions import ( - ModelContentPermissionReceipt, - ModelContentPermissionSnapshot, - _recipients, -) - -from one_memory.hy_permission_transport import HyPermissionTransport, HyPermissionTransportError -from one_memory.hy_response_guard import HyResponseGuard -from one_memory.hy_runtime_config import HY_MEMORY_DISTRIBUTION, HY_MEMORY_VERSION -from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind, attach_disable_thinking - - -@dataclass -class Lease: - receipt: ModelContentPermissionReceipt - profile: ModelProfileSnapshot - checks: int = 0 - revoked: bool = False - context_changed: bool = False - - def recheck(self): - self.checks += 1 - if self.revoked or self.context_changed: - raise RuntimeError("private permission error must not escape") - return self.receipt - - -def lease(*, local=False, thinking=False): - from one_memory.embedding_assets import BUILTIN_EMBEDDING_DIMENSIONS, BUILTIN_EMBEDDING_MODEL - - profile = ModelProfileSnapshot( - profile_id="synthetic-profile", - revision=1, - provider_kind=ProviderKind.OPENAI_COMPATIBLE, - base_url="https://models.example.test/approved/v1", - model="synthetic-generation", - embedding_provider_kind=ProviderKind.LOCAL_ONNX - if local - else ProviderKind.OPENAI_COMPATIBLE, - embedding_base_url=None if local else "https://models.example.test/embedding/v1", - embedding_model=BUILTIN_EMBEDDING_MODEL if local else "synthetic-embedding", - embedding_dimensions=BUILTIN_EMBEDDING_DIMENSIONS if local else 2, - timeout_seconds=5, - max_concurrency=1, - context_window=None, - has_api_key=True, - disable_thinking=thinking, - ) - generation, embedding = _recipients(profile) - now = datetime.now(UTC) - snapshot = ModelContentPermissionSnapshot( - user_id="synthetic-user", - profile=profile, - generation=generation, - embedding=embedding, - sources=[ - dict( - source_id="one-memory-manual", - authority="manual", - granted_at=now, - revision=1, - root_identity=None, - root_path="", - requested_path=None, - candidate_kind="default", - ) - ], - evidence=[ - dict( - evidence_id="synthetic-evidence", - source_id="one-memory-manual", - revision=1, - content_hash="a" * 64, - identity_hash="b" * 64, - origin_hash="c" * 64, - imported_at=now, - ) - ], - ) - return Lease( - ModelContentPermissionReceipt( - user_id=snapshot.user_id, - revision=1, - revocation_revision=0, - granted_at=now, - revoked_at=None, - snapshot=snapshot, - ), - profile, - ) - - -def native(permission): - config = MemoryConfig() - config.llm.provider = "openai" - config.llm.base_url = permission.receipt.snapshot.generation.endpoint - config.llm.model = permission.profile.model - config.llm.api_key = "synthetic-secret-never-log" - config.llm.max_retries = 2 - config.llm.retry_delay = 0 - config.llm.extra_headers = None - config.llm.extra_body = attach_disable_thinking(permission.profile, {}) or None - config.embedder.provider = "openai" - config.embedder.base_url = ( - permission.receipt.snapshot.embedding.endpoint or "http://127.0.0.1/local-onnx" - ) - config.embedder.model = permission.profile.embedding_model - config.embedder.api_key = "synthetic-secret-never-log" - config.embedder.embedding_dims = permission.profile.embedding_dimensions - config.embedder.max_retries = 2 - config.embedder.retry_delay = 0 - config.embedder.extra_headers = None - config.embedder.extra_body = None - provider, embedder = LLMProvider(config), EmbedService(config) - embedder._embed_config.queue_batch_window_ms = 1 - return provider, embedder - - -def response(request): - if request.url.path.endswith("/embeddings"): - inputs = json.loads(request.content)["input"] - return httpx.Response( - 200, - json={ - "object": "list", - "model": "synthetic-embedding", - "data": [ - {"object": "embedding", "index": i, "embedding": [0.25, 0.75]} - for i, _ in enumerate(inputs) - ], - "usage": {"prompt_tokens": 3, "total_tokens": 3}, - }, - headers={"x-private-header": "private-response-metadata"}, - ) - return httpx.Response( - 200, - json={ - "id": "synthetic-completion", - "object": "chat.completion", - "created": 0, - "model": "synthetic-generation", - "choices": [ - { - "index": 0, - "finish_reason": "tool_calls", - "message": { - "role": "assistant", - "content": "native result", - "tool_calls": [ - { - "id": "call-1", - "type": "function", - "function": { - "name": "record", - "arguments": '{"value":"unaltered"}', - }, - } - ], - }, - } - ], - "usage": {"prompt_tokens": 7, "completion_tokens": 4, "total_tokens": 11}, - }, - ) - - -class Wire(httpx.MockTransport): - def __init__(self, handler=None): - self.requests = [] - self.closed = 0 - self.callback = handler - super().__init__(self.send) - - async def send(self, request): - self.requests.append(request) - return self.callback(request, len(self.requests)) if self.callback else response(request) - - async def aclose(self): - self.closed += 1 - await super().aclose() - - -def test_native_entry_points_preserve_payload_response_and_restore_instances(): - async def run(): - permission = lease(thinking=True) - provider, embedder = native(permission) - original = dict(provider.__dict__) - complete_signature = inspect.signature(provider.complete) - wire = Wire() - messages = [ - {"role": "system", "content": "exact system"}, - {"role": "user", "content": "exact user"}, - ] - before = copy.deepcopy(messages) - tools = [{"type": "function", "function": {"name": "record", "parameters": {}}}] - async with HyPermissionTransport(permission, http_transport=wire) as guard: - assert guard.bind_provider(provider) is provider - assert inspect.signature(provider.complete) == complete_signature - guard.bind_embed_service(embedder) - saved_complete = provider.complete - result = await provider.complete("exact native prompt\n spaces", stop=["STOP"]) - assert isinstance(result, LLMResponse) - assert ( - result.content, - result.tokens_used, - result.prompt_tokens, - result.completion_tokens, - result.finish_reason, - ) == ("native result", 11, 7, 4, "tool_calls") - assert result.tool_calls[0]["function"]["arguments"] == '{"value":"unaltered"}' - await provider.complete_messages(messages, tools=tools, tool_choice="auto") - # Native dispatch itself also converges on the guarded leaf. - await LLMProvider._call_llm(provider, "direct native dispatch", 12, 0) - assert await embedder.embed("synthetic text", use_cache=False) == [0.25, 0.75] - vector, meta = await embedder.embed_with_meta("another", use_cache=False) - assert vector == [0.25, 0.75] - assert set(meta) <= {"http_status", "model", "usage", "attempts", "cache_hit"} - assert "private-response-metadata" not in repr(meta) - assert permission.checks >= len(wire.requests) - assert wire.closed == 1 - for name in ("complete", "complete_messages", "_call_llm", "_chat_completions"): - assert provider.__dict__.get(name) is original.get(name) - with pytest.raises(HyPermissionTransportError, match="ended"): - await saved_complete("must not send") - assert len(wire.requests) == 5 - bodies = [json.loads(request.content) for request in wire.requests] - assert bodies[0]["messages"] == [ - {"role": "system", "content": ""}, - {"role": "user", "content": "exact native prompt\n spaces"}, - ] - assert bodies[0]["stop"] == ["STOP"] - assert bodies[0]["reasoning_effort"] == "none" - assert bodies[1]["messages"] == before == messages - assert bodies[1]["tools"] == tools and bodies[1]["tool_choice"] == "auto" - assert str(wire.requests[3].url).endswith("/embedding/v1/embeddings") - assert bodies[3]["dimensions"] == 2 - - asyncio.run(run()) - - -def test_permission_transport_pins_the_public_sdk_identity(): - """The transport fails closed unless the audited SDK identity is installed.""" - assert HY_MEMORY_DISTRIBUTION == "hy-memory" - assert HY_MEMORY_VERSION == "2.0.2" - - -@pytest.mark.parametrize("entry", ["complete", "complete_messages", "embed"]) -@pytest.mark.parametrize("revoked", [False, True]) -def test_retry_rechecks_each_send_and_latches_across_fresh_child_providers(entry, revoked): - async def run(): - permission = lease() - provider, embedder = native(permission) - - def handler(request, count): - if count == 1: - permission.revoked = revoked - if entry == "embed": - raise httpx.ReadTimeout("private timeout details", request=request) - return httpx.Response(500, json={"error": {"message": "private failure"}}) - return response(request) - - wire = Wire(handler) - guard = HyPermissionTransport(permission, http_transport=wire) - try: - async with guard: - guard.bind_provider(provider) - guard.bind_embed_service(embedder) - call = ( - provider.complete("prompt") - if entry == "complete" - else provider.complete_messages([{"role": "user", "content": "prompt"}]) - if entry == "complete_messages" - else embedder.embed("text") - ) - if not revoked: - await call - assert len(wire.requests) == 2 - else: - with pytest.raises(HyPermissionTransportError): - await call - checks = permission.checks - permission.revoked = False - child, _ = native(permission) - with pytest.raises(HyPermissionTransportError): - guard.bind_provider(child) - with pytest.raises(HyPermissionTransportError): - await provider._call_llm("child", 10, 0) - assert permission.checks == checks - assert len(wire.requests) == 1 - except HyPermissionTransportError: - assert revoked # Exit must also refuse a swallowed denial. - assert wire.closed == 1 - - asyncio.run(run()) - - -@pytest.mark.parametrize( - "entry,expected", [("complete", 2), ("complete_messages", 2), ("embed", 3)] -) -def test_native_outer_attempt_budgets_no_openai_hidden_retries(entry, expected, caplog): - """0.2.0 keeps retry ownership outside OpenAI's client: our leaf sends each attempt. - - The expected counts are intentionally unchanged from the old SDK pin: 0.2.0 - must not hide retries below this transport, or D84's per-send permission and - Cookie/header checks would be bypassed. - """ - - async def run(): - permission = lease() - provider, embedder = native(permission) - - def fail(request, _): - raise httpx.ConnectError("private upstream body and secret", request=request) - - wire = Wire(fail) - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_provider(provider) - guard.bind_embed_service(embedder) - with pytest.raises((LLMRetryError, HyPermissionTransportError)) as error: - if entry == "embed": - await embedder.embed("private synthetic input") - elif entry == "complete": - await provider.complete("private synthetic input") - else: - await provider.complete_messages([{"role": "user", "content": "input"}]) - assert "private" not in str(error.value) - assert len(wire.requests) == expected - if entry != "embed": - assert len(error.value.attempts) == expected - assert provider._errors == expected - assert "private" not in caplog.text - - asyncio.run(run()) - - -@pytest.mark.parametrize("entry", ["complete", "complete_messages"]) -@pytest.mark.parametrize( - "failure,kind", - [ - (httpx.ReadTimeout, "timeout"), - (httpx.ConnectTimeout, "timeout"), - (httpx.WriteTimeout, "timeout"), - (httpx.PoolTimeout, "timeout"), - (httpx.ConnectError, "connection"), - ], -) -def test_generation_failure_category_survives_sanitized_native_attempts( - entry, failure, kind, caplog -): - """0.2.0 still exposes sanitized attempt records with our five failure kinds. - - Timeout and connection categories must stay distinguishable after the SDK - upgrade; otherwise real provider failures collapse into an unknown retry path. - """ - - async def run(): - permission = lease() - provider, _ = native(permission) - - def fail(request, _count): - raise failure("private upstream body secret must not escape", request=request) - - wire = Wire(fail) - async with HyPermissionTransport(permission, http_transport=wire) as transport: - transport.bind_provider(provider) - guard = HyResponseGuard(provider) - with pytest.raises(LLMRetryError) as raised: - if entry == "complete": - await guard.complete("private input") - else: - await guard.complete_messages([{"role": "user", "content": "private input"}]) - assert len(wire.requests) == 2 - assert len(raised.value.attempts) == 2 - assert all(item["failure_kind"] == kind for item in raised.value.attempts) - assert all("status_code" not in item for item in raised.value.attempts) - expected = ( - "hy_memory_model_timeout" if kind == "timeout" else "hy_memory_model_request_failed" - ) - assert guard.failures == [expected] - assert "private" not in json.dumps(raised.value.attempts) - assert "private" not in caplog.text - assert "synthetic-secret-never-log" not in caplog.text - assert wire.closed == 1 - - asyncio.run(run()) - - -@pytest.mark.parametrize("entry", ["complete", "complete_messages"]) -def test_malformed_generation_response_is_not_classified_as_timeout(entry): - async def run(): - permission = lease() - provider, _ = native(permission) - wire = Wire(lambda _request, _count: httpx.Response(200, json={"choices": []})) - async with HyPermissionTransport(permission, http_transport=wire) as transport: - transport.bind_provider(provider) - guard = HyResponseGuard(provider) - with pytest.raises(LLMRetryError) as raised: - if entry == "complete": - await guard.complete("synthetic input") - else: - await guard.complete_messages([{"role": "user", "content": "synthetic input"}]) - assert len(wire.requests) == 2 - assert all(item["failure_kind"] == "invalid_response" for item in raised.value.attempts) - assert guard.failures == ["hy_memory_model_request_failed"] - - asyncio.run(run()) - - -@pytest.mark.parametrize( - "change", - ["host", "path", "query", "model", "backend", "dimensions", "headers", "body", "key", "kwargs"], -) -def test_config_drift_or_overrides_deny_before_send(change): - async def run(): - permission = lease() - provider, embedder = native(permission) - wire = Wire() - with pytest.raises(HyPermissionTransportError): - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_provider(provider) - guard.bind_embed_service(embedder) - if change in {"host", "path", "query"}: - provider._llm_config.base_url += { - "host": ".evil.test", - "path": "/other", - "query": "?token=private", - }[change] - elif change == "model": - provider._llm_config.model = "not-approved" - elif change == "backend": - # Public SDK 2.0.2 keeps LLMBackend narrowed to OPENAI, so the - # drifted-backend case uses an illegal sentinel value - # instead. It must differ from the approved openai backend, - # otherwise no drift is constructed at all. - provider._llm_config.backend = "eval-platform" - elif change == "dimensions": - embedder._embed_config.embedding_dims = 3 - elif change == "headers": - provider._llm_config.extra_headers = {"Authorization": "not-approved"} - elif change == "body": - provider._llm_config.extra_body = {"model": "not-approved"} - elif change == "key": - provider._llm_config.api_key = "changed" - if change == "dimensions": - await embedder.embed("input") - else: - await provider.complete( - "input", **({"model": "override"} if change == "kwargs" else {}) - ) - assert not wire.requests - assert wire.closed == 1 - - asyncio.run(run()) - - -@pytest.mark.parametrize( - "change", ["method", "url", "model", "messages", "dimensions", "auth", "host", "stream"] -) -def test_physical_send_checks_final_request_after_httpx_hooks(change): - async def run(): - permission = lease() - provider, embedder = native(permission) - wire = Wire() - - async def tamper(request): - if change == "method": - request.method = "PUT" - elif change == "url": - request.url = httpx.URL("https://unexpected.example.test/v1/chat/completions") - elif change in {"model", "messages", "dimensions"}: - body = json.loads(request.content) - body[change] = 3 if change == "dimensions" else "override" - request._content = json.dumps(body).encode() - request.stream = httpx.ByteStream(request._content) - elif change == "stream": - body = json.loads(request.content) - body["model"] = "stream-only-override" - request.stream = httpx.ByteStream(json.dumps(body).encode()) - else: - request.headers["authorization" if change == "auth" else "host"] = "override" - - with pytest.raises(HyPermissionTransportError): - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_provider(provider) - guard.bind_embed_service(embedder) - guard._http_client.event_hooks["request"].append(tamper) - if change == "dimensions": - await embedder.embed("input") - else: - await provider.complete("input") - assert not wire.requests - - asyncio.run(run()) - - -def test_redirect_is_terminal_and_cannot_reach_another_recipient(): - async def run(): - permission = lease() - provider, _ = native(permission) - wire = Wire( - lambda request, _: httpx.Response( - 307, - headers={ - "location": "https://unexpected.example.test/steal", - }, - ) - ) - with pytest.raises(HyPermissionTransportError, match="redirect"): - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_provider(provider) - await provider.complete("input") - assert len(wire.requests) == 1 and wire.closed == 1 - - asyncio.run(run()) - - -def test_lease_revoked_during_successful_response_cannot_publish(): - async def run(): - permission = lease() - provider, _ = native(permission) - - def revoke(request, _): - permission.revoked = True - return response(request) - - wire = Wire(revoke) - with pytest.raises(HyPermissionTransportError): - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_provider(provider) - await provider.complete("input") - assert len(wire.requests) == 1 - - asyncio.run(run()) - - -def test_queue_and_cache_are_lease_owned_and_outstanding_work_is_cancelled(): - """0.2.0's EmbedService uses an LRU cache, so the lease supplies OrderedDict. - - The old dict still accepted plain assignment, but 0.2.0's cache-hit path - calls move_to_end; a plain dict here fails the full embedding path and - masks the real permission/Cookie behavior. - """ - - async def run(): - permission = lease() - _, embedder = native(permission) - assert isinstance(embedder._cache, dict) - old_cache = embedder._cache - old_cache[embedder._get_cache_key("input")] = [99.0, 99.0] - wire = Wire() - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_embed_service(embedder) - assert await embedder.embed_queued("input") == [0.25, 0.75] - assert len(wire.requests) == 1 - embedder._embed_config.queue_batch_window_ms = 60000 - pending = asyncio.create_task(embedder.embed_queued("pending")) - await asyncio.sleep(0) - assert embedder._queue_pending - assert pending.cancelled() - assert not embedder._queue_pending - assert not embedder._queue_flush_tasks - assert embedder._cache is old_cache - assert len(wire.requests) == 1 and wire.closed == 1 - - asyncio.run(run()) - - -def test_pending_foreign_queue_and_double_install_are_rejected(): - async def run(): - permission = lease() - provider, embedder = native(permission) - future = asyncio.get_running_loop().create_future() - embedder._queue_pending[("https://other.example/v1", "secret")] = [ - ("other lease input", future) - ] - wire = Wire() - with pytest.raises(HyPermissionTransportError, match="queue"): - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_embed_service(embedder) - assert embedder._queue_pending == { - ("https://other.example/v1", "secret"): [("other lease input", future)] - } - async with HyPermissionTransport(permission, http_transport=Wire()) as first: - first.bind_provider(provider) - with pytest.raises(HyPermissionTransportError, match="bound"): - async with HyPermissionTransport(permission, http_transport=Wire()) as second: - second.bind_provider(provider) - await provider.complete("first still works") - future.cancel() - - asyncio.run(run()) - - -def test_local_onnx_stays_local_and_remote_fallback_is_denied(): - from one_memory.embedding_assets import BUILTIN_EMBEDDING_DIMENSIONS - from one_memory.hy_memory_bridge import LocalHyEmbedService - - async def run(): - permission = lease(local=True) - local = LocalHyEmbedService( - SimpleNamespace( - encode=lambda texts: [[0.5] * BUILTIN_EMBEDDING_DIMENSIONS for _ in texts], - ) - ) - wire = Wire() - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_local_embed_service(local) - assert len(await local.embed("input")) == BUILTIN_EMBEDDING_DIMENSIONS - saved = local.embed - with pytest.raises(HyPermissionTransportError, match="ended"): - await saved("late") - _, remote = native(permission) - with pytest.raises(HyPermissionTransportError): - async with HyPermissionTransport(permission, http_transport=Wire()) as guard: - guard.bind_embed_service(remote) - assert not wire.requests - - asyncio.run(run()) - - -def test_sdk_0_2_0_has_no_legacy_provider_specific_bypass_methods(): - """0.2.0 still exposes no provider-specific bypasses on LLMProvider. - - If a new SDK method could send outside complete/complete_messages/_call_llm, - the transport closure would no longer cover every outbound path. - """ - permission = lease() - provider, _ = native(permission) - - assert not hasattr(provider, "_call_openai") - assert not hasattr(provider, "_call_openai_messages") - assert not hasattr(provider, "_call_eval_platform") - assert not hasattr(provider, "_call_eval_platform_messages") - - -@pytest.mark.parametrize("logger_name", ["openai._base_client", "httpcore.http11"]) -def test_wire_debug_logging_does_not_block_provider_calls(caplog, logger_name): - async def run(): - permission = lease() - provider, _ = native(permission) - wire = Wire() - with caplog.at_level(logging.DEBUG, logger=logger_name): - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_provider(provider) - await provider.complete("private synthetic prompt") - assert logging.getLogger(logger_name).isEnabledFor(logging.DEBUG) - assert wire.requests - - asyncio.run(run()) - - -def test_revocation_in_last_request_hook_still_blocks_physical_send(): - async def run(): - permission = lease() - provider, _ = native(permission) - wire = Wire() - - async def revoke(_request): - permission.revoked = True - - with pytest.raises(HyPermissionTransportError): - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_provider(provider) - guard._http_client.event_hooks["request"].append(revoke) - await provider.complete("input") - assert not wire.requests and wire.closed == 1 - - asyncio.run(run()) - - -def test_unenveloped_http_and_unsupported_leaf_arguments_fail_closed(): - async def run(): - permission = lease() - provider, _ = native(permission) - wire = Wire() - with pytest.raises(HyPermissionTransportError, match="unsupported_request"): - async with HyPermissionTransport(permission, http_transport=wire) as guard: - await guard._http_client.post( - permission.receipt.snapshot.generation.endpoint + "/chat/completions", - json={"model": permission.profile.model}, - ) - assert not wire.requests - with pytest.raises(HyPermissionTransportError, match="unsupported_operation"): - async with HyPermissionTransport(permission, http_transport=Wire()) as guard: - guard.bind_provider(provider) - await provider.complete_messages([], 10, 0, stop=["not in native signature"]) - - asyncio.run(run()) - - -def test_independent_instances_and_rebinding_do_not_change_global_methods(): - async def run(): - first_lease, second_lease = lease(), lease() - first, _ = native(first_lease) - second, _ = native(second_lease) - class_methods = dict(LLMProvider.__dict__) - wires = [Wire(), Wire(), Wire()] - async with HyPermissionTransport(second_lease, http_transport=wires[1]) as second_guard: - second_guard.bind_provider(second) - with pytest.raises(HyPermissionTransportError): - async with HyPermissionTransport( - first_lease, http_transport=wires[0] - ) as first_guard: - first_guard.bind_provider(first) - saved_first = first.complete - await first.complete("first") - first_lease.revoked = True - await first.complete("denied") - await second.complete("second still allowed") - first_lease.revoked = False - async with HyPermissionTransport(first_lease, http_transport=wires[2]) as fresh: - fresh.bind_provider(first) - with pytest.raises(HyPermissionTransportError, match="ended"): - await saved_first("old captured method") - await first.complete("new binding") - assert [len(wire.requests) for wire in wires] == [1, 1, 1] - assert all(wire.closed == 1 for wire in wires) - assert dict(LLMProvider.__dict__) == class_methods - - asyncio.run(run()) - - -def test_cache_hit_and_queued_flush_cannot_outlive_revocation(): - async def run(): - permission = lease() - _, service = native(permission) - wire = Wire() - with pytest.raises(HyPermissionTransportError): - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_embed_service(service) - await service.embed("cached input") - permission.revoked = True - await service.embed("cached input") - assert len(wire.requests) == 1 - - asyncio.run(run()) - - -def test_exit_cancels_inflight_send_before_restoring_native_methods(): - async def run(): - permission = lease() - provider, _ = native(permission) - entered, cancelled = asyncio.Event(), asyncio.Event() - - async def slow(request): - entered.set() - try: - await asyncio.Event().wait() - finally: - cancelled.set() - - transport = httpx.MockTransport(slow) - async with HyPermissionTransport(permission, http_transport=transport) as guard: - guard.bind_provider(provider) - child = asyncio.create_task(provider.complete("in flight")) - await entered.wait() - http_client = guard._http_client - sdk_client = guard._clients[0] - assert child.cancelled() and cancelled.is_set() - assert http_client.is_closed and sdk_client.is_closed() - assert provider.complete.__func__ is LLMProvider.complete - - asyncio.run(run()) - - -@pytest.mark.parametrize("field", ["data", "usage"]) -def test_malformed_embedding_response_does_not_publish_raw_metadata(field, caplog): - async def run(): - permission = lease() - _, service = native(permission) - - def invalid(request, _): - data = json.loads(response(request).content) - if field == "data": - data["data"][0]["embedding"] = ["private-raw-provider-value", 0.5] - else: - data["usage"]["prompt_tokens"] = "private-raw-provider-value" - return httpx.Response(200, json=data) - - wire = Wire(invalid) - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_embed_service(service) - with pytest.raises(HyPermissionTransportError) as error: - await service.embed_with_meta("input") - assert "private-raw" not in str(error.value) - assert "private-raw" not in repr(service._last_embed_meta) - assert "private-raw" not in caplog.text - - asyncio.run(run()) - - -def test_local_encoder_failure_is_sanitized_without_cloud_fallback(caplog): - from one_memory.hy_memory_bridge import LocalHyEmbedService - - async def run(): - permission = lease(local=True) - - def broken(_texts): - raise RuntimeError("private-raw-encoder-error") - - service = LocalHyEmbedService(SimpleNamespace(encode=broken)) - wire = Wire() - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_local_embed_service(service) - with pytest.raises(HyPermissionTransportError) as error: - await service.embed("input") - assert "private-raw" not in str(error.value) - assert not wire.requests and "private-raw" not in caplog.text - - asyncio.run(run()) - - -def test_local_encoder_thread_is_drained_before_restoration(): - from one_memory.hy_memory_bridge import LocalHyEmbedService - - async def run(): - entered, release, finished = threading.Event(), threading.Event(), threading.Event() - - def encode(_texts): - entered.set() - assert release.wait(5) - finished.set() - return [[0.5, 0.5]] - - service = LocalHyEmbedService(SimpleNamespace(encode=encode)) - guard = await HyPermissionTransport(lease(local=True), http_transport=Wire()).__aenter__() - guard.bind_local_embed_service(service) - child = asyncio.create_task(service.embed("synthetic input")) - try: - assert await asyncio.to_thread(entered.wait, 5) - exiting = asyncio.create_task(guard.__aexit__(None, None, None)) - while not child.cancelling(): - await asyncio.sleep(0) - await asyncio.sleep(0) - assert not child.done() - assert not exiting.done() - assert service.embed.__func__ is not LocalHyEmbedService.embed - finally: - release.set() - await exiting - assert finished.is_set() and child.cancelled() - assert service.embed.__func__ is LocalHyEmbedService.embed - - asyncio.run(run()) - - -def test_seal_checks_full_lease_then_exit_allows_own_governance_projection(): - async def run(): - permission = lease() - provider, embedder = native(permission) - wire = Wire() - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_provider(provider) - guard.bind_embed_service(embedder) - await provider.complete("last SDK call") - checks = permission.checks - await guard.seal() - assert guard.requests_sealed and permission.checks == checks + 1 - checks = permission.checks - # Represents main's own expected version update AFTER full checking. - # Main separately checks frozen permissions before its commit. - permission.context_changed = True - await guard.seal() # Idempotent; no full-context check after publication. - for call in ( - provider.complete("late child"), - provider._call_llm("late direct dispatch", 10, 0), - embedder.embed("late embed"), - guard._http_client.post("https://models.example.test/approved/v1/embeddings"), - ): - with pytest.raises(HyPermissionTransportError, match="sealed"): - await call - assert permission.checks == checks - assert permission.checks == checks and wire.closed == 1 - assert len(wire.requests) == 1 - - asyncio.run(run()) - - -def test_seal_does_not_skip_revocation_or_a_swallowed_denial(): - async def run(): - permission = lease() - provider, _ = native(permission) - wire = Wire() - with pytest.raises(HyPermissionTransportError, match="lease_denied"): - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_provider(provider) - await provider.complete("positive") - permission.revoked = True - with pytest.raises(HyPermissionTransportError, match="lease_denied"): - await guard.seal() - assert not guard.requests_sealed - permission.revoked = False - await guard.seal() - assert wire.closed == 1 - - asyncio.run(run()) - - -def test_seal_refuses_inflight_request_then_succeeds_only_after_join(): - async def run(): - permission = lease() - provider, _ = native(permission) - entered, release = asyncio.Event(), asyncio.Event() - - async def slow(request): - entered.set() - await release.wait() - return response(request) - - async with HyPermissionTransport( - permission, http_transport=httpx.MockTransport(slow) - ) as guard: - guard.bind_provider(provider) - child = asyncio.create_task(provider.complete("in flight")) - await entered.wait() - with pytest.raises(HyPermissionTransportError, match="active_requests"): - await guard.seal() - assert not guard.requests_sealed - release.set() - await child - await guard.seal() - assert guard.requests_sealed - - asyncio.run(run()) - - -def test_seal_refuses_pending_native_queue_before_timer_has_started(): - async def run(): - permission = lease() - _, service = native(permission) - wire = Wire() - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_embed_service(service) - child = asyncio.create_task(service.embed_queued("pending")) - await asyncio.sleep(0) - assert service._queue_pending - with pytest.raises(HyPermissionTransportError, match="active_requests"): - await guard.seal() - assert not guard.requests_sealed - await child - # SDK 0.2.0 leaves the bucket key with a None task after the timer - # has taken ownership of the batch. Waiting only on live tasks is - # the same synchronization point the old SDK expressed by removing - # the key. - await asyncio.gather( - *(task for task in service._queue_flush_tasks.values() if task is not None) - ) - await guard.seal() - assert len(wire.requests) == 1 and wire.closed == 1 - - asyncio.run(run()) - - -def test_exit_joins_queue_timer_even_before_its_bound_coroutine_starts(): - async def run(): - permission = lease() - _, service = native(permission) - wire = Wire() - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_embed_service(service) - child = asyncio.create_task(service.embed_queued("pending")) - await asyncio.sleep(0) - timer = next(iter(service._queue_flush_tasks.values())) - assert timer not in guard._tasks - assert child.cancelled() - assert timer.done() - # A timer that faults only because exit outran its first admission check - # must be retrieved; otherwise asyncio logs an unhandled task exception. - assert not timer._log_traceback - assert not wire.requests and wire.closed == 1 - - asyncio.run(run()) - - -@pytest.mark.parametrize("bind", [False, True]) -def test_close_failure_is_sanitized_and_cannot_skip_instance_restoration(bind): - class BrokenClose(Wire): - async def aclose(self): - await super().aclose() - raise RuntimeError("private-close-error") - - async def run(): - permission = lease() - provider, _ = native(permission) - wire = BrokenClose() - with pytest.raises(HyPermissionTransportError, match="cleanup_failed"): - async with HyPermissionTransport(permission, http_transport=wire) as guard: - if bind: - guard.bind_provider(provider) - await provider.complete("positive") - assert provider.complete.__func__ is LLMProvider.complete - assert guard._http_client.is_closed - assert wire.closed == 1 - - asyncio.run(run()) - - -def test_a_gateway_cookie_does_not_deny_the_next_call_in_the_task(): - """Gateway cookies must not become model request headers. - - 0.2.0's client stack still shares the gateway response with the next call - in the same task. The transport has to keep the CookieJar policy denying - every domain so the second physical request remains header-identical. - """ - - def handler(request, count): - reply = response(request) - reply.headers["set-cookie"] = "x_host_key_access=abc; path=/; HttpOnly" - return reply - - async def run(): - permission = lease() - provider, _embedder = native(permission) - wire = Wire(handler) - async with HyPermissionTransport(permission, http_transport=wire) as guard: - guard.bind_provider(provider) - await provider.complete("judge") - await provider.complete_messages([{"role": "user", "content": "extract"}]) - assert len(wire.requests) == 2 - assert all("cookie" not in request.headers for request in wire.requests) - - asyncio.run(run()) diff --git a/memhub/apps/core/tests/test_hy_state_source.py b/memhub/apps/core/tests/test_hy_state_source.py deleted file mode 100644 index 0103a9e..0000000 --- a/memhub/apps/core/tests/test_hy_state_source.py +++ /dev/null @@ -1,547 +0,0 @@ -"""RC4:记忆生命周期(有效 / 被取代 / 去重隐藏 / 过期)直接读 HY,产品账本不再逐类镜像。 - -产品只保留 HY 没有的三样:使用范围、来源撤权后的隐藏过滤、用户删除记录。 -这里钉住:HY 自己改了状态(导入写回从未见过的也算),读路径立刻按 HY 走; -HY 读不到时读路径退回账本,而不是把"没读到"当成"全部有效"。 -""" - -import asyncio -from pathlib import Path - -import pytest -from hy_memory.models.memory import MemoryStatus as HyStatus - -from one_memory.hy_lifecycle import HyLifecycle, HyLifecycleUnreadable -from one_memory.hy_memory_bridge import HyMemoryBridge -from one_memory.imports.grants import SourceGrantStore -from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy -from one_memory.imports.sources.registry import RootCandidateKind -from one_memory.imports.storage import EvidenceStore -from one_memory.memory_governance import ( - HyCreate, - MemoryDraft, - MemoryGovernanceStore, - MemoryKind, - MemoryMutationConflictError, - MemoryStatus, -) -from one_memory.memory_library import read_memory_library -from tests.person_door import as_person -from tests.test_hy_permission_raw_context import ( - AGENT_TEXT, - USER_TEXT, - ScriptedOracle, - assert_outputs, - bind_oracle, - native_client, - on_native_loop, - raw_client, -) - -FACT = "用户的发布前检查要求一次权限评审" - - -def _evidence(database: Path, message_id: str, *, conversation: str = "conversation") -> str: - return ( - EvidenceStore(database) - .put( - EvidenceInput( - source_id="cursor", - conversation_id=conversation, - message_id=message_id, - correlation_id=message_id, - role="user", - written_by=WrittenBy.PERSON, - verbatim=f"原话 {message_id}", - origin_meta={EvidenceOrigin.FILE: OriginMetadata()}, - ) - ) - .record.evidence_id - ) - - -def _library(tmp_path: Path) -> tuple[Path, MemoryGovernanceStore, dict[str, str]]: - """Four memories, newest last: a, b, c, d. Each rests on its own evidence.""" - database = tmp_path / "one-memory.sqlite3" - SourceGrantStore(database, home=tmp_path) - store = MemoryGovernanceStore(database) - ids: dict[str, str] = {} - for key in ("a", "b", "c", "d"): - evidence_id = _evidence(database, key) - as_person(store).create( - MemoryDraft( - user_id="local", - kind=MemoryKind.CHAT, - content=f"{FACT} {key}", - scopes=("all-apps",), - evidence_ids=(evidence_id,), - ), - request_id=f"create-{key}", - memory_id=key, - ) - ids[key] = evidence_id - return database, store, ids - - -def _ids(memories) -> list[str]: - return [memory.memory_id for memory in memories] - - -# ---------------------------------------------------------------- 账本读路径 - - -def test_what_hy_hides_leaves_every_current_read_and_shows_hy_state_by_id(tmp_path) -> None: - _, store, _ = _library(tmp_path) - # HY deduplicated d into b during a search, and replaced a with c. - lifecycle = HyLifecycle(hidden={"d": ("b",), "a": ("c",)}) - - assert _ids(store.list(user_id="local")) == ["d", "c", "b", "a"] - assert _ids(store.list(user_id="local", lifecycle=lifecycle)) == ["c", "b"] - found, more = store.find_confirmed_matching_terms( - user_id="local", terms=(FACT,), lifecycle=lifecycle - ) - assert _ids(found) == ["c", "b"] and more is False - - hidden = store.get("d", user_id="local", lifecycle=lifecycle) - assert hidden.status is MemoryStatus.SUPERSEDED - assert hidden.superseded_by == ("b",) and hidden.deleted_at is None - kept = store.get("b", user_id="local", lifecycle=lifecycle) - assert kept.status is MemoryStatus.CONFIRMED and kept.supersedes == ("d",) - assert _ids( - store.list(user_id="local", status=MemoryStatus.SUPERSEDED, lifecycle=lifecycle) - ) == [ - "d", - "a", - ] - assert _ids( - store.list(user_id="local", status=MemoryStatus.CONFIRMED, lifecycle=lifecycle) - ) == [ - "c", - "b", - ] - - -def test_hidden_rows_are_excluded_before_the_page_is_cut(tmp_path) -> None: - _, store, _ = _library(tmp_path) - lifecycle = HyLifecycle(hidden={"d": ("b",), "c": ("b",)}) - - assert _ids(store.list(user_id="local", limit=1, lifecycle=lifecycle)) == ["b"] - assert _ids(store.list(user_id="local", limit=1, offset=1, lifecycle=lifecycle)) == ["a"] - found, more = store.find_confirmed_matching_terms( - user_id="local", terms=(FACT,), limit=1, lifecycle=lifecycle - ) - assert _ids(found) == ["b"] and more is True - - -def test_a_user_deletion_stays_a_deletion_whatever_hy_says(tmp_path) -> None: - _, store, _ = _library(tmp_path) - b = store.get("b", user_id="local") - as_person(store).delete( - "b", user_id="local", request_id="delete-b", expected_revision=b.revision - ) - # HY still holds b as active until the deletion outbox reaches it. - lifecycle = HyLifecycle(hidden={}) - - assert "b" not in _ids(store.list(user_id="local", lifecycle=lifecycle)) - assert store.get("b", user_id="local", lifecycle=lifecycle).status is MemoryStatus.DELETED - assert _ids(store.list(user_id="local", status=MemoryStatus.DELETED, lifecycle=lifecycle)) == [ - "b" - ] - - -def test_hy_restoring_a_memory_it_had_replaced_brings_it_back(tmp_path) -> None: - """A row an older build mirrored as superseded is current again once HY says so. - - HY restores the predecessor when the head of a chain is deleted; no write - reaches the ledger for that, so only reading HY can see it. - """ - from one_memory.memory_governance import HySupersede - - _, store, _ = _library(tmp_path) - a = store.get("a", user_id="local") - store.write_from_hy( - HySupersede( - memory_id="a", - request_id="hy-supersede:t:a", - expected_revision=a.revision, - superseded_by=("c",), - ), - user_id="local", - derivation=store.hy_derivation(), - ) - assert "a" not in _ids(store.list(user_id="local")) - - restored = HyLifecycle(hidden={}) - assert "a" in _ids(store.list(user_id="local", lifecycle=restored)) - memory = store.get("a", user_id="local", lifecycle=restored) - assert memory.status is MemoryStatus.CONFIRMED and memory.superseded_by == () - assert store.get("c", user_id="local", lifecycle=restored).supersedes == () - - -def test_without_a_snapshot_the_ledger_reads_as_before(tmp_path) -> None: - _, store, _ = _library(tmp_path) - - assert _ids(store.list(user_id="local", lifecycle=None)) == ["d", "c", "b", "a"] - assert store.get("d", user_id="local", lifecycle=None).status is MemoryStatus.CONFIRMED - - -def test_a_superseded_memory_does_not_come_back_after_reopen(tmp_path) -> None: - """Reopening the library may not resurrect what HY already replaced. - - The ledger's own mirrored supersession is the floor that holds while HY is - not open; a second read path must not treat "no snapshot yet" as "active". - """ - from one_memory.memory_governance import HySupersede - - database, store, _ = _library(tmp_path) - a = store.get("a", user_id="local") - store.write_from_hy( - HySupersede( - memory_id="a", - request_id="hy-supersede:reopen:a", - expected_revision=a.revision, - superseded_by=("c",), - ), - user_id="local", - derivation=store.hy_derivation(), - ) - reopened = MemoryGovernanceStore(database) - assert "a" not in _ids(reopened.list(user_id="local")) - page = read_memory_library(database, limit=100) - assert "a" not in [item.memory_id for item in page.items] - - -def test_revoked_source_stays_hidden_even_with_a_stale_lifecycle_snapshot(tmp_path) -> None: - """Authority outranks an old HY snapshot: no snapshot may re-expose it.""" - database, store, _ = _library(tmp_path) - stale = HyLifecycle(hidden={}) - assert _ids(store.list(user_id="local", lifecycle=stale)) == ["d", "c", "b", "a"] - - grants = SourceGrantStore(database, home=tmp_path) - root = tmp_path / "cursor-root" - root.mkdir() - grants.grant( - user_id="local", - source_id="cursor", - root=root, - candidate_kind=RootCandidateKind.DEFAULT, - ) - grants.revoke(user_id="local", source_id="cursor") - # Documented contract: the ledger itself still lists the rows it owns. - # Revocation is enforced by the read projections and external authority, - # so a stale lifecycle snapshot must not re-expose them there. - assert _ids(store.list(user_id="local", lifecycle=stale)) == ["d", "c", "b", "a"] - page = read_memory_library(database, source="cursor", limit=100, lifecycle=stale) - assert page.total == 0 - - -def test_revocation_survives_reopen_and_keeps_the_ledger_intact(tmp_path) -> None: - """Revocation hides reads; it does not delete, and it must outlive a restart. - - The governance store keeps its row by design, so a downstream consumer has - to read through authority and must not treat the surviving row as current. - """ - database, _, _ = _library(tmp_path) - grants = SourceGrantStore(database, home=tmp_path) - root = tmp_path / "cursor-root" - root.mkdir() - grants.grant( - user_id="local", - source_id="cursor", - root=root, - candidate_kind=RootCandidateKind.DEFAULT, - ) - grants.revoke(user_id="local", source_id="cursor") - - reopened = MemoryGovernanceStore(database) - receipt = SourceGrantStore(database, home=tmp_path).receipt(user_id="local", source_id="cursor") - - assert receipt is not None and receipt.revoked_at is not None - # Rows survive; they are simply not readable through this source. - assert len(reopened.list(user_id="local")) == 4 - assert read_memory_library(database, source="cursor", limit=100).total == 0 - - -def test_the_memory_library_page_and_total_follow_hy(tmp_path) -> None: - database, _, _ = _library(tmp_path) - lifecycle = HyLifecycle(hidden={"d": ("b",)}) - - page = read_memory_library(database, lifecycle=lifecycle) - assert _ids(page.items) == ["c", "b", "a"] and page.total == 3 - replaced = read_memory_library(database, status=MemoryStatus.SUPERSEDED, lifecycle=lifecycle) - assert _ids(replaced.items) == ["d"] and replaced.total == 1 - assert replaced.items[0].superseded_by == ("b",) - active = read_memory_library(database, active_only=True, lifecycle=lifecycle, limit=1) - assert _ids(active.items) == ["c"] and active.total == 3 - - -# ---------------------------------------------------------------- 删除语义 - - -def test_a_new_conversation_saying_a_deleted_fact_is_remembered_again(tmp_path) -> None: - """Deleting removes that memory, not the fact: no content tombstone. - - Only the exact old evidence stays guarded (RC10); a different conversation - saying the same thing later is a new statement. - """ - database, store, _ = _library(tmp_path) - a = store.get("a", user_id="local") - as_person(store).delete( - "a", user_id="local", request_id="delete-a", expected_revision=a.revision - ) - again = _evidence(database, "again", conversation="later-conversation") - - created = store.write_from_hy( - HyCreate( - draft=MemoryDraft( - user_id="local", - kind=MemoryKind.CHAT, - content=a.content, - scopes=("all-apps",), - evidence_ids=(again,), - ), - request_id="extract:later:new-a", - memory_id="new-a", - ), - user_id="local", - derivation=store.hy_derivation((again,)), - ) - - assert created.status is MemoryStatus.CONFIRMED and created.content == a.content - assert "new-a" in _ids(store.list(user_id="local")) - assert store.blocked_source_ids(user_id="local", evidence_ids=(again,)) == set() - - -def test_the_store_uses_its_lifecycle_source_for_reads_and_person_edits(tmp_path) -> None: - _, store, _ = _library(tmp_path) - hidden = HyLifecycle(hidden={"d": ("b",)}) - calls: list[tuple[str, tuple[str, ...] | None]] = [] - - def source(user_id: str, ids: tuple[str, ...] | None = None) -> HyLifecycle: - calls.append((user_id, ids)) - return hidden - - store.read_lifecycle_from(source) - assert _ids(store.list(user_id="local")) == ["c", "b", "a"] - current = store.get("d", user_id="local") - assert current is not None and current.status is MemoryStatus.SUPERSEDED - assert calls == [("local", None), ("local", ("d",))] - # Editing what HY has set aside would fork the chain it already replaced. - with pytest.raises(MemoryMutationConflictError, match="memory_superseded"): - as_person(store).correct( - "d", - user_id="local", - request_id="correct-hidden", - expected_revision=current.revision, - content=f"{FACT} corrected", - ) - - -def test_a_rejected_or_missing_source_answers_degraded_not_all_active(tmp_path) -> None: - """Wiring contract for C0/C4: unreadable means unavailable, never 'active'. - - A source that raises must be visible as an error to the composition root; a - source that answers `None` keeps the ledger floor rather than inventing - freshness. Neither may be read as 'HY says everything is current'. - """ - _, store, _ = _library(tmp_path) - - def broken(_user_id: str, _ids: tuple[str, ...] | None = None) -> HyLifecycle: - raise HyLifecycleUnreadable("hy_lifecycle_unreadable") - - store.read_lifecycle_from(broken) - with pytest.raises(HyLifecycleUnreadable): - store.list(user_id="local") - with pytest.raises(HyLifecycleUnreadable): - store.get("d", user_id="local") - - # Before HY is open, `None` keeps the ledger's own supersession floor: - # a memory HY replaced stays hidden because the write was observed here. - from one_memory.memory_governance import HySupersede - - _, store, _ = _library(tmp_path) - a = store.get("a", user_id="local") - store.write_from_hy( - HySupersede( - memory_id="a", - request_id="hy-supersede:floor:a", - expected_revision=a.revision, - superseded_by=("c",), - ), - user_id="local", - derivation=store.hy_derivation(), - ) - store.read_lifecycle_from(lambda _user_id, _ids=None: None) - assert "a" not in _ids(store.list(user_id="local")) - - -def test_one_snapshot_per_page_not_one_per_row(tmp_path) -> None: - """Wiring contract for C4: a page read asks HY once, not once per row.""" - _, store, _ = _library(tmp_path) - calls: list[tuple[str, tuple[str, ...] | None]] = [] - - def source(user_id: str, ids: tuple[str, ...] | None = None) -> HyLifecycle: - calls.append((user_id, ids)) - return HyLifecycle(hidden={"d": ("b",)}) - - store.read_lifecycle_from(source) - assert len(store.list(user_id="local")) == 3 - assert calls == [("local", None)] - - -def test_an_unreadable_lifecycle_blocks_edits_but_not_a_local_delete(tmp_path) -> None: - _, store, _ = _library(tmp_path) - current = store.get("d", user_id="local", lifecycle=None) - assert current is not None - - def unreadable(_user_id: str, _ids: tuple[str, ...] | None = None) -> HyLifecycle: - raise HyLifecycleUnreadable("hy_lifecycle_unreadable") - - store.read_lifecycle_from(unreadable) - with pytest.raises(HyLifecycleUnreadable): - as_person(store).correct( - "d", - user_id="local", - request_id="correct-unreadable", - expected_revision=current.revision, - content=f"{FACT} corrected", - ) - - deleted = as_person(store).delete( - "d", - user_id="local", - request_id="delete-unreadable", - expected_revision=current.revision, - ) - assert deleted.status is MemoryStatus.DELETED - - -# ---------------------------------------------------------------- 真 HY - - -async def _first_turn(supervisor, records): - first = supervisor.prepare_conversation(records[0])[0] - client = await native_client(supervisor, first) - bind_oracle(supervisor, ScriptedOracle(records)) - assert (await supervisor._create_runner().run(first.task_id)).status == "completed" - await assert_outputs(supervisor, first, records, client) - by_content = {m.content: m for m in supervisor.governance.list(user_id="local")} - bridge = supervisor._bridge(first.profile_snapshot, None, supervisor.data_dir) - return client, bridge, by_content[USER_TEXT], by_content[AGENT_TEXT] - - -def test_hy_search_dedup_is_read_straight_from_hy(tmp_path) -> None: - """HY shadows a duplicate while searching; no ingest write-back ever sees it.""" - - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, _): - client, bridge, dup, kept = await _first_turn(supervisor, records) - assert isinstance(bridge, HyMemoryBridge) - assert bridge.lifecycle_now("local") == HyLifecycle(hidden={}) - - await on_native_loop( - client, - client._vector_store.update_payload( - dup.memory_id, - { - "status": HyStatus.SHADOW.value, - "is_latest": False, - "shadow_reason": "dedup:search", - "dedup_kept_id": kept.memory_id, - }, - ), - ) - - lifecycle = bridge.lifecycle_now("local") - assert lifecycle == HyLifecycle(hidden={dup.memory_id: (kept.memory_id,)}) - assert _ids(supervisor.governance.list(user_id="local", lifecycle=lifecycle)) == [ - kept.memory_id - ] - assert bridge.lifecycle_now("someone-else") == HyLifecycle(hidden={}) - - asyncio.run(scenario()) - - -def test_a_replacement_hy_records_on_the_old_node_is_read_from_hy(tmp_path) -> None: - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, _): - client, bridge, old, head = await _first_turn(supervisor, records) - await on_native_loop( - client, - client._vector_store.update_payload( - old.memory_id, - { - "status": HyStatus.SUPERSEDED.value, - "is_latest": False, - "superseded_by": [head.memory_id], - }, - ), - ) - - lifecycle = bridge.lifecycle_now("local") - assert lifecycle == HyLifecycle(hidden={old.memory_id: (head.memory_id,)}) - memory = supervisor.governance.get(old.memory_id, user_id="local", lifecycle=lifecycle) - assert memory.status is MemoryStatus.SUPERSEDED - assert memory.superseded_by == (head.memory_id,) - - asyncio.run(scenario()) - - -def test_an_unreadable_hy_store_raises_instead_of_reading_as_all_active(tmp_path) -> None: - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, _): - client, bridge, dup, kept = await _first_turn(supervisor, records) - await on_native_loop( - client, - client._vector_store.update_payload( - dup.memory_id, - {"status": HyStatus.SHADOW.value, "is_latest": False}, - ), - ) - # Positive first: this store does hold something hidden. - assert bridge.lifecycle_now("local") == HyLifecycle(hidden={dup.memory_id: ()}) - - collection = client._vector_store._collection - - def broken(*args, **kwargs): - raise RuntimeError("chroma is gone") - - original = collection.get - collection.get = broken - try: - with pytest.raises(HyLifecycleUnreadable): - bridge.lifecycle_now("local") - finally: - collection.get = original - - asyncio.run(scenario()) - - -def test_hy_not_opened_yet_is_only_safe_when_no_store_exists(tmp_path) -> None: - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, _): - first = supervisor.prepare_conversation(records[0])[0] - bridge = supervisor._bridge(first.profile_snapshot, None, supervisor.data_dir) - assert bridge.lifecycle_now("local") is None - - (supervisor.data_dir / "hy-memory" / "governed-v2" / "graph").mkdir(parents=True) - with pytest.raises(HyLifecycleUnreadable, match="hy_lifecycle_not_ready"): - bridge.lifecycle_now("local") - - asyncio.run(scenario()) - - -def test_malformed_hy_lifecycle_rows_fail_closed(tmp_path) -> None: - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, _): - client, bridge, _dup, _kept = await _first_turn(supervisor, records) - collection = client._vector_store._collection - original = collection.get - collection.get = lambda **_kwargs: {"ids": ["missing-metadata"], "metadatas": []} - try: - with pytest.raises(HyLifecycleUnreadable, match="hy_lifecycle_store_mismatch"): - bridge.lifecycle_now("local") - finally: - collection.get = original - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_hy_step_timing.py b/memhub/apps/core/tests/test_hy_step_timing.py deleted file mode 100644 index ce470f6..0000000 --- a/memhub/apps/core/tests/test_hy_step_timing.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Where an HY write spends the time that is not a model call.""" - -import asyncio -import json -import time - -import httpx - -from one_memory.diagnostic_log import configure_diagnostics, reset_diagnostics -from one_memory.hy_permission_context import permission_bridge_factory -from one_memory.hy_step_timing import HyStepTimer -from tests.test_hy_permission_context import whole_client - - -def _handler(record): - def handler(request: httpx.Request): - body = json.loads(request.content) - if request.url.path.endswith("embeddings"): - return httpx.Response( - 200, - json={ - "object": "list", - "model": "test-embedding", - "data": [ - {"index": i, "object": "embedding", "embedding": [1.0] + [0.0] * 383} - for i, _ in enumerate(body["input"]) - ], - "usage": {"prompt_tokens": 1, "total_tokens": 1}, - }, - ) - return httpx.Response( - 200, - json={ - "id": "fixture-response", - "object": "chat.completion", - "created": 1, - "model": "test-generation", - "choices": [ - { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": json.dumps( - [ - { - "op": "ADD", - "content": record.verbatim, - "layer": "L2_FACT", - "owner": "agent", - } - ] - ), - }, - } - ], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, - }, - ) - - return handler - - -def test_leased_ingest_logs_each_hy_step_with_duration_and_count(tmp_path, monkeypatch): - # Per-step tracing is its own tier on top of the master switch, so a test - # that reads the step events has to ask for both. - monkeypatch.setenv("ONE_MEMORY_DIAGNOSTICS", "1") - monkeypatch.setenv("ONE_MEMORY_HY_STEP_TRACE", "1") - receipt = configure_diagnostics(tmp_path / "diagnostics") - - async def scenario(): - with whole_client(tmp_path) as (supervisor, record, _, _): - supervisor.permission_bridge_factory = permission_bridge_factory( - supervisor._bridge, http_transport=httpx.MockTransport(_handler(record)) - ) - tasks = supervisor.prepare_conversation(record) - try: - result = await supervisor._create_runner().run(tasks[0].task_id) - assert result.status == "completed", result.last_error_code - finally: - await supervisor.shutdown() - return record, tasks[0].task_id - - try: - record, task_id = asyncio.run(scenario()) - finally: - reset_diagnostics() - written = receipt.path.read_text() - events = [json.loads(line) for line in written.splitlines()] - steps = { - event["details"]["phase"]: event - for event in events - if event["event"] == "hy.step.completed" - } - # Memory content with no prior candidate takes HY's no-model ADD path. - assert "model" not in steps - expected = { - "embedding": "hy.embedding", - "vector_store_read": "hy.vector_store", - "vector_store_write": "hy.vector_store", - "permission_check": "hy.permission_store", - "permission_store_read": "hy.permission_store", - "permission_store_write": "hy.permission_store", - "write_verification": "hy.write_verification", - "reconcile": "hy.reconcile", - } - assert expected.items() <= {name: event["component"] for name, event in steps.items()}.items() - for event in steps.values(): - assert event["task_id"] == task_id - assert event["request_id"] - assert isinstance(event["duration_ms"], int) - assert event["details"]["call_count"] >= 1 - assert event["details"]["summed_ms"] + 1 >= event["duration_ms"] - summaries = [event for event in events if event["event"] == "hy.steps.summary"] - assert len(summaries) == 1 - summary = summaries[0] - assert summary["component"] == "hy.bridge" - assert summary["task_id"] == task_id - details = summary["details"] - assert details["model_ms"] == 0 - assert details["accounted_ms"] + details["unaccounted_ms"] == summary["duration_ms"] - assert details["accounted_ms"] + 1 >= max(event["duration_ms"] for event in steps.values()) - assert record.verbatim not in written - - -def test_nested_same_step_counts_once_and_overlap_counts_wall_clock_once(): - timer = HyStepTimer() - with timer.step("permission_check"), timer.step("permission_check"): - time.sleep(0.02) - - async def overlapping(): - async def one(): - with timer.step("embedding", items=2): - await asyncio.sleep(0.05) - - await asyncio.gather(one(), one()) - - asyncio.run(overlapping()) - measured = timer.measurements() - assert measured["permission_check"].call_count == 1 - embedding = measured["embedding"] - assert embedding.call_count == 2 - assert embedding.item_count == 4 - assert embedding.summed_ms >= 95 - assert 45 <= embedding.busy_ms < 90 diff --git a/memhub/apps/core/tests/test_hy_supersede_sync.py b/memhub/apps/core/tests/test_hy_supersede_sync.py deleted file mode 100644 index 38be050..0000000 --- a/memhub/apps/core/tests/test_hy_supersede_sync.py +++ /dev/null @@ -1,453 +0,0 @@ -"""HY decides supersessions; the ledger mirrors them. Pinned SDK, scripted HTTP.""" - -import asyncio -import json -from copy import deepcopy - -import httpx -import pytest -from hy_memory.agent.tools.basic_profile import upsert_basic_profile -from hy_memory.coding.types import WorkingFact -from hy_memory.models.memory import MemoryLayer -from hy_memory.models.memory import MemoryStatus as HyStatus - -import tests.test_hy_permission_raw_context as raw_context -from one_memory.hy_memory_bridge import HyMemoryBridgeError -from one_memory.hy_permission_coding_store import HyPermissionCodingStore, hy_coding_fingerprint -from one_memory.hy_permission_store import HyNodePermission -from one_memory.memory_governance import MemoryKind, MemoryStatus -from tests.test_hy_coding_lease import TOOL_TEXT, CodingOracle, by_kind, run_coding_turn -from tests.test_hy_permission_raw_context import ( - AGENT_TEXT, - USER_TEXT, - ScriptedOracle, - assert_outputs, - bind_oracle, - grant_records, - native_client, - on_native_loop, - put_turn, - raw_client, -) -from tests.test_hy_permission_store import Backend, Lease, node, wrapper - -NEW_TEXT = "The user now requires two permission reviews before the synthetic release." - - -def _vector(text): - axis = {USER_TEXT: 1, AGENT_TEXT: 2, NEW_TEXT: 3}.get(text, 0) - return [float(index == axis) for index in range(384)] - - -class SupersedingOracle(ScriptedOracle): - """HY's reconciler replaces existing claims with the new one instead of updating them.""" - - def __init__(self, records, *, chain): - super().__init__(records) - self.chain = chain - - def respond(self, request): - response = super().respond(request) - if self.stages[-1] != "reconcile": - return response - body = json.loads(response.content) - ops = json.loads(body["choices"][0]["message"]["content"]) - by_content = {op["content"]: op for op in ops} - supersede = { - "op": "SUPERSEDE", - "content": NEW_TEXT, - "owner": "user", - "layer": "L2_FACT", - "tags": [], - "supersede_reason": "the user raised the requirement", - } - if self.chain: - supersede["memory_ids"] = [self.chain[0], self.chain[1]] - replaced = {USER_TEXT, AGENT_TEXT} - else: - supersede["memory_id"] = by_content[USER_TEXT]["memory_id"] - replaced = {USER_TEXT} - ops = [op for op in ops if op["content"] not in replaced | {NEW_TEXT}] + [supersede] - body["choices"][0]["message"]["content"] = json.dumps(ops) - return httpx.Response(200, json=body) - - -async def _first_turn(supervisor, records): - first = supervisor.prepare_conversation(records[0])[0] - client = await native_client(supervisor, first) - bind_oracle(supervisor, ScriptedOracle(records)) - assert (await supervisor._create_runner().run(first.task_id)).status == "completed" - await assert_outputs(supervisor, first, records, client) - by_content = {m.content: m for m in supervisor.governance.list(user_id="local")} - return client, by_content[USER_TEXT], by_content[AGENT_TEXT] - - -async def _superseding_turn(supervisor, records, permission, *, chain=()): - added = put_turn(supervisor.evidence, "user-raise", "user", NEW_TEXT, 3) - records = (*records, added) - grant_records(supervisor, records, permission.revision) - second = supervisor.prepare_conversation(added)[0] - oracle = SupersedingOracle(records, chain=chain) - bind_oracle(supervisor, oracle) - result = await supervisor._create_runner().run(second.task_id) - assert not oracle.errors, oracle.errors - assert result.status == "completed", (result.last_error_code, oracle.stages) - return oracle - - -def _head(supervisor): - (head,) = [ - m for m in supervisor.governance.list(user_id="local", limit=500) if m.content == NEW_TEXT - ] - return head - - -def test_a_memory_hy_supersedes_is_superseded_in_the_ledger_with_its_replacement( - tmp_path, monkeypatch -): - monkeypatch.setattr(raw_context, "vector", _vector) - - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, permission): - client, replaced, kept = await _first_turn(supervisor, records) - monkeypatch.setitem(raw_context.CLAIMS, NEW_TEXT, "user") - await _superseding_turn(supervisor, records, permission) - - native = await on_native_loop( - client, client._vector_store.get_by_id(replaced.memory_id) - ) - assert native.status == HyStatus.SUPERSEDED - head = _head(supervisor) - assert native.superseded_by == [head.memory_id] - - old = supervisor.governance.get(replaced.memory_id, user_id="local") - assert old.status is MemoryStatus.SUPERSEDED - assert old.deleted_at is None and old.content == USER_TEXT - assert old.superseded_by == (head.memory_id,) - assert supervisor.governance.deletion(old.memory_id, user_id="local") is None - assert head.status is MemoryStatus.CONFIRMED - assert head.supersedes == (old.memory_id,) - untouched = supervisor.governance.get(kept.memory_id, user_id="local") - assert untouched.status is MemoryStatus.CONFIRMED - assert untouched.supersedes == untouched.superseded_by == () - assert {m.memory_id for m in supervisor.governance.list(user_id="local")} == { - head.memory_id, - kept.memory_id, - } - - asyncio.run(scenario()) - - -def test_a_chain_fold_supersedes_every_folded_memory(tmp_path, monkeypatch): - monkeypatch.setattr(raw_context, "vector", _vector) - - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, permission): - client, user, agent = await _first_turn(supervisor, records) - monkeypatch.setitem(raw_context.CLAIMS, NEW_TEXT, "user") - await _superseding_turn( - supervisor, records, permission, chain=(agent.memory_id, user.memory_id) - ) - - head = _head(supervisor) - oldest = supervisor.governance.get(agent.memory_id, user_id="local") - newer = supervisor.governance.get(user.memory_id, user_id="local") - assert oldest.status is newer.status is MemoryStatus.SUPERSEDED - assert oldest.superseded_by == (user.memory_id,) - assert newer.superseded_by == (head.memory_id,) - assert newer.supersedes == (agent.memory_id,) - assert head.supersedes == (user.memory_id,) - assert head.status is MemoryStatus.CONFIRMED - assert [m.memory_id for m in supervisor.governance.list(user_id="local")] == [ - head.memory_id - ] - for key in (agent.memory_id, user.memory_id): - native = await on_native_loop(client, client._vector_store.get_by_id(key)) - assert native.status == HyStatus.SUPERSEDED - - asyncio.run(scenario()) - - -async def _turn_after_supersede(supervisor, records, permission): - """The user repeats the new requirement one turn after HY superseded the old one.""" - added = next( - r - for r in supervisor.evidence.list_conversation(records[0].evidence_id) - if r.verbatim == NEW_TEXT - ) - again = put_turn(supervisor.evidence, "user-again", "user", NEW_TEXT, 4) - records = (*records, added, again) - grant_records(supervisor, records, permission.revision + 1) - third = supervisor.prepare_conversation(again)[0] - oracle = ScriptedOracle(records) - bind_oracle(supervisor, oracle) - result = await supervisor._create_runner().run(third.task_id) - assert not oracle.errors, oracle.errors - return result, oracle - - -def test_the_turn_after_a_supersede_still_completes(tmp_path, monkeypatch): - monkeypatch.setattr(raw_context, "vector", _vector) - - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, permission): - _, replaced, _ = await _first_turn(supervisor, records) - monkeypatch.setitem(raw_context.CLAIMS, NEW_TEXT, "user") - await _superseding_turn(supervisor, records, permission) - - # The old memory's evidence is in this turn's window; being - # superseded by HY is not the user changing what was said. - result, oracle = await _turn_after_supersede(supervisor, records, permission) - - assert result.status == "completed", (result.last_error_code, oracle.stages) - old = supervisor.governance.get(replaced.memory_id, user_id="local") - assert old.status is MemoryStatus.SUPERSEDED - - asyncio.run(scenario()) - - -def test_hy_still_sees_the_head_of_a_supersede_chain_on_the_next_turn(tmp_path, monkeypatch): - monkeypatch.setattr(raw_context, "vector", _vector) - - async def scenario(): - async with raw_client(tmp_path) as (supervisor, records, permission): - await _first_turn(supervisor, records) - monkeypatch.setitem(raw_context.CLAIMS, NEW_TEXT, "user") - await _superseding_turn(supervisor, records, permission) - head = _head(supervisor) - - result, oracle = await _turn_after_supersede(supervisor, records, permission) - - assert result.status == "completed", (result.last_error_code, oracle.stages) - existing = {item["memory_id"] for batch in oracle.reconciles for item in batch} - assert head.memory_id in existing, (head.memory_id, oracle.reconciles) - repeated = [ - m - for m in supervisor.governance.list(user_id="local", limit=500) - if m.content == NEW_TEXT - ] - assert [m.memory_id for m in repeated] == [head.memory_id] - - asyncio.run(scenario()) - - -class SupersedingFactOracle(CodingOracle): - """HY's fact reconciler judges the new session to continue the old task.""" - - def __init__(self, records, *, old_fact, **kwargs): - super().__init__(records, **kwargs) - self.old_fact = old_fact - - def respond(self, request): - body = json.loads(request.content) - if request.url.path != "/v1/embeddings": - prompt = body["messages"][-1]["content"] - if prompt.startswith("A user task in a working-memory store has progressed"): - self.requests.append(body) - self.stages.append("chain_summary") - return _completion(body, "The suite moved from pytest to tox.") - response = super().respond(request) - if self.stages[-1] != "fact_reconcile": - return response - return _completion( - body, - json.dumps( - { - "ops": [ - { - "draft_idx": 0, - "supersedes": [self.old_fact], - "reason": "continues the same test-suite task", - } - ] - } - ), - ) - - -def _completion(body, content): - return httpx.Response( - 200, - json={ - "id": "synthetic-coding-response", - "object": "chat.completion", - "created": 1, - "model": body["model"], - "choices": [ - { - "index": 0, - "finish_reason": "stop", - "message": {"role": "assistant", "content": content}, - } - ], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, - }, - ) - - -def test_a_working_fact_hy_supersedes_is_superseded_in_the_ledger(tmp_path): - async def scenario(): - async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, permission): - _, first = await run_coding_turn(supervisor, records[0], CodingOracle(records)) - assert first.status == "completed", first.last_error_code - (old_fact,) = by_kind(supervisor)[MemoryKind.WORKING] - question = put_turn(supervisor.evidence, "user-again", "user", "Use tox now.", 4) - tool = put_turn(supervisor.evidence, "tool-again", "tool", "[Bash] tox\nok", 5) - grant_records(supervisor, (*records, question, tool), permission.revision) - oracle = SupersedingFactOracle( - (question, tool), - old_fact=old_fact.memory_id, - solution="Run `tox` from the repository root.", - statement="The agent switched the synthetic suite to tox.", - ) - _, second = await run_coding_turn(supervisor, question, oracle) - assert second.status == "completed", (second.last_error_code, oracle.stages) - assert "fact_reconcile" in oracle.stages - client = await native_client(supervisor, second) - (native_old,) = await on_native_loop( - client, client._coding_store.get_facts([old_fact.memory_id], user_id="local") - ) - assert native_old.status == "superseded" - - old = supervisor.governance.get(old_fact.memory_id, user_id="local") - assert old.status is MemoryStatus.SUPERSEDED - assert old.superseded_by == (native_old.fact_link,) - new = supervisor.governance.get(native_old.fact_link, user_id="local") - assert new is not None and new.status is MemoryStatus.CONFIRMED - assert new.kind is MemoryKind.WORKING - assert new.supersedes == (old.memory_id,) - working = [ - m.memory_id - for m in supervisor.governance.list(user_id="local") - if m.kind is MemoryKind.WORKING - ] - assert working == [new.memory_id] - - asyncio.run(scenario()) - - -def test_the_chat_store_reports_what_hy_superseded_and_nothing_else(): - head = node("head", supersedes=["old"]) - old = node("old") - kept = node("kept") - backend = Backend(old, kept) - store = wrapper(backend, approved=(old, kept)) - - async def scenario(): - # Positive control and negative in one store: an ordinary update is not reported. - await store.update_payload("kept", {"content": "Synthetic kept, refined"}) - assert store.superseded_nodes == {} - await store.update_payload("old", {"is_latest": False, "status": "superseded"}) - await store.upsert(head) - await store.update_payload("old", {"superseded_by": ["head"]}) - assert store.superseded_nodes == {"old": ("head",)} - assert "old" not in store.retired_node_ids - - asyncio.run(scenario()) - - -def test_a_superseded_link_written_only_on_the_head_is_still_reported(): - old = node("old") - backend = Backend(old) - store = wrapper(backend, approved=(old,)) - - async def scenario(): - await store.update_payload("old", {"is_latest": False, "status": "superseded"}) - await store.upsert(node("head", supersedes=["old"])) - assert store.superseded_nodes == {"old": ("head",)} - - asyncio.run(scenario()) - - -def test_a_basic_profile_supersede_is_reported_for_the_ledger(): - old = node( - "old-profile", - agent_id="", - session_id="", - owner=None, - layer=MemoryLayer.L0_BASIC_INFO, - content="name: Before", - custom={"basic_info_kv": {"name": "Before"}}, - ) - backend = Backend(old) - store = wrapper(backend, approved=(old,)) - - class Embedder: - async def embed_queued(self, text): - return [1.0, 0.0] - - async def scenario(): - result = await upsert_basic_profile( - user_id="local", - kv={"name": "After"}, - vector_store=store, - embed_service=Embedder(), - allowed_fields=["name"], - ) - assert result.success and result.changed - assert store.superseded_nodes == {"old-profile": (result.node_id,)} - - asyncio.run(scenario()) - - -def _fact(key, **kwargs): - return WorkingFact( - fact_id=key, user_id="local", agent_id="agent", session_id="session", **kwargs - ) - - -class _NativeFacts: - def __init__(self, *facts): - self.facts = {fact.fact_id: deepcopy(fact) for fact in facts} - - async def get_facts(self, keys, user_id=None): - return [deepcopy(self.facts[key]) for key in keys if key in self.facts] - - async def supersede_fact(self, old_id, new_id): - row = self.facts[old_id] - row.status, row.fact_link = "superseded", new_id - return True - - async def insert_fact(self, fact): - self.facts[fact.fact_id] = deepcopy(fact) - - -def _coding_store(native, *approved): - return HyPermissionCodingStore( - native, - Lease(), - agent_id="agent", - session_id="session", - node_permissions={ - fact.fact_id: HyNodePermission(hy_coding_fingerprint(fact), ("current", "support")) - for fact in approved - }, - ) - - -def test_the_coding_store_lets_hy_supersede_a_fact_before_inserting_its_successor(): - old = _fact("old-fact") - store = _coding_store(_NativeFacts(old), old) - - async def scenario(): - assert store.superseded_ids == {} - assert await store.supersede_fact("old-fact", "new-fact") - await store.insert_fact(_fact("new-fact")) - store.recheck() - assert store.superseded_ids == {"old-fact": ("new-fact",)} - assert "old-fact" not in store.retired_ids - - asyncio.run(scenario()) - - -def test_the_coding_store_never_links_a_fact_to_an_existing_unapproved_one(): - old, foreign = _fact("old-fact"), _fact("foreign-fact", statement="DENIED") - native = _NativeFacts(old, foreign) - store = _coding_store(native, old) - - async def scenario(): - with pytest.raises(HyMemoryBridgeError, match="target_denied"): - await store.supersede_fact("old-fact", "foreign-fact") - - asyncio.run(scenario()) - assert native.facts["old-fact"].status == "active" diff --git a/memhub/apps/core/tests/test_hy_turn_input.py b/memhub/apps/core/tests/test_hy_turn_input.py deleted file mode 100644 index 55a7dbb..0000000 --- a/memhub/apps/core/tests/test_hy_turn_input.py +++ /dev/null @@ -1,433 +0,0 @@ -"""HY gets one turn at a time, exactly as it happened, and picks its own route. - -A turn is one question from the person plus everything that answered it: -assistant replies and the tool calls made along the way. HY's `add` decides -chat or coding itself when it sees tool messages (`scene=None`); forcing -`scene="chat"` made it strip them before it ever looked. -""" - -from __future__ import annotations - -import asyncio -import json -import sqlite3 -from datetime import UTC, datetime - -from one_memory.hy_memory_bridge import ( - HyMemoryBridge, - HyMemoryIngestRequest, - HyMemoryOutput, - HyMemoryTurn, -) -from one_memory.hy_source_context import HySourceContext, HyWriteBindings -from one_memory.imports.grants import GRANT_SCHEMA -from one_memory.imports.models import WrittenBy -from one_memory.memory_processing import MemoryProcessingSupervisor, _conversation_chunks -from one_memory.memory_tasks import MemoryTaskKind, MemoryTaskStatus -from one_memory.model_profiles import ProviderKind -from tests.test_hy_memory_bridge import FakeHyMemoryClient, snapshot -from tests.test_memory_processing import FakeBridge, _put, _runner, _setup, _task -from tests.test_model_processing_permissions import Harness - - -def _turn(evidence, index: int, *, tool: str | None = None): - records = [ - _put( - evidence, - message_id=f"u{index}", - role="user", - written_by=WrittenBy.PERSON, - content=f"第{index}个问题。", - ), - _put( - evidence, - message_id=f"a{index}", - role="assistant", - written_by=WrittenBy.AGENT, - content=f"第{index}个回答。", - ), - ] - if tool is not None: - records.append( - _put( - evidence, - message_id=f"t{index}", - role="tool", - written_by=WrittenBy.TOOL, - content=tool, - ) - ) - records.append( - _put( - evidence, - message_id=f"a{index}-after-tool", - role="assistant", - written_by=WrittenBy.AGENT, - content=f"第{index}个结论。", - ) - ) - return records - - -def _supervisor(tmp_path, stores, bridge=None): - evidence, profiles, tasks, governance, secrets, _profile = stores - return MemoryProcessingSupervisor( - tasks=tasks, - profiles=profiles, - secrets=secrets, - evidence=evidence, - governance=governance, - data_dir=tmp_path, - runner_factory=lambda: _runner(tmp_path, stores, bridge or FakeBridge(outputs=())), - ) - - -def _sent_turns(tmp_path, stores, created): - bridge = FakeBridge(outputs=()) - runner = _runner(tmp_path, stores, bridge) - for task in created: - asyncio.run(runner.run(task.task_id)) - return [[turn.content for turn in request.turns] for request in bridge.requests] - - -def test_each_turn_goes_to_hy_on_its_own(tmp_path) -> None: - evidence = _setup(tmp_path)[0] - first = _turn(evidence, 1, tool="[Bash] ls") - second = _turn(evidence, 2) - - chunks = _conversation_chunks(evidence.list_conversation(first[0].evidence_id)) - - assert [[r.evidence_id for r in chunk] for chunk in chunks] == [ - [r.evidence_id for r in first], - [r.evidence_id for r in second], - ] - - -def test_what_came_before_the_first_question_joins_the_turn_that_answers_it(tmp_path) -> None: - """An opener with no reply yet is not a turn of its own. - - C3's real-authority drift showed splitting `assistant` proposal from the - user turn that adopts it sends HY an answer without what it answers, so an - unanswered assistant prefix joins the following turn. The prefix is still - sent, once, and in order — it is just no longer a window of its own. - """ - evidence = _setup(tmp_path)[0] - opener = _put( - evidence, - message_id="a0", - role="assistant", - written_by=WrittenBy.AGENT, - content="你好,我能帮你做什么?", - ) - first = _turn(evidence, 1) - - chunks = _conversation_chunks(evidence.list_conversation(opener.evidence_id)) - - assert [[r.evidence_id for r in chunk] for chunk in chunks] == [ - [opener.evidence_id, *[r.evidence_id for r in first]] - ] - - -def test_a_turn_too_long_for_one_window_is_split_but_every_window_keeps_its_question( - tmp_path, monkeypatch -) -> None: - # D74 sends this turn whole; the invariant still holds on the split path. - monkeypatch.setenv("ONE_MEMORY_SPLIT_TURNS", "1") - evidence = _setup(tmp_path)[0] - question = _put( - evidence, - message_id="u1", - role="user", - written_by=WrittenBy.PERSON, - content="把这个仓库的测试跑一遍。", - ) - calls = [ - _put( - evidence, - message_id=f"t{index}", - role="tool", - written_by=WrittenBy.TOOL, - content=f"[Bash] {index} " + "x" * 1500, - ) - for index in range(12) - ] - - chunks = _conversation_chunks(evidence.list_conversation(question.evidence_id)) - - assert len(chunks) > 1 - assert all(chunk[0].evidence_id == question.evidence_id for chunk in chunks) - assert all(sum(len(r.verbatim) for r in chunk) <= 12_000 for chunk in chunks) - assert {r.evidence_id for chunk in chunks for r in chunk} == { - question.evidence_id, - *(call.evidence_id for call in calls), - } - - -def test_a_split_turn_never_sends_a_tool_only_window(tmp_path, monkeypatch) -> None: - """WorkBuddy/Codex can answer, then emit many tool calls. - - If the answer fills the first window, the remaining calls used to become a - tool-only window; HY rejected it with ``content or messages is required``. - The tool calls must stay with the preceding non-tool window instead. - D74 sends this turn whole; the invariant still holds on the split path. - """ - monkeypatch.setenv("ONE_MEMORY_SPLIT_TURNS", "1") - evidence = _setup(tmp_path)[0] - question = _put( - evidence, - message_id="u1", - role="user", - written_by=WrittenBy.PERSON, - content="先回答,再调用很多工具。", - ) - answer = _put( - evidence, - message_id="a1", - role="assistant", - written_by=WrittenBy.AGENT, - content="好。" + "z" * 7_000, - ) - calls = [ - _put( - evidence, - message_id=f"t{index}", - role="tool", - written_by=WrittenBy.TOOL, - content=f"[Bash] {index} " + "x" * 1_500, - ) - for index in range(4) - ] - - chunks = _conversation_chunks(evidence.list_conversation(question.evidence_id)) - - assert chunks - assert all(any(record.role != "tool" for record in chunk) for chunk in chunks) - assert all(sum(len(record.verbatim) for record in chunk) <= 12_000 for chunk in chunks) - assert list(dict.fromkeys(record.evidence_id for chunk in chunks for record in chunk)) == [ - question.evidence_id, - answer.evidence_id, - *(call.evidence_id for call in calls), - ] - assert any(record.role == "tool" for chunk in chunks for record in chunk) - - -def test_tool_calls_travel_with_their_turn_redacted_and_as_tool_messages(tmp_path) -> None: - stores = _setup(tmp_path) - evidence, _profiles, tasks, _governance, _secrets, profile = stores - records = _turn( - evidence, 1, tool="[Bash] docker login --username kristinhe --password exposed-token-value" - ) - bridge = FakeBridge() - - asyncio.run(_runner(tmp_path, stores, bridge).run(_task(tasks, profile, records[0]).task_id)) - - request = bridge.requests[0] - assert [turn.role for turn in request.turns] == ["user", "assistant", "tool", "assistant"] - assert "exposed-token-value" not in request.turns[2].content - assert "" in request.turns[2].content - assert records[2].evidence_id in request.evidence_ids - - -def test_the_next_turn_reaches_hy_with_the_previous_turn_as_history(tmp_path) -> None: - stores = _setup(tmp_path) - evidence, _profiles, _tasks, _governance, _secrets, profile = stores - with sqlite3.connect(tmp_path / "one-memory.sqlite3") as db: - db.executescript(GRANT_SCHEMA) - db.execute( - "INSERT INTO source_grants VALUES ('local','cursor','/authorized','default',?,NULL)", - (datetime.now(UTC).isoformat(),), - ) - first = _turn(evidence, 1, tool="[Bash] 12 passed") - second = _turn(evidence, 2) - supervisor = _supervisor(tmp_path, stores) - bridge = FakeBridge(outputs=()) - runner = _runner(tmp_path, stores, bridge) - - for turn in (first, second): - task = supervisor._enqueue_chunk(tuple(turn), profile, freeze_inputs=True) - asyncio.run(runner.run(task.task_id)) - - context = bridge.requests[1].source_context - assert context is not None - assert [r.evidence_id for r in context.current] == [r.evidence_id for r in second] - assert [r.evidence_id for r in context.history] == [r.evidence_id for r in first] - - -def test_each_later_turn_carries_the_earlier_turns_as_history(tmp_path) -> None: - """Later windows in one conversation must not arrive at HY alone. - - The frozen live pair expects later batches to grow because a window is sent - with the turns behind it as history. A run where every request holds only the - current turn loses the context the authority/adoption gate reads, which is - what the cross-window real run showed. - """ - stores = _setup(tmp_path) - evidence, _profiles, _tasks, _governance, _secrets, profile = stores - with sqlite3.connect(tmp_path / "one-memory.sqlite3") as db: - db.executescript(GRANT_SCHEMA) - db.execute( - "INSERT INTO source_grants VALUES ('local','cursor','/authorized','default',?,NULL)", - (datetime.now(UTC).isoformat(),), - ) - turns = [_turn(evidence, index) for index in (1, 2, 3)] - supervisor = _supervisor(tmp_path, stores) - bridge = FakeBridge(outputs=()) - runner = _runner(tmp_path, stores, bridge) - - for turn in turns: - task = supervisor._enqueue_chunk(tuple(turn), profile, freeze_inputs=True) - asyncio.run(runner.run(task.task_id)) - - assert len(bridge.requests) == 3 - for index, request in enumerate(bridge.requests): - context = request.source_context - assert context is not None - assert [r.evidence_id for r in context.current] == [r.evidence_id for r in turns[index]] - # Every turn after the first is sent with the ones behind it. - expected = [r.evidence_id for turn in turns[:index] for r in turn] - assert [r.evidence_id for r in context.history] == expected - - -def test_the_bridge_hands_hy_the_turn_and_lets_hy_choose_the_route(tmp_path) -> None: - client = FakeHyMemoryClient({"success": True, "memory_ids": []}) - bridge = HyMemoryBridge( - profile=snapshot(ProviderKind.OLLAMA, base_url="http://127.0.0.1:11434", has_api_key=False), - api_key=None, - data_dir=tmp_path, - client=client, - ) - request = HyMemoryIngestRequest( - request_id="turn-1", - user_id="local", - source_id="codex", - session_id="conversation-1", - turns=( - HyMemoryTurn(role="user", content="跑一下测试"), - HyMemoryTurn(role="tool", content="[Bash] 12 passed"), - HyMemoryTurn(role="assistant", content="全部通过。"), - ), - evidence_ids=("u", "t", "a"), - ) - - asyncio.run(bridge.ingest(request)) - asyncio.run(bridge.close()) - - assert client.calls[0]["scene"] is None - assert [item["role"] for item in client.calls[0]["data"]] == ["user", "tool", "assistant"] - - -def test_write_verification_accepts_the_tool_messages_hy_extracts_from(tmp_path) -> None: - evidence = _setup(tmp_path)[0] - question, _reply, call, _conclusion = _turn(evidence, 1, tool="[Bash] 12 passed") - bindings = HyWriteBindings(HySourceContext(current=(question, call))) - - bindings.check_content( - json.dumps( - [ - {"role": "user", "content": question.verbatim}, - {"role": "tool", "content": call.verbatim}, - ], - ensure_ascii=False, - ) - ) - - assert [source["role"] for source in bindings.sources] == ["user", "tool"] - - -def test_every_turn_gets_its_own_task_when_nothing_was_queued_before(tmp_path) -> None: - stores = _setup(tmp_path) - evidence = stores[0] - turns = [_turn(evidence, index) for index in (1, 2, 3)] - - created = _supervisor(tmp_path, stores).prepare_conversation(turns[2][0]) - - assert _sent_turns(tmp_path, stores, created) == [[r.verbatim for r in turn] for turn in turns] - - -def _earlier_policy_window(stores, records, *, succeeded: bool): - _evidence, _profiles, tasks, _governance, _secrets, profile = stores - task = tasks.create( - request_id=f"conversation:hy-native-v22:cursor:{records[0].evidence_id}", - user_id=records[0].user_id, - kind=MemoryTaskKind.EXTRACT, - input_ref=records[0].evidence_id, - profile=profile, - input_records=tuple(records), - ) - _claimed, token = tasks.claim(task.task_id) - if succeeded: - tasks.complete(task.task_id, runner_token=token) - else: - tasks.fail(task.task_id, runner_token=token, error_code="hy_memory_unavailable") - return tasks.require(task.task_id) - - -def test_turns_an_earlier_policy_already_extracted_are_not_sent_again(tmp_path) -> None: - stores = _setup(tmp_path) - evidence = stores[0] - old = [*_turn(evidence, 1), *_turn(evidence, 2)] - assert _earlier_policy_window(stores, old, succeeded=True).status is ( - MemoryTaskStatus.COMPLETED - ) - new = _turn(evidence, 3) - - created = _supervisor(tmp_path, stores).prepare_conversation(new[0]) - - assert _sent_turns(tmp_path, stores, created) == [[r.verbatim for r in new]] - - -def test_renewed_consent_still_prepares_a_turn_the_current_policy_completed(tmp_path) -> None: - h = Harness(tmp_path) - h.bridge.fail = False - turn = h.put_closed_turn("selected", conversation="renewed-consent") - h.grant(turn) - supervisor = h.supervisor() - - async def scenario(): - try: - first = supervisor.prepare_conversation(turn[0])[0] - done = await supervisor._create_runner().run(first.task_id) - assert done.status is MemoryTaskStatus.COMPLETED - h.grant(turn) - return first, supervisor.prepare_conversation(turn[0]) - finally: - await supervisor.shutdown() - - first, again = asyncio.run(scenario()) - - assert len(again) == 1 and again[0].task_id != first.task_id - - -def test_a_failed_earlier_window_does_not_count_as_already_sent(tmp_path) -> None: - stores = _setup(tmp_path) - old = _turn(stores[0], 1) - assert _earlier_policy_window(stores, old, succeeded=False).status is MemoryTaskStatus.FAILED - - created = _supervisor(tmp_path, stores).prepare_conversation(old[0]) - - assert _sent_turns(tmp_path, stores, created) == [[r.verbatim for r in old]] - - -def test_a_tool_heavy_turn_reaches_hy_once_and_still_becomes_a_memory(tmp_path) -> None: - """D74's full criterion: one call to HY, and a memory comes out of it. - - The half that was only ever checked by hand is the second one. On 17:14 - the acceptance run for this ended with `completed=false` and zero - evidence, so nothing proved the memory half either way; the window count - alone leaves it open. - """ - stores = _setup(tmp_path) - evidence, _profiles, tasks, governance, _secrets, profile = stores - records = _turn(evidence, 1, tool="x" * 50_000) - task = _task(tasks, profile, records[0]) - bridge = FakeBridge( - outputs=(HyMemoryOutput(memory_id="hy-tool-1", content="用户跑过一次全量测试。"),) - ) - - asyncio.run(_runner(tmp_path, stores, bridge).run(task.task_id)) - - # One window, so one call. - assert len(_conversation_chunks(records)) == 1 - assert len(bridge.requests) == 1 - # And the memory that call produced actually landed. - assert [item.content for item in governance.list(user_id="local")] == ["用户跑过一次全量测试。"] diff --git a/memhub/apps/core/tests/test_incremental_memory_backfill.py b/memhub/apps/core/tests/test_incremental_memory_backfill.py deleted file mode 100644 index c738856..0000000 --- a/memhub/apps/core/tests/test_incremental_memory_backfill.py +++ /dev/null @@ -1,672 +0,0 @@ -import asyncio -import threading -from contextlib import suppress - -import pytest - -from one_memory.imports.models import EvidenceContentKind, WrittenBy -from one_memory.memory_processing import MemoryProcessingSupervisor, _merge_historical_chunks -from one_memory.memory_tasks import MemoryTaskStatus -from one_memory.model_profiles import ModelProfilePatch -from tests.test_memory_processing import _put, _setup -from tests.test_model_processing_permissions import Harness - - -def setup(tmp_path, count=3): - evidence, profiles, tasks, governance, secrets, _profile = _setup(tmp_path) - records = [ - _put( - evidence, - message_id=f"message-{index}", - conversation_id=f"conversation-{index}", - role="user", - written_by=WrittenBy.PERSON, - content=f"Synthetic conversation {index}", - content_kind=EvidenceContentKind.MEMORY_CONTENT, - ) - for index in range(count) - ] - supervisor = MemoryProcessingSupervisor( - tasks=tasks, - profiles=profiles, - secrets=secrets, - evidence=evidence, - governance=governance, - data_dir=tmp_path, - ) - return supervisor, records - - -def test_accept_backfill_returns_after_first_batch_while_later_history_is_still_read( - tmp_path, monkeypatch -): - monkeypatch.setattr("one_memory.memory_processing._BACKFILL_START_GRACE_SECONDS", 0) - - async def scenario(): - supervisor, records = setup(tmp_path) - later_entered, release = threading.Event(), threading.Event() - scheduled = [] - reads = [] - original = supervisor.evidence.list_conversation - - def blocked(evidence_id): - reads.append(evidence_id) - if len(reads) == 2: - later_entered.set() - assert release.wait(5) - return original(evidence_id) - - monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked) - monkeypatch.setattr( - supervisor, "_queue_validated_work", lambda work: scheduled.extend(work) - ) - try: - accepted = asyncio.create_task(supervisor.accept_backfill_off_thread()) - assert await asyncio.to_thread(later_entered.wait, 3) - await asyncio.wait_for(accepted, 1) - assert len(scheduled) == 1 - assert accepted.done() - assert supervisor._live_backfill is not None - assert not supervisor._live_backfill.done() - finally: - release.set() - if supervisor._live_backfill is not None: - await supervisor._live_backfill - await supervisor.shutdown() - assert len(scheduled) == len(records) - - asyncio.run(scenario()) - - -def test_first_task_is_dispatched_before_later_conversation_is_prepared(tmp_path, monkeypatch): - async def scenario(): - supervisor, records = setup(tmp_path) - later_entered, release = threading.Event(), threading.Event() - scheduled = [] - reads = [] - original = supervisor.evidence.list_conversation - - def blocked(evidence_id): - reads.append(evidence_id) - if len(reads) == 2: - later_entered.set() - assert release.wait(5) - return original(evidence_id) - - monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked) - monkeypatch.setattr( - supervisor, "_queue_validated_work", lambda work: scheduled.extend(work) - ) - task = asyncio.create_task(supervisor.backfill_off_thread()) - try: - assert await asyncio.to_thread(later_entered.wait, 3) - assert len(scheduled) == 1, "first task still waits behind later conversation reads" - assert supervisor.tasks.require(scheduled[0].task_id).input_ref in { - record.evidence_id for record in records - } - finally: - release.set() - await task - await supervisor.shutdown() - assert len(scheduled) == len(records) - assert {supervisor.tasks.require(item.task_id).input_ref for item in scheduled} == { - record.evidence_id for record in records - } - - asyncio.run(scenario()) - - -def test_cancellation_joins_the_preparation_thread_before_return(tmp_path, monkeypatch): - async def scenario(): - supervisor, _records = setup(tmp_path) - entered, release, exited = threading.Event(), threading.Event(), threading.Event() - original = supervisor.evidence.list_conversation - scheduled = [] - - def blocked(evidence_id): - entered.set() - assert release.wait(5) - result = original(evidence_id) - exited.set() - return result - - monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked) - monkeypatch.setattr( - supervisor, "_queue_validated_work", lambda work: scheduled.extend(work) - ) - task = asyncio.create_task(supervisor.backfill_off_thread()) - try: - assert await asyncio.to_thread(entered.wait, 3) - task.cancel() - await asyncio.sleep(0.03) - task.cancel() - await asyncio.sleep(0.03) - assert not task.done(), "backfill abandoned a thread that can still write tasks" - finally: - release.set() - with suppress(asyncio.CancelledError): - await task - await supervisor.shutdown() - assert exited.is_set() - assert scheduled == [] - - asyncio.run(scenario()) - - -def test_later_failure_keeps_the_first_committed_batch_visible(tmp_path, monkeypatch): - async def scenario(): - supervisor, _records = setup(tmp_path) - scheduled, reads = [], [] - original = supervisor.evidence.list_conversation - - def broken(evidence_id): - reads.append(evidence_id) - if len(reads) == 2: - raise RuntimeError("synthetic later read failure") - return original(evidence_id) - - monkeypatch.setattr(supervisor.evidence, "list_conversation", broken) - monkeypatch.setattr( - supervisor, "_queue_validated_work", lambda work: scheduled.extend(work) - ) - try: - with pytest.raises(RuntimeError, match="synthetic later"): - await supervisor.backfill_off_thread() - assert len(scheduled) == 1 - assert len(supervisor.tasks.list()) == 1 - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("change", ["revoke", "regrant"]) -def test_permission_change_between_batches_stops_without_new_tasks(tmp_path, monkeypatch, change): - async def scenario(): - h = Harness(tmp_path) - records = [ - h.put("first", conversation="first", memory_content=True), - h.put("second", conversation="second", memory_content=True), - ] - receipt = h.grant(records) - supervisor = h.supervisor() - scheduled = [] - - def dispatch(work): - scheduled.extend(work) - if len(scheduled) == 1: - if change == "revoke": - h.permissions.revoke( - user_id="local", user_confirmed=True, expected_revision=receipt.revision - ) - else: - h.grant(records) - - monkeypatch.setattr(supervisor, "_queue_validated_work", dispatch) - try: - with pytest.raises(RuntimeError, match="model_content_permission_"): - await supervisor.backfill_off_thread(expected_permission_revision=receipt.revision) - assert len(h.tasks.list()) == len(scheduled) == 1 - assert h.factory_calls == [] and h.bridge.calls == [] - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_profile_change_after_first_batch_preserves_old_task_identity(tmp_path, monkeypatch): - async def scenario(): - h = Harness(tmp_path) - h.grant( - [ - h.put("first", conversation="first", memory_content=True), - h.put("second", conversation="second", memory_content=True), - ] - ) - supervisor = h.supervisor() - scheduled = [] - - def dispatch(work): - scheduled.extend(work) - h.profiles.update( - h.profile.profile_id, - ModelProfilePatch(model="changed-model", expected_revision=h.profile.revision), - h.secrets, - ) - - monkeypatch.setattr(supervisor, "_queue_validated_work", dispatch) - try: - with pytest.raises(RuntimeError, match="model_content_profile_changed"): - await supervisor.backfill_off_thread() - assert len(h.tasks.list()) == len(scheduled) == 1 - assert h.tasks.list()[0].profile_revision == h.profile.revision - assert h.factory_calls == [] - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_batched_and_sync_preparation_keep_all_task_inputs_and_idempotency(tmp_path, monkeypatch): - async def scenario(): - supervisor, records = setup(tmp_path, count=21) - scheduled = [] - monkeypatch.setattr( - supervisor, "_queue_validated_work", lambda batch: scheduled.append(batch) - ) - try: - expected = supervisor.prepare_backfill() - expected_ids = {work.task_id for work in expected} - assert len(expected_ids) == 21 - before = { - record.evidence_id: supervisor.evidence.get(record.evidence_id) - for record in records - } - await supervisor.backfill_off_thread() - assert len(scheduled[0]) == 1 - assert all(len(batch) <= 8 for batch in scheduled) - assert {work.task_id for batch in scheduled for work in batch} == expected_ids - assert len(supervisor.tasks.list()) == 21 - assert { - record.evidence_id: supervisor.evidence.get(record.evidence_id) - for record in records - } == before - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_history_backfill_merges_adjacent_unsent_turns_in_one_conversation(tmp_path): - supervisor, records = setup(tmp_path, count=0) - profile = supervisor.profiles.active(supervisor.secrets) - assert profile is not None - conversation = [] - for index in range(2): - conversation.extend( - ( - _put( - supervisor.evidence, - message_id=f"user-{index}", - role="user", - written_by=WrittenBy.PERSON, - content=f"问题 {index}", - conversation_id="shared-history", - ), - _put( - supervisor.evidence, - message_id=f"assistant-{index}", - role="assistant", - written_by=WrittenBy.AGENT, - content=f"回答 {index}", - conversation_id="shared-history", - ), - ) - ) - - chunks = supervisor._prepare_conversation_chunks(conversation, profile, None) - merged = _merge_historical_chunks(chunks) - - assert len(chunks) == 2 - assert len(merged) == 1 - assert {record.evidence_id for record in merged[0]} == { - record.evidence_id for record in conversation - } - - -def test_backfill_holds_an_unanswered_raw_turn_until_its_reply_arrives(tmp_path): - supervisor, _records = setup(tmp_path, count=0) - question = _put( - supervisor.evidence, - message_id="open-question", - role="user", - written_by=WrittenBy.PERSON, - content="发布包上传了吗?", - conversation_id="open-turn", - ) - - assert supervisor.prepare_conversation(question) == [] - assert supervisor.prepare_backfill() == [] - - answer = _put( - supervisor.evidence, - message_id="closing-answer", - role="assistant", - written_by=WrittenBy.AGENT, - content="已经上传完成。", - conversation_id="open-turn", - ) - - prepared = supervisor.prepare_conversation(answer) - - assert len(prepared) == 1 - assert prepared[0].input_ref == question.evidence_id - - -def test_history_merge_supersedes_an_already_queued_shorter_session_task(tmp_path): - supervisor, _records = setup(tmp_path, count=0) - first = _put( - supervisor.evidence, - message_id="merge-user-1", - role="user", - written_by=WrittenBy.PERSON, - content="第一问", - conversation_id="growing-history", - ) - first_reply = _put( - supervisor.evidence, - message_id="merge-assistant-1", - role="assistant", - written_by=WrittenBy.AGENT, - content="第一答", - conversation_id="growing-history", - ) - profile = supervisor.profiles.active(supervisor.secrets) - assert profile is not None - old = supervisor._enqueue_chunk((first, first_reply), profile, freeze_inputs=True) - second = _put( - supervisor.evidence, - message_id="merge-user-2", - role="user", - written_by=WrittenBy.PERSON, - content="第二问", - conversation_id="growing-history", - ) - second_reply = _put( - supervisor.evidence, - message_id="merge-assistant-2", - role="assistant", - written_by=WrittenBy.AGENT, - content="第二答", - conversation_id="growing-history", - ) - - chunks = supervisor._prepare_conversation_chunks( - supervisor.evidence.list_conversation(second.evidence_id), profile, None - ) - merged_chunk = _merge_historical_chunks(chunks)[0] - merged_task = supervisor._enqueue_chunk(merged_chunk, profile, freeze_inputs=True) - - pending = supervisor.tasks.list(statuses=(MemoryTaskStatus.PENDING,)) - merged = next(task for task in supervisor.tasks.list() if task.task_id != old.task_id) - assert supervisor.tasks.require(old.task_id).status.value == "cancelled" - assert [task.task_id for task in pending] == [merged.task_id] - assert {ref["evidence_id"] for ref in supervisor.tasks.inputs(merged.task_id)} == { - first.evidence_id, - first_reply.evidence_id, - second.evidence_id, - second_reply.evidence_id, - } - assert merged_task.task_id == merged.task_id - - -def test_backfill_budget_yields_between_rounds_and_resumes_same_cursor(tmp_path, monkeypatch): - async def scenario(): - supervisor, records = setup(tmp_path, count=3) - scheduled = [] - clock = iter((0.0, 0.0, 331.0, 331.0, 331.0, 662.0, 662.0, 662.0)) - supervisor._backfill_clock = lambda: next(clock) - supervisor._backfill_round_budget_seconds = 330.0 - releases = [] - - async def schedule(work): - assert supervisor._backfill_lock.locked() - scheduled.extend(work) - - original_sleep = asyncio.sleep - - async def observed_sleep(delay): - if delay == 0: - releases.append(not supervisor._backfill_lock.locked()) - await original_sleep(0) - - monkeypatch.setattr(supervisor, "_schedule_tasks_off_thread", schedule) - monkeypatch.setattr("one_memory.memory_processing.asyncio.sleep", observed_sleep) - try: - await supervisor.backfill_off_thread() - finally: - await supervisor.shutdown() - - assert releases == [True, True] - assert len(scheduled) == len(records) - assert len({work.task_id for work in scheduled}) == len(records) - - asyncio.run(scenario()) - - -def test_shutdown_waits_for_backfill_preparation_and_never_dispatches_tail(tmp_path, monkeypatch): - async def scenario(): - supervisor, _records = setup(tmp_path) - entered, release = threading.Event(), threading.Event() - scheduled = [] - original = supervisor.evidence.list_conversation - - def blocked(evidence_id): - entered.set() - assert release.wait(5) - return original(evidence_id) - - monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked) - monkeypatch.setattr( - supervisor, "_queue_validated_work", lambda work: scheduled.extend(work) - ) - task = asyncio.create_task(supervisor.backfill_off_thread()) - try: - assert await asyncio.to_thread(entered.wait, 3) - stopping = asyncio.create_task(supervisor.shutdown()) - await asyncio.sleep(0.03) - assert not stopping.done() - finally: - release.set() - await task - await stopping - assert scheduled == [] - assert supervisor._backfill_preparations == set() - assert supervisor.tasks.list() == [] - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("count", [0, 1, 8, 9, 18]) -def test_empty_and_batch_boundaries_preserve_all_inputs(tmp_path, monkeypatch, count): - async def scenario(): - supervisor, records = setup(tmp_path, count=count) - scheduled = [] - monkeypatch.setattr( - supervisor, "_queue_validated_work", lambda work: scheduled.extend(work) - ) - active_calls = [] - active = supervisor.profiles.active - - def counted(secrets): - active_calls.append(True) - return active(secrets) - - monkeypatch.setattr(supervisor.profiles, "active", counted) - try: - await supervisor.backfill_off_thread() - assert len(active_calls) == 1, "batch identity checks should not reaccess credentials" - assert {supervisor.tasks.require(work.task_id).input_ref for work in scheduled} == { - record.evidence_id for record in records - } - assert len(supervisor.tasks.list()) == count - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_empty_denied_batches_do_not_end_the_remaining_traversal(tmp_path, monkeypatch): - async def scenario(): - supervisor, records = setup(tmp_path, count=19) - scheduled = [] - original = supervisor.evidence.list_conversation - earliest = records[0].evidence_id - monkeypatch.setattr( - supervisor.evidence, - "list_conversation", - lambda identity: original(identity) if identity == earliest else [], - ) - monkeypatch.setattr( - supervisor, "_queue_validated_work", lambda work: scheduled.append(work) - ) - try: - await supervisor.backfill_off_thread() - assert scheduled[:2] == [[], []] - useful = [work for batch in scheduled for work in batch] - assert len(useful) == 1 - assert supervisor.tasks.require(useful[0].task_id).input_ref == earliest - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_replacement_backfill_waits_for_cancelled_writer_and_remains_idempotent( - tmp_path, monkeypatch -): - async def scenario(): - supervisor, records = setup(tmp_path) - entered, release = threading.Event(), threading.Event() - reads, scheduled = [], [] - original = supervisor.evidence.list_conversation - - def blocked(evidence_id): - reads.append(evidence_id) - if len(reads) == 1: - entered.set() - assert release.wait(5) - return original(evidence_id) - - monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked) - monkeypatch.setattr( - supervisor, "_queue_validated_work", lambda work: scheduled.extend(work) - ) - old = asyncio.create_task(supervisor.backfill_off_thread()) - try: - assert await asyncio.to_thread(entered.wait, 3) - old.cancel() - replacement = asyncio.create_task(supervisor.backfill_off_thread()) - await asyncio.sleep(0.03) - assert not old.done() and not replacement.done() - assert len(reads) == 1 - finally: - release.set() - with suppress(asyncio.CancelledError): - await old - await replacement - await supervisor.shutdown() - assert len(supervisor.tasks.list()) == len(records) - assert len({work.task_id for work in scheduled}) == len(scheduled) == len(records) - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("unclaimable", ["completed", "deferred"]) -def test_unclaimable_first_seed_does_not_delay_first_useful_batch( - tmp_path, monkeypatch, unclaimable -): - async def scenario(): - supervisor, records = setup(tmp_path) - first = supervisor.prepare_conversation(records[-1])[0] - if unclaimable == "completed": - _, token = supervisor.tasks.claim(first.task_id) - supervisor.tasks.complete(first.task_id, runner_token=token) - else: - supervisor.tasks.defer_outside_window(first.task_id) - scheduled, reads = [], [] - entered, release = threading.Event(), threading.Event() - original = supervisor.evidence.list_conversation - - def blocked(identity): - reads.append(identity) - if len(reads) == 3: - entered.set() - assert release.wait(5) - return original(identity) - - monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked) - monkeypatch.setattr( - supervisor, "_queue_validated_work", lambda work: scheduled.extend(work) - ) - task = asyncio.create_task(supervisor.backfill_off_thread()) - try: - assert await asyncio.to_thread(entered.wait, 3) - assert len(scheduled) == 1 - assert scheduled[0].task_id != first.task_id - finally: - release.set() - await task - await supervisor.shutdown() - - asyncio.run(scenario()) - - -def test_cancelled_shutdown_waits_for_inflight_generator_and_preserves_cleanup( - tmp_path, monkeypatch -): - async def scenario(): - supervisor, _records = setup(tmp_path) - entered, release = threading.Event(), threading.Event() - original = supervisor.evidence.list_conversation - - def blocked(identity): - entered.set() - assert release.wait(5) - return original(identity) - - monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked) - backfill = asyncio.create_task(supervisor.backfill_off_thread()) - try: - assert await asyncio.to_thread(entered.wait, 3) - stopping = asyncio.create_task(supervisor.shutdown()) - await asyncio.sleep(0.02) - stopping.cancel() - await asyncio.sleep(0.02) - stopping.cancel() - await asyncio.sleep(0.02) - assert not stopping.done() and not backfill.done() - assert len(supervisor._backfill_preparations) == 1 - finally: - release.set() - await asyncio.gather(stopping, backfill, return_exceptions=True) - assert not backfill.cancelled() and backfill.exception() is None - assert supervisor._backfill_preparations == set() - assert supervisor.tasks.list() == [] - - asyncio.run(scenario()) - - -def test_exact_backfill_keeps_granted_prefix_when_same_conversation_grows(tmp_path): - async def scenario(): - h = Harness(tmp_path) - first = h.put("first", memory_content=True) - second = h.put("second", memory_content=True) - receipt = h.grant([first, second]) - later = h.put("later", memory_content=True) - other = h.put("other", conversation="other", memory_content=True) - supervisor = h.supervisor() - try: - await supervisor.backfill_off_thread(expected_permission_revision=receipt.revision) - tasks = supervisor.tasks.list() - # One task per turn; both granted turns, nothing after them. - assert len(tasks) == 1 - assert {task.input_ref for task in tasks} == {second.evidence_id} - refs = { - item["evidence_id"] - for task in tasks - for item in supervisor.tasks.inputs(task.task_id) - } - assert {first.evidence_id, second.evidence_id} <= refs - assert later.evidence_id not in refs - assert other.evidence_id not in refs - # The first turn may already be running; whatever reached the model was granted. - assert all( - {record.evidence_id for record in lease.records} - <= {first.evidence_id, second.evidence_id} - for lease, _data_dir in h.factory_calls - ) - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_manual_memory_via_hy.py b/memhub/apps/core/tests/test_manual_memory_via_hy.py deleted file mode 100644 index b9926f3..0000000 --- a/memhub/apps/core/tests/test_manual_memory_via_hy.py +++ /dev/null @@ -1,460 +0,0 @@ -"""T04: what the person explicitly asks to remember is written through HY's import path. - -Whole native HY client behind the production permission context; only the model -endpoints are scripted through an httpx MockTransport, never a network. -""" - -import asyncio -import hashlib -import json -import sqlite3 -from contextlib import asynccontextmanager, closing -from datetime import UTC, datetime -from uuid import uuid4 - -import httpx -import pytest -from httpx import ASGITransport, AsyncClient -from pydantic import SecretStr - -from one_memory import continuous_policy, ingest_journal -from one_memory.api import create_app -from one_memory.config import Settings -from one_memory.hy_memory_bridge import HyMemoryIngestResult, HyMemoryOutput -from one_memory.hy_permission_context import permission_bridge_factory -from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy -from one_memory.matters.selection import select_evidence -from one_memory.memory_notes import SOURCE_ID -from one_memory.model_profiles import ModelCapabilities, ModelProfileInput, ProviderKind -from one_memory.secrets import InMemorySecretStore -from tests.source_consent import confirm_model_content -from tests.test_memory_processing import FakeBridge, _runner, _setup, _task - -TEXT = "请记住:项目周会固定不排在周五。" - - -def _vector(text: str) -> list[float]: - digest = hashlib.sha256(text.encode()).digest() - values = [float(byte) + 1.0 for byte in digest * 12] - norm = sum(value * value for value in values) ** 0.5 - return [value / norm for value in values] - - -class ScriptedModel: - """The model endpoints HY reaches. Reconcile says "nothing to change" unless told.""" - - def __init__(self) -> None: - self.reconcile_prompts: list[str] = [] - self.other_prompts: list[str] = [] - self.embeddings_down = False - - def __call__(self, request: httpx.Request) -> httpx.Response: - body = json.loads(request.content) - if request.url.path.endswith("embeddings"): - if self.embeddings_down: - return httpx.Response(503, json={"error": "down"}) - return httpx.Response( - 200, - json={ - "object": "list", - "model": "test-embedding", - "data": [ - {"index": i, "object": "embedding", "embedding": _vector(text)} - for i, text in enumerate(body["input"]) - ], - "usage": {"prompt_tokens": 1, "total_tokens": 1}, - }, - ) - prompt = "\n".join(str(message.get("content")) for message in body["messages"]) - reconcile = "Existing memories" in prompt - (self.reconcile_prompts if reconcile else self.other_prompts).append(prompt) - # Extraction or reconcile: the model finds nothing worth writing. - content = "[]" if reconcile else '{"memory": [], "identity": [], "intentions": []}' - return httpx.Response( - 200, - json={ - "id": "scripted", - "object": "chat.completion", - "created": 1, - "model": "test-generation", - "choices": [ - { - "index": 0, - "finish_reason": "stop", - "message": {"role": "assistant", "content": content}, - } - ], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, - }, - ) - - -@pytest.fixture -def hy_calls(monkeypatch): - from hy_memory.client import HyMemoryClient - - calls: list[tuple[str, object]] = [] - original_add = HyMemoryClient.add - original_extracted = HyMemoryClient.add_extracted - - def add(self, data, **kwargs): - calls.append(("add", [turn["content"] for turn in data])) - return original_add(self, data, **kwargs) - - def add_extracted(self, contents, **kwargs): - raw = original_extracted(self, contents, **kwargs) - calls.append(("add_extracted", {"contents": list(contents), "raw": raw})) - return raw - - monkeypatch.setattr(HyMemoryClient, "add", add) - monkeypatch.setattr(HyMemoryClient, "add_extracted", add_extracted) - return calls - - -def _application(tmp_path): - return create_app( - Settings( - data_dir=tmp_path / "data", - source_home=tmp_path, - api_token=SecretStr("t04"), - imports_enabled=False, - automatic_memory_processing=False, - live_sync_enabled=False, - ), - secret_store=InMemorySecretStore(), - ) - - -async def _configure(app, model: ScriptedModel) -> None: - profiles, secrets = app.state.model_profile_store, app.state.secret_store - profile = profiles.create( - ModelProfileInput( - name="Scripted", - provider_kind=ProviderKind.OLLAMA, - base_url="http://127.0.0.1:1/v1", - model="test-generation", - embedding_provider_kind=ProviderKind.OLLAMA, - embedding_base_url="http://127.0.0.1:2/v1", - embedding_model="test-embedding", - ), - secrets, - ) - profiles.record_capabilities( - profile.profile_id, - expected_revision=profile.revision, - capabilities=ModelCapabilities( - endpoint_reachable=True, model_available=True, embedding_available=True - ), - secrets=secrets, - ) - profiles.activate(profile.profile_id, secrets) - processor = app.state.memory_processing_supervisor - processor.permission_bridge_factory = permission_bridge_factory( - processor._bridge, http_transport=httpx.MockTransport(model) - ) - - -async def _remember(app, client, text: str) -> dict: - saved = ( - await client.post( - "/api/v1/memory-notes", - json={"request_id": str(uuid4()), "text": text, "scope": "all-apps"}, - ) - ).json() - await confirm_model_content( - client, - source_ids=("one-memory-manual",), - evidence_ids=(saved["evidence_id"],), - future_additions=False, - ) - started = await client.post(f"/api/v1/memory-notes/{saved['evidence_id']}/process") - assert started.status_code == 200, started.text - await _wait_for_note_job(app, saved["evidence_id"]) - return (await client.get(f"/api/v1/memory-notes/{saved['evidence_id']}")).json() - - -async def _wait_for_note_job(app, evidence_id: str, *, timeout: float = 10) -> str: - """Wait for the production durable note job, not the legacy worker map.""" - deadline = asyncio.get_running_loop().time() + timeout - key = f"entry:{evidence_id}" - while asyncio.get_running_loop().time() < deadline: - with sqlite3.connect(app.state.evidence_store.database_path) as db: - row = db.execute( - "SELECT state FROM memory_write_jobs WHERE idempotency_key=?", - (key,), - ).fetchone() - if row is not None and row[0] not in {"pending", "running"}: - return str(row[0]) - await asyncio.sleep(0.05) - raise AssertionError(f"note job for {evidence_id} never settled") - - -def _client(app): - return AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - headers={"Authorization": "Bearer t04"}, - ) - - -def test_note_is_written_through_hy_import_even_when_the_model_would_extract_nothing( - tmp_path, hy_calls -): - async def scenario(): - app, model = _application(tmp_path), ScriptedModel() - async with app.router.lifespan_context(app), _client(app) as client: - await _configure(app, model) - receipt = await _remember(app, client, TEXT) - - assert receipt["state"] == "ready", (receipt, [name for name, _ in hy_calls]) - assert [name for name, _ in hy_calls] == ["add_extracted"] - assert hy_calls[0][1]["contents"] == [TEXT] - assert len(receipt["memory_ids"]) == 1 - memory = app.state.memory_governance_store.get( - receipt["memory_ids"][0], user_id="local" - ) - assert memory is not None and memory.content == TEXT - assert memory.status == "confirmed" and memory.scopes == ("all-apps",) - assert receipt["evidence_id"] in memory.evidence_ids - assert memory.memory_id in hy_calls[0][1]["raw"]["memory_ids"] - assert app.state.evidence_store.get(receipt["evidence_id"]).verbatim == TEXT - # Still the person's own words: matter discovery keeps reading the note. - selected = select_evidence(app.state.evidence_store.database_path) - assert receipt["evidence_id"] in {record.evidence_id for record in selected.records} - - asyncio.run(scenario()) - - -def test_enabled_continuous_policy_processes_a_saved_note_without_separate_consent( - tmp_path, hy_calls -): - async def scenario(): - app, model = _application(tmp_path), ScriptedModel() - async with app.router.lifespan_context(app), _client(app) as client: - await _configure(app, model) - app.state.memory_note_store.intake = None - profile = app.state.model_profile_store.active(app.state.secret_store) - assert profile is not None - with app.state.evidence_store._connect(writing=True) as db: - ingest_journal.initialize(db) - continuous_policy.initialize(db) - continuous_policy.enable( - db, - profile=app.state.model_profile_store.snapshot( - profile.profile_id, profile.revision - ), - expected_revision=0, - expected_legacy_revision=0, - operation_id=str(uuid4()), - backlog_mode="none", - user_confirmed=True, - ) - - saved = await client.post( - "/api/v1/memory-notes", - json={"request_id": str(uuid4()), "text": TEXT, "scope": "all-apps"}, - ) - assert saved.status_code == 201, saved.text - processor = app.state.memory_processing_supervisor - await asyncio.gather(*tuple(processor._workers.values())) - receipt = ( - await client.get(f"/api/v1/memory-notes/{saved.json()['evidence_id']}") - ).json() - - assert receipt["state"] == "ready" - assert [name for name, _ in hy_calls] == ["add_extracted"] - with app.state.evidence_store._connect() as db: - assert ( - db.execute("SELECT COUNT(*) FROM model_content_permissions").fetchone()[0] == 0 - ) - - asyncio.run(scenario()) - - -@pytest.mark.xfail( - strict=True, - reason=( - "T04 wait-user: a note's permission lease covers only that note's evidence, so HY " - "cannot see the memory an earlier note produced and adds a second one. Letting it " - "see earlier notes means sending them to the model with this one: a consent-scope " - "decision. The bridge's no-ids fallback also cannot find it (decorated search " - "content; the leased vector store has no keyword_search)." - ), -) -def test_identical_second_note_points_at_the_memory_hy_already_holds(tmp_path, hy_calls): - async def scenario(): - app, model = _application(tmp_path), ScriptedModel() - async with app.router.lifespan_context(app), _client(app) as client: - await _configure(app, model) - first = await _remember(app, client, TEXT) - assert first["state"] == "ready", first - second = await _remember(app, client, TEXT) - - # HY really deduplicated: reconcile saw the first memory and wrote nothing new. - assert len(model.reconcile_prompts) == 1 and TEXT in model.reconcile_prompts[0] - assert first["memory_ids"][0] in model.reconcile_prompts[0] - assert not hy_calls[-1][1]["raw"].get("memory_ids") - assert second["state"] == "ready", second - assert second["memory_ids"] == first["memory_ids"] - memories = app.state.memory_governance_store.list(user_id="local") - assert [item.memory_id for item in memories] == first["memory_ids"] - assert {first["evidence_id"], second["evidence_id"]} <= set(memories[0].evidence_ids) - - asyncio.run(scenario()) - - -def test_note_saved_before_t04_is_imported_too_and_keeps_all_apps_scope(tmp_path): - """Notes stored before T04 are not migrated; retrying one must still write it as said.""" - calls = [] - - class Bridge: - async def ingest(self, request): - pytest.fail("A note the person asked to keep must be imported, not re-extracted") - - async def ingest_extracted(self, request, contents): - calls.append(list(contents)) - return HyMemoryIngestResult( - success=True, - memories=( - HyMemoryOutput( - memory_id="legacy-note-memory", - content=TEXT, - owner="user", - layer="L2_FACT", - ), - ), - ) - - async def profile_memories(self, **kwargs): - return () - - async def close(self): - pass - - async def scenario(): - app, model = _application(tmp_path), ScriptedModel() - async with app.router.lifespan_context(app), _client(app) as client: - await _configure(app, model) - request_id = str(uuid4()) - record = app.state.evidence_store.put( - EvidenceInput( - source_id=SOURCE_ID, - conversation_id=f"note:{request_id}", - message_id=request_id, - role="user", - written_by=WrittenBy.PERSON, - created_at=datetime.now(UTC), - verbatim=TEXT, - origin_meta={ - EvidenceOrigin.MANUAL_IMPORT: OriginMetadata( - raw_meta={ - "input_mode": "direct_memory_note", - "recall_scope": "all-apps", - } - ) - }, - ) - ).record - database = app.state.evidence_store.database_path - with closing(sqlite3.connect(database)) as db, db: - db.execute( - "INSERT INTO source_grants VALUES ('local',?,?,'default',?,NULL)", - (SOURCE_ID, str(database.parent), datetime.now(UTC).isoformat()), - ) - await confirm_model_content( - client, - source_ids=(SOURCE_ID,), - evidence_ids=(record.evidence_id,), - future_additions=False, - ) - processor = app.state.memory_processing_supervisor - - @asynccontextmanager - async def isolated(lease, _secret, _data): - lease.recheck() - yield Bridge() - - processor.permission_bridge_factory = isolated - app.state.memory_write_drain.stop() - await app.state.memory_write_drain_task - started = await client.post(f"/api/v1/memory-notes/{record.evidence_id}/process") - assert started.status_code == 200, started.text - assert await app.state.memory_write_drain.run_one() - deadline = asyncio.get_running_loop().time() + 5 - while True: - await asyncio.gather(*tuple(processor._workers.values())) - receipt = (await client.get(f"/api/v1/memory-notes/{record.evidence_id}")).json() - if receipt["state"] != "running" or asyncio.get_running_loop().time() >= deadline: - break - await asyncio.sleep(0.01) - assert calls == [[TEXT]] - assert receipt["state"] == "ready" and receipt["memory_ids"] == ["legacy-note-memory"] - memory = app.state.memory_governance_store.get("legacy-note-memory", user_id="local") - assert memory.scopes == ("all-apps",) and memory.content == TEXT - assert record.evidence_id in memory.evidence_ids - - asyncio.run(scenario()) - - -def test_model_outage_keeps_the_original_words_and_a_retry_writes_them(tmp_path, hy_calls): - async def scenario(): - app, model = _application(tmp_path), ScriptedModel() - async with app.router.lifespan_context(app), _client(app) as client: - await _configure(app, model) - model.embeddings_down = True - failed = await _remember(app, client, TEXT) - assert failed["state"] in {"waiting_for_model", "failed"}, failed - assert failed["memory_ids"] == [] - assert app.state.evidence_store.get(failed["evidence_id"]).verbatim == TEXT - assert app.state.memory_governance_store.list(user_id="local") == [] - - model.embeddings_down = False - retried = await client.post( - "/api/v1/memory-processing/retry-unknown", json={"confirm": True} - ) - assert retried.status_code == 200, retried.text - await _wait_for_note_job(app, failed["evidence_id"]) - receipt = (await client.get(f"/api/v1/memory-notes/{failed['evidence_id']}")).json() - assert receipt["state"] == "ready", receipt - assert hy_calls[-1][0] == "add_extracted" and hy_calls[-1][1]["contents"] == [TEXT] - memory = app.state.memory_governance_store.get( - receipt["memory_ids"][0], user_id="local" - ) - assert memory.content == TEXT and failed["evidence_id"] in memory.evidence_ids - - asyncio.run(scenario()) - - -@pytest.mark.parametrize( - ("raw_meta", "imported"), - [ - ({"input_mode": "direct_memory_note", "recall_scope": "all-apps"}, True), - ({"matter_id": "matter-1"}, False), - ], - ids=["memory-note", "matter-note"], -) -def test_only_a_note_saved_as_memory_skips_extraction(tmp_path, raw_meta, imported): - """Matter notes share the manual source but are context for a matter, not a memory.""" - stores = _setup(tmp_path) - evidence, _profiles, tasks, governance, _secrets, profile = stores - record = evidence.put( - EvidenceInput( - source_id=SOURCE_ID, - conversation_id=f"note:{uuid4()}", - message_id=str(uuid4()), - role="user", - written_by=WrittenBy.PERSON, - created_at=datetime.now(UTC), - verbatim=TEXT, - origin_meta={EvidenceOrigin.MANUAL_IMPORT: OriginMetadata(raw_meta=raw_meta)}, - ) - ).record - bridge = FakeBridge() - - asyncio.run(_runner(tmp_path, stores, bridge).run(_task(tasks, profile, record).task_id)) - - assert len(bridge.requests) == 1 - assert bridge.extracted_contents == ([(TEXT,)] if imported else []) - if imported: - (memory,) = governance.list(user_id="local") - assert memory.content == TEXT and record.evidence_id in memory.evidence_ids diff --git a/memhub/apps/core/tests/test_memory_notes.py b/memhub/apps/core/tests/test_memory_notes.py index 458ff16..dbe0c44 100644 --- a/memhub/apps/core/tests/test_memory_notes.py +++ b/memhub/apps/core/tests/test_memory_notes.py @@ -1,6 +1,5 @@ import asyncio import sqlite3 -from contextlib import asynccontextmanager from uuid import uuid4 import pytest @@ -9,9 +8,7 @@ from pydantic import SecretStr from one_memory.api import create_app from one_memory.config import Settings -from one_memory.hy_memory_bridge import HyMemoryBridgeError, HyMemoryIngestResult, HyMemoryOutput from one_memory.imports.models import WrittenBy -from one_memory.model_profiles import ModelCapabilities, ModelProfileInput, ProviderKind from one_memory.secrets import InMemorySecretStore @@ -37,30 +34,6 @@ def client_for(app): ) -async def wait_for_note_job(app, evidence_id, *, timeout=10.0): - """Wait for the durable note job to leave pending/running. - - The note now reaches HY through the shared durable queue - (`entry:`), so the drain - not the supervisor's in-memory - workers - is what completes it. Waiting on `processor._workers` directly - races that drain and can observe zero requests. - """ - import time as _time - - deadline = _time.monotonic() + timeout - key = f"entry:{evidence_id}" - while _time.monotonic() < deadline: - with sqlite3.connect(app.state.evidence_store.database_path) as db: - row = db.execute( - "SELECT state FROM memory_write_jobs WHERE idempotency_key=?", - (key,), - ).fetchone() - if row is not None and row[0] not in {"pending", "running"}: - return row[0] - await asyncio.sleep(0.05) - raise AssertionError(f"note job for {evidence_id} never settled") - - def test_empty_library_can_save_a_note_without_a_matter_or_configured_model(tmp_path): async def scenario(): app = application(tmp_path) @@ -153,12 +126,6 @@ def test_saved_note_evidence_returns_raw_and_consent_reference_from_same_record( assert value["evidence_id"] == saved["evidence_id"] and value["revision"] == 1 assert value["role"] == "user" and value["written_by"] == "person" assert value["available"] is True and value["source_id"] == "one-memory-manual" - from one_memory.model_content_permissions import model_content_evidence_snapshot - - record = app.state.evidence_store.get(saved["evidence_id"]) - reference = model_content_evidence_snapshot(record).model_dump(mode="json") - assert all(value[key] == expected for key, expected in reference.items()) - assert app.state.model_content_permissions.receipt(user_id="local") is None assert app.state.memory_task_store.list() == [] assert (await client.get("/api/v1/memory-notes/missing/evidence")).status_code == 404 app.state.source_grant_store.revoke(user_id="local", source_id="one-memory-manual") @@ -210,128 +177,3 @@ def test_revoked_note_source_is_not_silently_reauthorized(tmp_path): assert (await client.get("/api/v1/memory-notes")).json() == [] asyncio.run(scenario()) - - -@pytest.mark.parametrize("fail", [False, True]) -def test_note_reuses_hy_queue_and_never_reports_saved_raw_text_as_extracted(tmp_path, fail): - async def scenario(): - app = application(tmp_path) - requests = [] - - class Bridge: - async def ingest(self, request): - pytest.fail("A note the person asked to keep must be imported, not re-extracted") - - async def ingest_extracted(self, request, contents): - assert list(contents) == [turn.content for turn in request.turns] - requests.append(request) - if fail: - raise HyMemoryBridgeError("hy_memory_unavailable") - return HyMemoryIngestResult( - success=True, - memories=( - HyMemoryOutput( - memory_id="note-memory", - content="用户不在周五安排项目周会。", - owner="user", - layer="L2_FACT", - ), - ), - ) - - async def profile_memories(self, **kwargs): - return () - - async def close(self): - pass - - async with app.router.lifespan_context(app), client_for(app) as client: - profiles, secrets = app.state.model_profile_store, app.state.secret_store - profile = profiles.create( - ModelProfileInput(name="Test", provider_kind=ProviderKind.OLLAMA, model="fixture"), - secrets, - ) - profiles.record_capabilities( - profile.profile_id, - expected_revision=profile.revision, - capabilities=ModelCapabilities( - endpoint_reachable=True, model_available=True, embedding_available=True - ), - secrets=secrets, - ) - profiles.activate(profile.profile_id, secrets) - processor = app.state.memory_processing_supervisor - - @asynccontextmanager - async def isolated_bridge(lease, _secret, _data): - lease.recheck() - yield Bridge() - - processor.permission_bridge_factory = isolated_bridge - response = await client.post( - "/api/v1/memory-notes", - json={ - "request_id": str(uuid4()), - "text": "请记住:项目周会不排在周五。", - "scope": "all-apps", - }, - ) - assert response.status_code == 201 - saved = response.json() - assert saved["state"] == "waiting_for_permission" - assert requests == [] - # Reproduce E5: the source is revoked while this unsent durable - # note is parked, so the queue records a terminal cancellation. - # Explicit processing after a fresh grant must safely revive it. - with sqlite3.connect(app.state.evidence_store.database_path) as db: - db.execute( - "UPDATE memory_write_jobs SET state='cancelled'," - "last_error='model_content_permission_revoked',hy_invoked=0 " - "WHERE idempotency_key=?", - (f"entry:{saved['evidence_id']}",), - ) - preview = await client.post( - "/api/v1/model-content-permission/preview", - json={ - "profile_id": profile.profile_id, - "profile_revision": profile.revision, - "source_ids": ["one-memory-manual"], - "evidence_ids": [saved["evidence_id"]], - }, - ) - assert preview.status_code == 200 - consent = await client.post( - "/api/v1/model-content-permission", - json={ - "snapshot": preview.json(), - "expected_revision": 0, - "user_confirmed": True, - }, - ) - assert consent.status_code == 200 - started = await client.post(f"/api/v1/memory-notes/{saved['evidence_id']}/process") - assert started.status_code == 200 - # The note is completed by the shared durable queue now; wait on its - # job rather than the supervisor's in-memory workers. - await wait_for_note_job(app, saved["evidence_id"]) - receipt = (await client.get(f"/api/v1/memory-notes/{saved['evidence_id']}")).json() - assert ( - len(requests) == 1 - and requests[0].turns[0].content == "请记住:项目周会不排在周五。" - ) - assert app.state.evidence_store.count() == 1 - if fail: - assert receipt["state"] == "waiting_for_model" and not receipt["memory_ids"] - assert receipt["error"] == "hy_memory_unavailable" - else: - assert receipt["state"] == "ready" and receipt["memory_ids"] == ["note-memory"] - memory = app.state.memory_governance_store.get("note-memory", user_id="local") - assert memory.scopes == ("all-apps",) - recall = await client.post("/api/v1/recall", json={"query": "周五 项目周会"}) - assert recall.status_code == 200 - assert any(hit["memory_id"] == "note-memory" for hit in recall.json()["hits"]) - await client.post(f"/api/v1/memory-notes/{saved['evidence_id']}/process", json={}) - await wait_for_note_job(app, saved["evidence_id"]) - assert len(requests) == 1 - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_memory_processing.py b/memhub/apps/core/tests/test_memory_processing.py index d2f395d..d124b4a 100644 --- a/memhub/apps/core/tests/test_memory_processing.py +++ b/memhub/apps/core/tests/test_memory_processing.py @@ -2925,7 +2925,7 @@ def test_frozen_harness_refuses_new_processing_before_a_task_or_model_call(tmp_p supervisor._bridges[(profile.profile_id, profile.revision, None)] = bridge assert supervisor._source_may_write([user]) is False - with pytest.raises(HyMemoryBridgeError, match="model_content_source_disabled"): + with pytest.raises(HyMemoryBridgeError, match="hy_memory_source_disabled"): asyncio.run(supervisor.process_records((user, reply))) assert asyncio.run(supervisor.enqueue_conversation_off_thread(user)) == [] assert supervisor.prepare_backfill() == [] @@ -2999,7 +2999,7 @@ def test_revoked_grant_refuses_new_processing(tmp_path) -> None: supervisor._bridges[(profile.profile_id, profile.revision, None)] = bridge assert supervisor._source_may_write([user]) is False - with pytest.raises(HyMemoryBridgeError, match="model_content_source_disabled"): + with pytest.raises(HyMemoryBridgeError, match="hy_memory_source_disabled"): asyncio.run(supervisor.process_records((user, reply))) assert asyncio.run(supervisor.enqueue_conversation_off_thread(user)) == [] assert supervisor.prepare_backfill() == [] diff --git a/memhub/apps/core/tests/test_memory_server_config_state.py b/memhub/apps/core/tests/test_memory_server_config_state.py index 4592f22..cf72117 100644 --- a/memhub/apps/core/tests/test_memory_server_config_state.py +++ b/memhub/apps/core/tests/test_memory_server_config_state.py @@ -13,7 +13,6 @@ def test_sync_config_defaults_off_and_survives_restart(tmp_path): saved = json.loads((tmp_path / "config.json").read_text()) assert saved == { - "model_content_allowed": False, "schema_version": 1, "sources": {"cursor": {"sync_enabled": True}}, } @@ -31,19 +30,27 @@ def test_sync_config_updates_one_source_without_losing_another(tmp_path): assert reopened.sync_enabled("claude-code") -def test_model_content_permission_is_one_global_final_state(tmp_path): +def test_legacy_model_content_permission_is_ignored_on_upgrade(tmp_path): + (tmp_path / "config.json").write_text( + json.dumps( + { + "schema_version": 1, + "model_content_allowed": False, + "sources": {"cursor": {"sync_enabled": True}}, + } + ) + ) + store = ConfigStore(tmp_path) - assert not store.model_content_allowed() - - store.set_model_content_allowed(True) - store.set_sync("cursor", True) - reopened = ConfigStore(tmp_path) - assert reopened.model_content_allowed() - assert reopened.sync_enabled("cursor") - - reopened.set_model_content_allowed(False) - assert not ConfigStore(tmp_path).model_content_allowed() - assert ConfigStore(tmp_path).sync_enabled("cursor") + assert store.sync_enabled("cursor") + store.set_sync("codex", True) + assert json.loads((tmp_path / "config.json").read_text()) == { + "schema_version": 1, + "sources": { + "codex": {"sync_enabled": True}, + "cursor": {"sync_enabled": True}, + }, + } def test_invalid_config_fails_loudly_instead_of_silently_disabling_sources(tmp_path): diff --git a/memhub/apps/core/tests/test_memory_server_durable_queue.py b/memhub/apps/core/tests/test_memory_server_durable_queue.py index 9b22470..c6a91c2 100644 --- a/memhub/apps/core/tests/test_memory_server_durable_queue.py +++ b/memhub/apps/core/tests/test_memory_server_durable_queue.py @@ -3267,103 +3267,6 @@ def test_idle_maintenance_database_failure_is_sampled_without_database_error( assert events[-1]["error_type"] == "RuntimeError" -@pytest.mark.slow -@pytest.mark.integration -@pytest.mark.serial -def test_no_model_permission_parks_without_sending(tmp_path) -> None: - """Model egress permission is separate from source read permission. - - With a permission guard installed but no grant, the durable job must park - in `waiting_for_permission` and never reach HY: the Evidence is committed - locally, but no private verbatim is sent to a model. It must not read as - `failed` (a user switch is not a pipeline break), and a later grant must be - able to re-admit it. - """ - from datetime import UTC, datetime - - from one_memory.imports.models import ( - EvidenceInput, - EvidenceOrigin, - OriginMetadata, - WrittenBy, - ) - from one_memory.imports.storage import EvidenceStore - from one_memory.memory_governance import MemoryGovernanceStore - from one_memory.memory_processing import MemoryProcessingSupervisor - from one_memory.memory_tasks import MemoryTaskStore - from one_memory.memory_server.config_state import ConfigStore - from one_memory.model_profiles import ( - ModelCapabilities, - ModelProfileInput, - ModelProfileStore, - ProviderKind, - ) - from one_memory.secrets import InMemorySecretStore - - database = tmp_path / "one-memory.sqlite3" - secrets = InMemorySecretStore() - store = EvidenceStore(database) - record = store.put( - EvidenceInput( - source_id="cursor", - conversation_id="c", - message_id="m-private", - role="user", - written_by=WrittenBy.PERSON, - verbatim="private verbatim that must not be sent", - created_at=datetime.now(UTC), - origin_meta={EvidenceOrigin.FILE: OriginMetadata()}, - ) - ).record - - profiles = ModelProfileStore(database) - profile = profiles.create( - ModelProfileInput( - name="Local", - provider_kind=ProviderKind.OLLAMA, - model="hy-bot-phi4", - embedding_model="nomic-embed-text", - ), - secrets, - ) - profiles.record_capabilities( - profile.profile_id, - expected_revision=profile.revision, - capabilities=ModelCapabilities( - endpoint_reachable=True, - model_available=True, - structured_output=True, - embedding_available=True, - ), - secrets=secrets, - ) - profiles.activate(profile.profile_id, secrets) - - config = ConfigStore(tmp_path) - config.set_model_content_allowed(False) - supervisor = MemoryProcessingSupervisor( - tasks=MemoryTaskStore(database), - profiles=profiles, - secrets=secrets, - evidence=store, - governance=MemoryGovernanceStore(database), - data_dir=tmp_path, - model_content_config=config, - ) - - queue = DurableWriteQueue(database) - submitted = queue.submit(_request(evidence=(record.evidence_id,))) - before = queue.get(submitted.request_id) - assert before.state == "pending" - _claim_through_drain(queue, submitted.job_id) - - asyncio.run(execute_job(submitted.job_id, queue=queue, supervisor=supervisor, evidence=store)) - - settled = queue.get(submitted.request_id) - assert settled.state == "waiting_for_permission", settled.state - assert "permission" in settled.detail.lower() - - def test_an_unknown_is_retired_only_by_an_inquiry(tmp_path) -> None: """`unknown` must be resolvable, but never by a blind retry. diff --git a/memhub/apps/core/tests/test_memory_server_migrations.py b/memhub/apps/core/tests/test_memory_server_migrations.py index 17a6851..490feec 100644 --- a/memhub/apps/core/tests/test_memory_server_migrations.py +++ b/memhub/apps/core/tests/test_memory_server_migrations.py @@ -2103,7 +2103,47 @@ def test_migration_49_resets_only_cursor_history_when_conversation_time_is_missi result = asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS)) - assert result.applied == (49, 50) + assert result.applied == (49, 50, 51) with main_database(path).read() as connection: assert connection.execute("SELECT COUNT(*) FROM source_cursors").fetchone()[0] == 0 assert connection.execute("SELECT COUNT(*) FROM live_source_cursors").fetchone()[0] == 1 + + +def test_upgrade_drops_obsolete_hy_node_permissions_without_touching_memories( + tmp_path, +) -> None: + import sqlite3 + + from one_memory.memory_server.migration_runner import SqliteMigrationStore + + path = tmp_path / "existing.sqlite3" + asyncio.run( + migrate( + SqliteMigrationStore(path), + tuple(migration for migration in MIGRATIONS if migration.version <= 50), + ) + ) + with sqlite3.connect(path) as connection: + connection.execute( + "CREATE TABLE hy_node_permissions (" + "node_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, evidence_json TEXT NOT NULL)" + ) + connection.execute( + "INSERT INTO hy_node_permissions(node_id,fingerprint,evidence_json) " + "VALUES ('legacy-node', ?, '[]')", + ("a" * 64,), + ) + + result = asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS)) + + assert result.applied == (51,) + with sqlite3.connect(path) as connection: + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + assert "hy_node_permissions" not in tables + assert "governed_memories" in tables + assert "evidence" in tables diff --git a/memhub/apps/core/tests/test_model_content_bulk.py b/memhub/apps/core/tests/test_model_content_bulk.py deleted file mode 100644 index 01075b4..0000000 --- a/memhub/apps/core/tests/test_model_content_bulk.py +++ /dev/null @@ -1,303 +0,0 @@ -"""Bulk consent reads preserve exact identities and SQLite snapshot boundaries.""" - -import asyncio -import sqlite3 - -import pytest -from fastapi import FastAPI -from httpx import ASGITransport, AsyncClient - -from one_memory.database_access import close_main_databases -from one_memory.imports.models import EvidenceOrigin, OriginMetadata, WrittenBy -from one_memory.model_content_permissions import ( - ModelContentPermissionError, - ModelContentPermissionSnapshot, - model_content_evidence_snapshot, -) -from one_memory.model_content_routes import model_content_router -from tests.test_model_content_permissions import ( - SOURCE, - USER, - evidence_value, -) -from tests.test_model_content_permissions import ( - env as environment_fixture, -) - - -@pytest.fixture -def env(tmp_path): - return environment_fixture.__wrapped__(tmp_path) - - -def many_records(environment, count): - records = [environment.record] - with environment.evidence._connect(writing=True) as connection: - for index in range(1, count): - value = evidence_value(str(index), text=f"Synthetic {index}: " + "x" * 128) - if index % 2: - value = value.model_copy( - update={ - "role": "assistant", - "written_by": WrittenBy.AGENT, - "origin_meta": { - EvidenceOrigin.FILE: OriginMetadata(raw_meta={"nested": [1, "text"]}), - EvidenceOrigin.HOOK: OriginMetadata(source_version="fixture-v1"), - }, - } - ) - records.append(environment.evidence.put(value, connection=connection).record) - return records - - -def app_for(environment): - app = FastAPI() - app.state.model_profile_store = environment.profiles - app.state.evidence_store = environment.evidence - app.state.model_content_permissions = environment.permissions - app.include_router(model_content_router([])) - return app - - -def preview(environment, **changes): - async def request(): - async with AsyncClient( - transport=ASGITransport(app=app_for(environment)), base_url="http://test" - ) as client: - return await client.post( - "/api/v1/model-content-permission/preview", - json={ - "profile_id": environment.profile.profile_id, - "profile_revision": environment.profile.revision, - "source_ids": [SOURCE], - "all_imported_from_selected_sources": True, - **changes, - }, - ) - - return asyncio.run(request()) - - -def observe_connections(monkeypatch): - connects, statements = [], [] - original = sqlite3.connect - - def observed(*args, **kwargs): - connection = original(*args, **kwargs) - connects.append(connection) - connection.set_trace_callback(lambda sql: statements.append(sql)) - return connection - - close_main_databases() # pooled connections would skip the patch - - monkeypatch.setattr(sqlite3, "connect", observed) - return connects, statements - - -def test_preview_uses_bounded_bulk_reads_without_per_id_connections(env, monkeypatch): - records = many_records(env, 1000) - expected = env.snapshot(records=tuple(records)) - connects, statements = observe_connections(monkeypatch) - response = preview(env) - assert response.status_code == 200 - actual = ModelContentPermissionSnapshot.model_validate(response.json()) - assert actual == expected - assert len(connects) <= 2, "preview still opens one connection per Evidence ID" - selects = [sql for sql in statements if sql.lstrip().upper().startswith("SELECT")] - assert len(selects) <= 20, "preview still rereads each record separately" - assert env.permissions.receipt(user_id=USER) is None - - -def test_hot_check_is_bulk_and_reuses_same_transaction_verified_refs(env, monkeypatch): - records = many_records(env, 1000) - receipt = env.permissions.grant( - env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True - ) - connects, statements = observe_connections(monkeypatch) - assert env.check(receipt, records=(records[0],)) == receipt - assert len(connects) == 1 - selects = [sql for sql in statements if sql.lstrip().upper().startswith("SELECT")] - assert len(selects) <= 20, ( - "checking one input still performs per-record SQL for the entire scope" - ) - - -def test_all_source_preview_and_future_boundary_share_one_read_snapshot(env, monkeypatch): - records = many_records(env, 4) - expected = env.snapshot(records=tuple(records), future=True) - source = env.permissions._source - changed = False - - def concurrent_insert(connection, user_id, source_id): - nonlocal changed - result = source(connection, user_id, source_id) - if not changed: - changed = True - env.evidence.put(evidence_value("arrived-during-preview")) - return result - - monkeypatch.setattr(env.permissions, "_source", concurrent_insert) - response = preview(env, include_future_additions=True) - assert response.status_code == 200 - actual = ModelContentPermissionSnapshot.model_validate(response.json()) - assert changed and actual == expected, "preview mixed old IDs with a newer future watermark" - receipt = env.permissions.grant(actual, expected_revision=0, user_confirmed=True) - assert receipt.snapshot == expected - added = env.evidence.put(evidence_value("arrived-during-preview")).record - assert added.evidence_id not in {ref.evidence_id for ref in actual.evidence} - assert env.check(receipt, records=(added,)) == receipt - - -@pytest.mark.parametrize("mutation", ["body", "author", "origins", "delete"]) -def test_hot_check_still_rejects_changed_unrequested_scope_record(env, mutation): - records = many_records(env, 4) - receipt = env.permissions.grant( - env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True - ) - other = records[-1] - with sqlite3.connect(env.database) as db: - sql = { - "body": ( - "UPDATE evidence SET verbatim='changed without revision or hash' " - "WHERE evidence_id=?" - ), - "author": "UPDATE evidence SET written_by='unknown' WHERE evidence_id=?", - "origins": "UPDATE evidence_origins SET source_version='changed' WHERE evidence_id=?", - "delete": "DELETE FROM evidence WHERE evidence_id=?", - }[mutation] - db.execute(sql, (other.evidence_id,)) - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt, records=(records[0],)) - - -@pytest.mark.parametrize( - "selection,code", - [ - ("missing", "evidence_not_found"), - ("other_user", "evidence_not_found"), - ("other_source", "invalid_content_scope"), - ("duplicate", "invalid_content_scope"), - ("ambiguous", "ambiguous_content_scope"), - ], -) -def test_bulk_preview_keeps_exact_selection_failures(env, selection, code): - other_user = env.evidence.put(evidence_value("other-user", user_id="other")).record - other_source = env.evidence.put(evidence_value("other-source", source_id="cursor")).record - identifiers = { - "missing": ["not-present"], - "other_user": [other_user.evidence_id], - "other_source": [other_source.evidence_id], - "duplicate": [env.record.evidence_id, env.record.evidence_id], - "ambiguous": [env.record.evidence_id], - }[selection] - response = preview( - env, evidence_ids=identifiers, all_imported_from_selected_sources=selection == "ambiguous" - ) - assert response.status_code == 409 - assert response.json()["detail"] == f"model_content_{code}" - assert env.permissions.receipt(user_id=USER) is None - - -def test_bulk_snapshot_preserves_every_existing_hash_field(env): - records = many_records(env, 8) - expected = tuple( - sorted( - (model_content_evidence_snapshot(record) for record in records), - key=lambda ref: ref.evidence_id, - ) - ) - actual = ModelContentPermissionSnapshot.model_validate(preview(env).json()) - assert actual.evidence == expected - assert not any(record.verbatim in actual.model_dump_json() for record in records) - - -def test_identifier_selection_does_not_mix_a_newer_future_watermark(env, monkeypatch): - records = many_records(env, 4) - expected = env.snapshot(records=tuple(records), future=True) - original_connect = sqlite3.connect - inserted = False - - class ConcurrentInsert(sqlite3.Connection): - def execute(self, sql, parameters=()): - nonlocal inserted - cursor = super().execute(sql, parameters) - if sql.startswith("SELECT evidence_id FROM evidence WHERE user_id=") and not inserted: - inserted = True - env.evidence.put(evidence_value("after-identifier-selection")) - return cursor - - def connect(*args, **kwargs): - return original_connect(*args, **kwargs, factory=ConcurrentInsert) - - close_main_databases() # pooled connections would skip the patch - - monkeypatch.setattr(sqlite3, "connect", connect) - response = preview(env, include_future_additions=True) - assert inserted and response.status_code == 200 - snapshot = ModelContentPermissionSnapshot.model_validate(response.json()) - assert snapshot == expected - receipt = env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True) - assert receipt.snapshot == expected - added = env.evidence.put(evidence_value("after-identifier-selection")).record - assert added.evidence_id not in {ref.evidence_id for ref in snapshot.evidence} - assert env.check(receipt, records=(added,)) == receipt - - -def test_bulk_batches_keep_one_snapshot_and_next_check_observes_an_edit(env, monkeypatch): - records = many_records(env, 1001) - snapshot = env.snapshot(records=tuple(records)) - receipt = env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True) - # Large grants now build a bounded scope seal. Invalidate it explicitly to - # exercise the full-snapshot fallback and its single read transaction. - with sqlite3.connect(env.database) as db: - db.execute( - "UPDATE model_content_scope_seals SET valid=0 WHERE user_id=? AND revision=?", - (receipt.user_id, receipt.revision), - ) - ordered = sorted(records, key=lambda record: record.evidence_id) - later = ordered[-1] - original_batch = env.permissions._evidence_batch - calls = [] - - def edit_after_first_batch(connection, identifiers): - yield from original_batch(connection, identifiers) - calls.append(len(identifiers)) - if len(calls) == 1: - with sqlite3.connect(env.database) as db: - db.execute( - "UPDATE evidence SET verbatim='edited but same stored hash' " - "WHERE evidence_id=?", - (later.evidence_id,), - ) - - with monkeypatch.context() as scoped: - scoped.setattr(env.permissions, "_evidence_batch", edit_after_first_batch) - assert env.check(receipt, records=(ordered[0],)) == receipt - assert calls == [500, 500, 1] - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt, records=(ordered[0],)) - - -def test_same_transaction_reuse_does_not_trust_forged_request_metadata(env): - receipt = env.grant() - forged = env.record.model_copy(update={"written_by": WrittenBy.AGENT}) - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt, records=(forged,)) - with pytest.raises(ModelContentPermissionError, match="invalid_content_scope"): - env.check(receipt, records=(env.record, env.record)) - - -@pytest.mark.parametrize( - "column,value", - [ - ("raw_meta_json", "not-json"), - ("origin", "unknown-origin"), - ("source_version", "x" * 129), - ], -) -def test_bulk_corrupt_origins_fail_closed(env, column, value): - receipt = env.grant() - with sqlite3.connect(env.database) as db: - db.execute(f"UPDATE evidence_origins SET {column}=?", (value,)) - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt) diff --git a/memhub/apps/core/tests/test_model_content_parse_cost.py b/memhub/apps/core/tests/test_model_content_parse_cost.py deleted file mode 100644 index 6812617..0000000 --- a/memhub/apps/core/tests/test_model_content_parse_cost.py +++ /dev/null @@ -1,326 +0,0 @@ -"""Memoize parsing only; every permission decision still reads current SQLite state.""" - -import json -import sqlite3 -from concurrent.futures import ThreadPoolExecutor -from datetime import timedelta -from sys import getsizeof -from threading import Barrier -from time import perf_counter -from unittest.mock import patch - -import pytest -from pydantic import ValidationError - -import one_memory.model_content_permissions as content -from one_memory.database_access import close_main_databases -from one_memory.model_content_permissions import ( - ModelContentPermissionError, - ModelContentPermissionSnapshot, - ModelContentPermissionStore, -) -from tests.test_model_content_permissions import SOURCE, USER, evidence_value -from tests.test_model_content_permissions import env as env - - -def repeated_receipt_measurement(store, *, repeats=6): - original = ModelContentPermissionSnapshot.model_validate_json - with patch.object(ModelContentPermissionSnapshot, "model_validate_json", wraps=original) as spy: - durations = [] - receipts = [] - for _ in range(repeats): - started = perf_counter() - receipts.append(store.receipt(user_id=USER)) - durations.append(perf_counter() - started) - assert receipts[0] is not None - assert all(receipt == receipts[0] for receipt in receipts) - return { - "references": len(receipts[0].snapshot.evidence), - "reads": repeats, - "snapshot_parses": spy.call_count, - "seconds_per_read": durations, - } - - -def synthetic_large_receipt(env, count=12_223): - """Only the parsing benchmark uses synthetic refs without corresponding bodies.""" - snapshot = env.snapshot() - template = snapshot.evidence[0] - snapshot = snapshot.model_copy( - update={ - "evidence": tuple( - template.model_copy(update={"evidence_id": f"synthetic-evidence-{index:06d}"}) - for index in range(count) - ), - } - ) - receipt = content.ModelContentPermissionReceipt( - user_id=USER, - revision=1, - revocation_revision=0, - granted_at=template.imported_at, - revoked_at=None, - snapshot=snapshot, - ) - with env.permissions._connect(writing=True) as db: - env.permissions._append(db, receipt) - return receipt - - -def test_large_unchanged_receipt_is_parsed_once_not_once_per_poll(env, record_property): - synthetic_large_receipt(env) - measured = repeated_receipt_measurement(env.permissions) - record_property("parse_benchmark", json.dumps(measured)) - print(json.dumps(measured), flush=True) - assert measured["references"] == 12_223 - assert measured["snapshot_parses"] == 1, measured - - -def test_concurrent_cold_reads_share_only_identical_parse_work(env): - receipt = env.grant() - barrier = Barrier(4) - original = ModelContentPermissionSnapshot.model_validate_json - - def read(_): - barrier.wait(timeout=5) - return env.permissions.receipt(user_id=USER) - - with ( - patch.object(ModelContentPermissionSnapshot, "model_validate_json", wraps=original) as spy, - ThreadPoolExecutor(max_workers=4) as pool, - ): - results = list(pool.map(read, range(4))) - assert results == [receipt] * 4 - assert spy.call_count == 1 - - -@pytest.mark.parametrize("mutation", ["corrupt_json", "owner", "hash", "generation"]) -def test_same_revision_snapshot_tampering_cannot_reuse_a_cached_parse(env, mutation): - receipt = env.grant() - assert env.check(receipt) == receipt - with sqlite3.connect(env.database) as db: - raw = db.execute("SELECT snapshot_json FROM model_content_permissions").fetchone()[0] - body = json.loads(raw) - if mutation == "owner": - body["user_id"] = "other-user" - elif mutation == "hash": - body["evidence"][0]["content_hash"] = "0" * 64 - elif mutation == "generation": - body["generation"]["model"] = "tampered-model" - changed = "{}" if mutation == "corrupt_json" else json.dumps(body) - db.execute("UPDATE model_content_permissions SET snapshot_json=?", (changed,)) - with pytest.raises(ModelContentPermissionError): - env.check(receipt) - # Returning to exactly the stored bytes is allowed to reuse parsing, not authority. - with sqlite3.connect(env.database) as db: - db.execute("UPDATE model_content_permissions SET snapshot_json=?", (raw,)) - assert env.check(receipt) == receipt - - -@pytest.mark.parametrize( - "field", ["user_id", "revision", "revocation_revision", "granted_at", "revoked_at"] -) -def test_cached_receipt_is_keyed_by_all_database_metadata(env, field): - receipt = env.grant() - assert env.permissions.receipt(user_id=USER) == receipt - values = { - "user_id": "other-user", - "revision": 2, - "revocation_revision": 1, - "granted_at": (receipt.granted_at + timedelta(seconds=1)).isoformat(), - "revoked_at": receipt.granted_at.isoformat(), - } - with sqlite3.connect(env.database) as db: - db.execute(f"UPDATE model_content_permissions SET {field}=?", (values[field],)) - if field == "user_id": - assert env.permissions.receipt(user_id=USER) is None - with pytest.raises(ModelContentPermissionError, match="permission_snapshot_invalid"): - env.permissions.receipt(user_id="other-user") - return - changed = env.permissions.receipt(user_id=USER) - assert changed is not None and changed != receipt - if field == "granted_at": - assert changed.granted_at == receipt.granted_at + timedelta(seconds=1) - else: - with pytest.raises(ModelContentPermissionError): - env.check(receipt) - - -def test_byte_different_json_is_reparsed_even_when_semantically_equal(env): - receipt = env.grant() - original = ModelContentPermissionSnapshot.model_validate_json - with patch.object(ModelContentPermissionSnapshot, "model_validate_json", wraps=original) as spy: - assert env.permissions.receipt(user_id=USER) == receipt - with sqlite3.connect(env.database) as db: - raw = db.execute("SELECT snapshot_json FROM model_content_permissions").fetchone()[0] - changed = json.dumps(json.loads(raw), indent=1) - assert raw != changed - db.execute("UPDATE model_content_permissions SET snapshot_json=?", (changed,)) - assert env.permissions.receipt(user_id=USER) == receipt - assert env.permissions.receipt(user_id=USER) == receipt - assert spy.call_count == 2 - - -@pytest.mark.parametrize( - "mutation", ["revoke", "delete", "profile", "root", "source", "evidence", "origins"] -) -def test_cache_hit_still_rechecks_live_authority_and_all_evidence(env, mutation): - other = env.evidence.put(evidence_value("not-the-requested-record")).record - receipt = env.permissions.grant( - env.snapshot(records=(env.record, other)), expected_revision=0, user_confirmed=True - ) - assert env.check(receipt) == receipt - if mutation == "revoke": - env.permissions.revoke(user_id=USER, expected_revision=1, user_confirmed=True) - elif mutation == "root": - env.root.rename(env.root.with_name("old-projects")) - env.root.mkdir() - elif mutation == "source": - env.sources.revoke(user_id=USER, source_id=SOURCE) - else: - with sqlite3.connect(env.database) as db: - if mutation == "delete": - db.execute("DELETE FROM model_content_permissions") - elif mutation == "profile": - db.execute("UPDATE model_profiles SET model='changed-without-revision'") - elif mutation == "evidence": - db.execute( - "UPDATE evidence SET verbatim='changed without revision' WHERE evidence_id=?", - (other.evidence_id,), - ) - else: - db.execute( - "UPDATE evidence_origins SET source_version='changed' WHERE evidence_id=?", - (other.evidence_id,), - ) - with pytest.raises(ModelContentPermissionError): - env.check(receipt) - - -def test_warm_parse_and_evidence_checks_share_the_current_transaction(env, monkeypatch): - receipt = env.grant() - assert env.check(receipt) == receipt - validate = env.permissions._validate_snapshot_authority - changed = False - - def edit_after_receipt_read(connection, snapshot): - nonlocal changed - if not changed: - changed = True - with sqlite3.connect(env.database) as db: - db.execute("UPDATE evidence SET verbatim='changed during validation'") - return validate(connection, snapshot) - - with monkeypatch.context() as scoped: - scoped.setattr(env.permissions, "_validate_snapshot_authority", edit_after_receipt_read) - # The receipt SELECT already opened this transaction's SQLite snapshot. - assert env.check(receipt) == receipt - assert changed - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt) - - -def test_repeated_check_reuses_parse_but_reads_and_validates_each_transaction(env, monkeypatch): - receipt = env.grant() - original_connect = sqlite3.connect - statements = [] - connections = [] - - def connect(*args, **kwargs): - db = original_connect(*args, **kwargs) - connections.append(db) - db.set_trace_callback(statements.append) - return db - - close_main_databases() # pooled connections would skip the patch - - monkeypatch.setattr(sqlite3, "connect", connect) - original = ModelContentPermissionSnapshot.model_validate_json - with ( - patch.object(ModelContentPermissionSnapshot, "model_validate_json", wraps=original) as spy, - patch.object( - env.permissions, - "_validate_snapshot_authority", - wraps=env.permissions._validate_snapshot_authority, - ) as validate, - ): - assert env.check(receipt) == receipt - # A fresh connection per check, so each transaction is observed. - close_main_databases() - assert env.check(receipt) == receipt - assert spy.call_count == 1 - assert validate.call_count == 2 - assert len(connections) == 2 - assert sum("FROM model_content_permissions WHERE user_id=" in sql for sql in statements) == 2 - assert all( - call.args[0] is db for call, db in zip(validate.call_args_list, connections, strict=True) - ) - - -def test_historical_cached_receipt_never_becomes_current_after_revoke(env): - first = env.grant() - assert env.check(first) == first - revoked = env.permissions.revoke(user_id=USER, expected_revision=1, user_confirmed=True) - assert env.permissions.receipt(user_id=USER, revision=1) == first - assert env.permissions.receipt(user_id=USER) == revoked - with pytest.raises(ModelContentPermissionError, match="permission_revoked"): - env.check(first) - - -def test_cached_parsed_snapshot_is_deeply_immutable(env): - env.grant() - receipt = env.permissions.receipt(user_id=USER) - for model, field, value in ( - (receipt, "revoked_at", receipt.granted_at), - (receipt.snapshot, "evidence", ()), - (receipt.snapshot.evidence[0], "revision", 9), - (receipt.snapshot.sources[0], "revision", 9), - (receipt.snapshot.profile, "model", "changed"), - (receipt.snapshot.generation, "model", "changed"), - ): - with pytest.raises(ValidationError, match="frozen_instance"): - setattr(model, field, value) - assert env.permissions.receipt(user_id=USER) == receipt - - -def test_parse_cache_is_entry_bounded_and_store_local(env, monkeypatch): - monkeypatch.setattr(content, "_RECEIPT_PARSE_CACHE_LIMIT", 2, raising=False) - first = env.grant() - second = env.permissions.revoke(user_id=USER, expected_revision=1, user_confirmed=True) - third = env.permissions.grant(env.snapshot(), expected_revision=2, user_confirmed=True) - store = ModelContentPermissionStore(env.database) - original = ModelContentPermissionSnapshot.model_validate_json - with patch.object(ModelContentPermissionSnapshot, "model_validate_json", wraps=original) as spy: - for revision, expected in ((1, first), (2, second), (3, third), (1, first), (1, first)): - assert store.receipt(user_id=USER, revision=revision) == expected - assert spy.call_count == 4 - fresh = ModelContentPermissionStore(env.database) - assert fresh.receipt(user_id=USER, revision=1) == first - assert spy.call_count == 5 - - -def test_oversized_parse_result_is_not_retained(env, monkeypatch): - monkeypatch.setattr(content, "_RECEIPT_PARSE_CACHE_BYTES", 1, raising=False) - env.grant() - measured = repeated_receipt_measurement(env.permissions, repeats=2) - assert measured["snapshot_parses"] == 2 - - -def test_parse_cache_total_bytes_are_bounded(env, monkeypatch): - receipt = env.grant() - with sqlite3.connect(env.database) as db: - row = db.execute("SELECT * FROM model_content_permissions").fetchone() - entry_size = getsizeof(row) + sum(getsizeof(value) for value in row) - monkeypatch.setattr(content, "_RECEIPT_PARSE_CACHE_BYTES", entry_size * 2 + 64) - monkeypatch.setattr(content, "_RECEIPT_PARSE_CACHE_LIMIT", 8) - store = ModelContentPermissionStore(env.database) - original = ModelContentPermissionSnapshot.model_validate_json - with patch.object(ModelContentPermissionSnapshot, "model_validate_json", wraps=original) as spy: - for offset in (0, 1, 2, 0, 0): - stamp = receipt.granted_at + timedelta(seconds=offset) - with sqlite3.connect(env.database) as db: - db.execute( - "UPDATE model_content_permissions SET granted_at=?", (stamp.isoformat(),) - ) - assert store.receipt(user_id=USER).granted_at == stamp - assert spy.call_count == 4 diff --git a/memhub/apps/core/tests/test_model_content_permissions.py b/memhub/apps/core/tests/test_model_content_permissions.py deleted file mode 100644 index bf6c9fe..0000000 --- a/memhub/apps/core/tests/test_model_content_permissions.py +++ /dev/null @@ -1,791 +0,0 @@ -"""Content permission tests use only a temporary database and synthetic sources.""" - -import sqlite3 -from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass -from datetime import UTC, datetime -from pathlib import Path -from threading import Barrier - -import pytest -from pydantic import ValidationError - -from one_memory.imports.grants import SourceGrantStore -from one_memory.imports.models import ( - EvidenceInput, - EvidenceOrigin, - EvidenceRecord, - OriginMetadata, - WrittenBy, -) -from one_memory.imports.sources.registry import RootCandidateKind -from one_memory.imports.storage import EvidenceStore -from one_memory.memory_server.config_state import ConfigStore -from one_memory.model_content_permissions import ( - ModelContentCostEstimate, - ModelContentPermissionError, - ModelContentPermissionReceipt, - ModelContentPermissionSnapshot, - ModelContentPermissionStore, -) -from one_memory.model_profiles import ( - ModelCapabilities, - ModelProfileInput, - ModelProfilePatch, - ModelProfileSnapshot, - ModelProfileStore, - ProviderKind, -) -from one_memory.secrets import InMemorySecretStore - -USER = "local" -SOURCE = "claude-code" -MANUAL = "one-memory-manual" - - -def evidence_value( - message: str, - *, - source_id: str = SOURCE, - user_id: str = USER, - text: str | None = None, -) -> EvidenceInput: - return EvidenceInput( - user_id=user_id, - source_id=source_id, - conversation_id="synthetic-conversation", - message_id=message, - role="user", - written_by=WrittenBy.PERSON, - verbatim=text or f"Synthetic content {message}", - origin_meta={EvidenceOrigin.MANUAL_IMPORT: OriginMetadata()}, - ) - - -@dataclass -class Environment: - database: Path - root: Path - sources: SourceGrantStore - evidence: EvidenceStore - profiles: ModelProfileStore - profile: ModelProfileSnapshot - permissions: ModelContentPermissionStore - record: EvidenceRecord - secrets: InMemorySecretStore - - def snapshot( - self, - *, - records: tuple[EvidenceRecord, ...] | None = None, - source_ids: tuple[str, ...] = (SOURCE,), - future: bool = False, - ) -> ModelContentPermissionSnapshot: - return self.permissions.snapshot( - user_id=USER, - source_ids=source_ids, - profile=self.profile, - evidence=records if records is not None else (self.record,), - include_future_additions=future, - ) - - def grant(self, *, future: bool = False) -> ModelContentPermissionReceipt: - return self.permissions.grant( - self.snapshot(future=future), user_confirmed=True, expected_revision=0 - ) - - def check( - self, - receipt: ModelContentPermissionReceipt, - *, - records: tuple[EvidenceRecord, ...] | None = None, - profile: ModelProfileSnapshot | None = None, - user_id: str = USER, - ) -> ModelContentPermissionReceipt: - return self.permissions.check( - user_id=user_id, - expected_revision=receipt.revision, - expected_revocation_revision=receipt.revocation_revision, - profile=profile or self.profile, - evidence=records if records is not None else (self.record,), - ) - - -@pytest.fixture -def env(tmp_path: Path) -> Environment: - database = tmp_path / "one-memory.sqlite3" - home = tmp_path / "home" - root = home / ".claude" / "projects" - root.mkdir(parents=True) - sources = SourceGrantStore(database, home=home) - sources.grant( - user_id=USER, source_id=SOURCE, root=root, candidate_kind=RootCandidateKind.DEFAULT - ) - evidence = EvidenceStore(database) - record = evidence.put(evidence_value("selected")).record - profiles = ModelProfileStore(database) - secrets = InMemorySecretStore() - profile = profiles.create( - ModelProfileInput( - name="Synthetic local model", - provider_kind=ProviderKind.OLLAMA, - base_url="http://127.0.0.1:11434/", - model="test-generation", - embedding_provider_kind=ProviderKind.OLLAMA, - embedding_base_url="http://127.0.0.1:11435/", - embedding_model="test-embedding", - ), - secrets, - ) - profiles.record_capabilities( - profile.profile_id, - expected_revision=profile.revision, - capabilities=ModelCapabilities( - endpoint_reachable=True, model_available=True, embedding_available=True - ), - secrets=secrets, - ) - profiles.activate(profile.profile_id, secrets) - return Environment( - database, - root, - sources, - evidence, - profiles, - profiles.snapshot(profile.profile_id, profile.revision), - ModelContentPermissionStore(database), - record, - secrets, - ) - - -def test_model_selection_and_source_grant_are_not_content_consent(env: Environment) -> None: - assert env.permissions.receipt(user_id=USER) is None - - -def test_cost_disclosure_is_explicit_unknown_without_a_pinned_price_source( - env: Environment, -) -> None: - snapshot = env.snapshot() - assert snapshot.cost_estimate.status == "unknown" - assert snapshot.cost_estimate.currency is None - assert snapshot.cost_estimate.amount is None - assert snapshot.cost_estimate.price_source is None - with pytest.raises(ValidationError): - ModelContentCostEstimate(status="estimated", currency="USD", amount=1.0) - with pytest.raises(ValidationError): - ModelContentCostEstimate(status="unknown", amount=0.0) - estimate = ModelContentCostEstimate( - status="estimated", - currency="USD", - amount=0.25, - price_source="https://example.invalid/pinned-price", - ) - assert estimate.amount == 0.25 - with pytest.raises(ModelContentPermissionError, match="permission_required"): - env.permissions.check( - user_id=USER, - expected_revision=0, - expected_revocation_revision=0, - profile=env.profile, - evidence=(env.record,), - ) - snapshot = env.snapshot() - for confirmation in (False, 1, "true", None): - with pytest.raises(ModelContentPermissionError, match="confirmation_required"): - env.permissions.grant(snapshot, user_confirmed=confirmation, expected_revision=0) - assert env.permissions.receipt(user_id=USER) is None - - -def test_snapshot_is_immutable_precise_and_durable_in_the_same_database(env: Environment) -> None: - receipt = env.grant() - assert receipt.revision == 1 and receipt.revocation_revision == 0 - assert receipt.revoked_at is None - assert env.check(receipt) == receipt - scope = receipt.snapshot - assert scope.profile == env.profile - assert scope.generation.endpoint == "http://127.0.0.1:11434/v1" - assert scope.embedding.endpoint == "http://127.0.0.1:11435/v1" - assert scope.generation.model == "test-generation" - assert scope.embedding.model == "test-embedding" - source = env.sources.require_receipt(user_id=USER, source_id=SOURCE) - assert scope.sources[0].granted_at == source.granted_at - assert scope.sources[0].revision == source.revision - assert scope.sources[0].root_identity == source.root_id - assert scope.evidence[0].evidence_id == env.record.evidence_id - assert scope.evidence[0].revision == env.record.revision - assert scope.evidence[0].content_hash == env.record.content_hash - assert scope.evidence[0].identity_hash - assert env.record.verbatim not in receipt.model_dump_json() - with pytest.raises(ValidationError): - scope.evidence[0].revision = 999 - with pytest.raises(ValidationError): - scope.sources[0].root_identity = "replacement" - with pytest.raises(ValidationError): - scope.profile.model = "replacement" - env.permissions = ModelContentPermissionStore(env.database) - assert env.permissions.receipt(user_id=USER) == receipt - assert env.check(receipt) == receipt - with sqlite3.connect(env.database) as db: - assert db.execute("SELECT count(*) FROM model_content_permissions").fetchone()[0] == 1 - assert db.execute("SELECT count(*) FROM evidence").fetchone()[0] == 1 - assert db.execute("SELECT count(*) FROM source_grants").fetchone()[0] == 1 - - -def test_revoke_and_regrant_have_distinct_monotonic_revisions(env: Environment) -> None: - first = env.grant() - with pytest.raises(ModelContentPermissionError, match="confirmation_required"): - env.permissions.revoke(user_id=USER, expected_revision=1, user_confirmed=False) - revoked = env.permissions.revoke(user_id=USER, expected_revision=1, user_confirmed=True) - assert revoked.revision == 2 and revoked.revocation_revision == 1 - assert revoked.revoked_at is not None - assert revoked.snapshot == first.snapshot and first.revoked_at is None - env.permissions = ModelContentPermissionStore(env.database) - with pytest.raises(ModelContentPermissionError, match="permission_revoked"): - env.check(revoked) - with pytest.raises(ModelContentPermissionError): - env.check(first) - with pytest.raises(ModelContentPermissionError, match="permission_changed"): - env.permissions.grant(env.snapshot(), expected_revision=1, user_confirmed=True) - renewed = env.permissions.grant(env.snapshot(), expected_revision=2, user_confirmed=True) - assert renewed.revision == 3 and renewed.revocation_revision == 1 - assert env.check(renewed) == renewed - assert env.permissions.receipt(user_id=USER, revision=1) == first - with pytest.raises(ModelContentPermissionError, match="permission_changed"): - env.permissions.check( - user_id=USER, - expected_revision=3, - expected_revocation_revision=0, - profile=env.profile, - evidence=(env.record,), - ) - - -def test_permission_changes_are_compare_and_swap_and_do_not_expand_old_receipts( - env: Environment, -) -> None: - first = env.grant() - other = env.evidence.put(evidence_value("second-selected")).record - changed = env.snapshot(records=(env.record, other)) - with pytest.raises(ModelContentPermissionError, match="permission_changed"): - env.permissions.grant(changed, expected_revision=0, user_confirmed=True) - assert env.check(first) == first - second = env.permissions.grant(changed, expected_revision=1, user_confirmed=True) - assert env.check(second, records=(other,)) == second - with pytest.raises(ModelContentPermissionError, match="permission_changed"): - env.check(first) - assert len(first.snapshot.evidence) == 1 - - -def test_two_concurrent_confirmations_cannot_both_replace_the_same_revision( - env: Environment, -) -> None: - snapshot = env.snapshot() - barrier = Barrier(2) - - def confirm() -> str: - barrier.wait(timeout=5) - try: - env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True) - return "granted" - except ModelContentPermissionError as error: - return error.code - - with ThreadPoolExecutor(max_workers=2) as pool: - results = list(pool.map(lambda _: confirm(), range(2))) - assert sorted(results) == ["granted", "permission_changed"] - - -@pytest.mark.parametrize( - "mutation", - [ - "revoke", - "regrant", - "grant_revision", - "grant_granted_at", - "root_identity", - "requested_path", - "root_path", - "candidate_kind", - "zero_revision", - "unknown_identity", - "missing_grant", - "replace_directory", - ], -) -def test_source_authority_changes_invalidate_existing_consent( - env: Environment, - mutation: str, -) -> None: - receipt = env.grant() - assert env.check(receipt) == receipt - if mutation == "revoke": - env.sources.revoke(user_id=USER, source_id=SOURCE) - elif mutation == "regrant": - env.sources.revoke(user_id=USER, source_id=SOURCE) - env.sources.grant( - user_id=USER, - source_id=SOURCE, - root=env.root, - candidate_kind=RootCandidateKind.DEFAULT, - ) - elif mutation == "replace_directory": - env.root.rename(env.root.with_name("original-projects")) - env.root.mkdir() - else: - sql = { - "grant_revision": "UPDATE source_grants SET revision=revision+1", - "grant_granted_at": "UPDATE source_grants SET granted_at='2099-01-01'", - "root_identity": "UPDATE source_grants SET root_identity='root:v2:replaced'", - "requested_path": "UPDATE source_grants SET requested_path='/missing-root'", - "root_path": "UPDATE source_grants SET root_path='/another-root'", - "candidate_kind": "UPDATE source_grants SET candidate_kind='environment_override'", - "zero_revision": "UPDATE source_grants SET revision=0", - "unknown_identity": "UPDATE source_grants SET root_identity='root:v99:unknown'", - "missing_grant": "DELETE FROM source_grants", - }[mutation] - with sqlite3.connect(env.database) as db: - db.execute(sql) - with pytest.raises(ModelContentPermissionError): - env.check(receipt) - assert env.permissions.receipt(user_id=USER) == receipt - - -def test_sync_permission_is_independent_and_does_not_grant_model_content(env: Environment) -> None: - config = ConfigStore(env.database.parent) - config.set_sync(SOURCE, True) - assert env.permissions.receipt(user_id=USER) is None - receipt = env.grant() - config.set_sync(SOURCE, False) - assert env.check(receipt) == receipt - - -def test_missing_source_grant_cannot_be_confirmed(env: Environment) -> None: - with sqlite3.connect(env.database) as db: - db.execute("DELETE FROM source_grants") - with pytest.raises(ModelContentPermissionError): - env.snapshot() - assert env.permissions.receipt(user_id=USER) is None - - -@pytest.mark.parametrize("mutation", ["source", "profile", "evidence"]) -def test_confirmation_revalidates_the_displayed_snapshot( - env: Environment, - mutation: str, -) -> None: - snapshot = env.snapshot() - if mutation == "source": - env.sources.grant( - user_id=USER, - source_id=SOURCE, - root=env.root, - candidate_kind=RootCandidateKind.DEFAULT, - ) - elif mutation == "profile": - env.profiles.update( - env.profile.profile_id, - ModelProfilePatch(expected_revision=1, model="changed"), - env.secrets, - ) - else: - env.evidence.put(evidence_value("selected", text="Changed after the consent preview")) - with pytest.raises(ModelContentPermissionError): - env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True) - assert env.permissions.receipt(user_id=USER) is None - - -@pytest.mark.parametrize( - "field,value", - [ - ("base_url", "http://127.0.0.1:19000/v1"), - ("model", "different-generation"), - ("embedding_base_url", "http://127.0.0.1:19001/v1"), - ("embedding_model", "different-embedding"), - ("embedding_provider_kind", "local_onnx"), - ("embedding_dimensions", 777), - ("timeout_seconds", 120), - ("revision", 2), - ], -) -def test_changed_profile_is_rejected_even_when_caller_supplies_the_old_snapshot( - env: Environment, - field: str, - value: object, -) -> None: - receipt = env.grant() - assert env.check(receipt) == receipt - with sqlite3.connect(env.database) as db: - db.execute( - f"UPDATE model_profiles SET {field}=? WHERE profile_id=?", # fixed test fields - (value, env.profile.profile_id), - ) - with pytest.raises(ModelContentPermissionError, match="profile_changed"): - env.check(receipt) - - -def test_other_profile_or_recipient_cannot_be_substituted_in_a_request(env: Environment) -> None: - receipt = env.grant() - for mutation in ( - {"profile_id": "not-the-approved-profile"}, - {"base_url": "https://different.example/v1"}, - {"embedding_base_url": "https://vectors.example/v1"}, - ): - with pytest.raises(ModelContentPermissionError, match="profile_changed"): - env.check(receipt, profile=env.profile.model_copy(update=mutation)) - tampered = receipt.snapshot.model_copy( - update={ - "generation": receipt.snapshot.generation.model_copy( - update={ - "endpoint": "https://different.example/v1", - } - ), - } - ) - with pytest.raises(ModelContentPermissionError): - env.permissions.grant(tampered, expected_revision=1, user_confirmed=True) - assert env.check(receipt) == receipt - - -def test_switching_or_deleting_the_current_profile_invalidates_checks(env: Environment) -> None: - receipt = env.grant() - other = env.profiles.create( - ModelProfileInput(name="Other", provider_kind=ProviderKind.OLLAMA, model="other"), - env.secrets, - ) - with sqlite3.connect(env.database) as db: - db.execute("UPDATE model_profiles SET is_active=0") - db.execute("UPDATE model_profiles SET is_active=1 WHERE profile_id=?", (other.profile_id,)) - with pytest.raises(ModelContentPermissionError, match="profile_changed"): - env.check(receipt) - with sqlite3.connect(env.database) as db: - db.execute("DELETE FROM model_profiles WHERE profile_id=?", (env.profile.profile_id,)) - with pytest.raises(ModelContentPermissionError, match="profile_changed"): - env.check(receipt) - - -@pytest.mark.parametrize( - "mutation", - ["revision", "verbatim", "written_by", "workspace", "source", "user", "origins", "delete"], -) -def test_changed_evidence_never_passes_a_saved_exact_snapshot( - env: Environment, - mutation: str, -) -> None: - receipt = env.grant() - assert env.check(receipt) == receipt - sql = { - "revision": "UPDATE evidence SET revision=revision+1", - "verbatim": "UPDATE evidence SET verbatim='same revision but different body'", - "written_by": "UPDATE evidence SET written_by='agent'", - "workspace": "UPDATE evidence SET workspace='different-workspace'", - "source": "UPDATE evidence SET source_id='cursor'", - "user": "UPDATE evidence SET user_id='other-user'", - "origins": 'UPDATE evidence_origins SET raw_meta_json=\'{"extra":"changed"}\'', - "delete": "DELETE FROM evidence", - }[mutation] - with sqlite3.connect(env.database) as db: - db.execute(sql) - with pytest.raises(ModelContentPermissionError): - env.check(receipt) - current = env.evidence.get(env.record.evidence_id) - if current is not None: - with pytest.raises(ModelContentPermissionError): - env.check(receipt, records=(current,)) - - -def test_missing_cross_user_and_unselected_evidence_scopes_are_denied(env: Environment) -> None: - receipt = env.grant() - other = env.evidence.put(evidence_value("unselected")).record - other_source = env.evidence.put(evidence_value("other-source", source_id="cursor")).record - other_user = env.evidence.put(evidence_value("other-user", user_id="someone-else")).record - for records in ((), (other,), (other_source,), (other_user,), (env.record, other)): - with pytest.raises(ModelContentPermissionError): - env.check(receipt, records=records) - with pytest.raises(ModelContentPermissionError, match="permission_required"): - env.check(receipt, user_id="someone-else") - with pytest.raises(ModelContentPermissionError): - env.snapshot(records=(other_user,)) - with pytest.raises(ModelContentPermissionError): - env.snapshot(source_ids=()) - with pytest.raises(ModelContentPermissionError): - env.snapshot(records=()) - - -def test_exact_mode_does_not_silently_authorize_later_imports(env: Environment) -> None: - receipt = env.grant() - later = env.evidence.put(evidence_value("after-confirmation")).record - assert env.check(receipt) == receipt - with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"): - env.check(receipt, records=(later,)) - - -def test_future_mode_only_authorizes_explicit_sources_and_truly_new_additions( - env: Environment, -) -> None: - old = env.evidence.put(evidence_value("older-unselected")).record - receipt = env.grant(future=True) - assert receipt.snapshot.include_future_additions is True - assert receipt.snapshot.future_after_sequence is not None - later = env.evidence.put(evidence_value("new-addition")).record - assert env.check(receipt, records=(later,)) == receipt - env.permissions = ModelContentPermissionStore(env.database) - assert env.check(receipt, records=(env.record, later)) == receipt - outside = env.evidence.put(evidence_value("outside", source_id="cursor")).record - for record in (old, outside): - with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"): - env.check(receipt, records=(record,)) - edited = env.evidence.put(evidence_value("new-addition", text="Edited, not added")).record - with pytest.raises(ModelContentPermissionError): - env.check(receipt, records=(later,)) - with pytest.raises(ModelContentPermissionError): - env.check(receipt, records=(edited,)) - - -def test_future_only_consent_never_backfills_preexisting_evidence(env: Environment) -> None: - future = env.snapshot(records=(), future=True) - receipt = env.permissions.grant(future, user_confirmed=True, expected_revision=0) - with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"): - env.check(receipt) - later = env.evidence.put(evidence_value("first-future")).record - assert env.check(receipt, records=(later,)) == receipt - - -def test_future_sequence_is_not_based_on_source_timestamps_or_reused_rowids( - env: Environment, -) -> None: - before = env.evidence.put(evidence_value("before")).record - receipt = env.grant(future=True) - with sqlite3.connect(env.database) as db: - db.execute("PRAGMA foreign_keys=ON") - db.execute("DELETE FROM evidence WHERE evidence_id=?", (before.evidence_id,)) - after = env.evidence.put(evidence_value("after")).record - assert env.check(receipt, records=(after,)) == receipt - reinserted = env.evidence.put(evidence_value("before")).record - with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"): - env.check(receipt, records=(reinserted,)) - - -@pytest.mark.parametrize( - "mutation", - ["verbatim", "workspace", "origins", "new_origin", "deleted_origin"], -) -def test_future_additions_reject_mutations_even_without_a_revision_bump( - env: Environment, - mutation: str, -) -> None: - receipt = env.grant(future=True) - later = env.evidence.put(evidence_value("later")).record - assert env.check(receipt, records=(later,)) == receipt - with sqlite3.connect(env.database) as db: - if mutation == "verbatim": - db.execute( - "UPDATE evidence SET verbatim='changed' WHERE evidence_id=?", (later.evidence_id,) - ) - elif mutation == "workspace": - db.execute( - "UPDATE evidence SET workspace='changed' WHERE evidence_id=?", (later.evidence_id,) - ) - elif mutation == "origins": - db.execute( - "UPDATE evidence_origins SET raw_meta_json='{}', source_version='new' " - "WHERE evidence_id=?", - (later.evidence_id,), - ) - elif mutation == "deleted_origin": - db.execute("DELETE FROM evidence_origins WHERE evidence_id=?", (later.evidence_id,)) - else: - db.execute( - "INSERT INTO evidence_origins VALUES (?,'hook','{}',NULL,?)", - (later.evidence_id, datetime.now(UTC).isoformat()), - ) - current = env.evidence.get(later.evidence_id) - assert current is not None - with pytest.raises(ModelContentPermissionError): - env.check(receipt, records=(current,)) - - -def test_identical_reimport_does_not_consume_or_expand_future_consent(env: Environment) -> None: - receipt = env.grant(future=True) - later = env.evidence.put(evidence_value("later")).record - assert env.check(receipt, records=(later,)) == receipt - duplicate = env.evidence.put(evidence_value("later")).record - assert env.check(receipt, records=(duplicate,)) == receipt - - -def test_future_only_consent_does_not_reclassify_deleted_legacy_evidence( - env: Environment, -) -> None: - receipt = env.permissions.grant( - env.snapshot(records=(), future=True), - expected_revision=0, - user_confirmed=True, - ) - with sqlite3.connect(env.database) as db: - db.execute("PRAGMA foreign_keys=ON") - db.execute("DELETE FROM evidence WHERE evidence_id=?", (env.record.evidence_id,)) - recreated = env.evidence.put(evidence_value("selected")).record - with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"): - env.check(receipt, records=(recreated,)) - - -@pytest.mark.parametrize( - "trigger", ["model_content_track_evidence_addition", "model_content_invalidate_evidence_update"] -) -def test_future_tracking_that_cannot_prove_additions_is_denied( - env: Environment, - trigger: str, -) -> None: - receipt = env.grant(future=True) - later = env.evidence.put(evidence_value("later")).record - assert env.check(receipt, records=(later,)) == receipt - with sqlite3.connect(env.database) as db: - db.execute(f"DROP TRIGGER {trigger}") # fixed test identifiers - with pytest.raises(ModelContentPermissionError): - env.check(receipt, records=(later,)) - - -def test_unknown_saved_policy_schema_is_rejected(env: Environment) -> None: - receipt = env.grant() - with sqlite3.connect(env.database) as db: - db.execute( - "UPDATE model_content_permissions SET snapshot_json=" - "json_set(snapshot_json, '$.schema_version', 999)" - ) - with pytest.raises(ModelContentPermissionError): - env.check(receipt) - - -def test_profile_version_row_cannot_diverge_from_the_current_profile(env: Environment) -> None: - receipt = env.grant() - assert env.check(receipt) == receipt - with sqlite3.connect(env.database) as db: - db.execute("UPDATE model_profile_versions SET embedding_base_url='http://127.0.0.1:19999'") - with pytest.raises(ModelContentPermissionError, match="profile_changed"): - env.check(receipt) - - -def test_all_selected_sources_and_evidence_are_bound_even_for_a_subset_request( - env: Environment, -) -> None: - second_root = env.root.parent / "cursor" - second_root.mkdir() - env.sources.grant( - user_id=USER, source_id="cursor", root=second_root, candidate_kind=RootCandidateKind.DEFAULT - ) - other = env.evidence.put(evidence_value("cursor-turn", source_id="cursor")).record - snapshot = env.snapshot(source_ids=("cursor", SOURCE), records=(env.record, other)) - receipt = env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True) - assert env.check(receipt) == receipt - env.sources.revoke(user_id=USER, source_id="cursor") - with pytest.raises(ModelContentPermissionError): - env.check(receipt) - - -def test_permission_rescoping_replaces_instead_of_unions_previous_sources(env: Environment) -> None: - first = env.grant() - second_root = env.root.parent / "cursor" - second_root.mkdir() - env.sources.grant( - user_id=USER, source_id="cursor", root=second_root, candidate_kind=RootCandidateKind.DEFAULT - ) - other = env.evidence.put(evidence_value("cursor-turn", source_id="cursor")).record - changed = env.snapshot(source_ids=("cursor",), records=(other,)) - receipt = env.permissions.grant(changed, expected_revision=first.revision, user_confirmed=True) - assert env.check(receipt, records=(other,)) == receipt - with pytest.raises(ModelContentPermissionError): - env.check(receipt) - - -def test_forged_future_watermark_and_edited_preview_are_denied(env: Environment) -> None: - env.evidence.put(evidence_value("not-selected-before-preview")) - snapshot = env.snapshot(future=True) - forged = snapshot.model_copy(update={"future_after_sequence": 0}) - with pytest.raises(ModelContentPermissionError): - env.permissions.grant(forged, expected_revision=0, user_confirmed=True) - env.evidence.put(evidence_value("selected", text="Changed since preview")) - env.evidence.put(evidence_value("arrived-after-preview")) - with pytest.raises(ModelContentPermissionError): - env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True) - assert env.permissions.receipt(user_id=USER) is None - - -def test_manual_source_uses_exact_evidence_without_filesystem_authority(env: Environment) -> None: - now = datetime.now(UTC).isoformat() - with sqlite3.connect(env.database) as db: - db.execute( - "INSERT INTO source_grants VALUES (?,?,?,'default',?,NULL)", - (USER, MANUAL, "/deliberately-not-a-filesystem-source", now), - ) - manual = env.evidence.put(evidence_value("manual-note", source_id=MANUAL)).record - snapshot = env.snapshot(records=(manual,), source_ids=(MANUAL,)) - assert snapshot.sources[0].authority == "manual" - assert snapshot.sources[0].root_identity is None and snapshot.sources[0].revision == 0 - receipt = env.permissions.grant(snapshot, user_confirmed=True, expected_revision=0) - assert env.check(receipt, records=(manual,)) == receipt - with pytest.raises(ModelContentPermissionError): - env.snapshot(records=(manual,), source_ids=(MANUAL,), future=True) - env.sources.revoke(user_id=USER, source_id=MANUAL) - with pytest.raises(ModelContentPermissionError): - env.check(receipt, records=(manual,)) - with sqlite3.connect(env.database) as db: - db.execute( - "UPDATE source_grants SET revoked_at=NULL, granted_at=? WHERE source_id=?", - (datetime.now(UTC).isoformat(), MANUAL), - ) - with pytest.raises(ModelContentPermissionError): - env.check(receipt, records=(manual,)) - - -def test_local_embedding_recipient_is_explicit_and_still_needs_consent(env: Environment) -> None: - updated = env.profiles.update( - env.profile.profile_id, - ModelProfilePatch(expected_revision=1, embedding_provider_kind=ProviderKind.LOCAL_ONNX), - env.secrets, - ) - env.profile = env.profiles.snapshot(updated.profile_id, updated.revision) - snapshot = env.snapshot() - assert snapshot.embedding.provider_kind is ProviderKind.LOCAL_ONNX - assert snapshot.embedding.endpoint is None - assert env.permissions.receipt(user_id=USER) is None - receipt = env.permissions.grant(snapshot, user_confirmed=True, expected_revision=0) - assert env.check(receipt) == receipt - - -@pytest.mark.parametrize( - "endpoint", - [ - "https://user:pass@example.test/v1", - "https://example.test/v1?token=example", - "https://example.test/v1#fragment", - "not-an-endpoint", - ], -) -def test_ambiguous_or_credential_bearing_recipients_fail_closed( - env: Environment, - endpoint: str, -) -> None: - with sqlite3.connect(env.database) as db: - db.execute("UPDATE model_profiles SET base_url=?", (endpoint,)) - profile = env.profile.model_copy(update={"base_url": endpoint}) - with pytest.raises(ModelContentPermissionError): - env.permissions.snapshot( - user_id=USER, - source_ids=(SOURCE,), - profile=profile, - evidence=(env.record,), - ) - - -def test_no_dependencies_or_corrupt_receipt_never_produce_authority(tmp_path: Path) -> None: - store = ModelContentPermissionStore(tmp_path / "empty.sqlite3") - assert store.receipt(user_id=USER) is None - with pytest.raises(ModelContentPermissionError, match="permission_required"): - store.revoke(user_id=USER, expected_revision=0, user_confirmed=True) - - -def test_corrupt_or_unknown_snapshot_versions_fail_closed(env: Environment) -> None: - receipt = env.grant() - with sqlite3.connect(env.database) as db: - db.execute("UPDATE model_content_permissions SET snapshot_json='{}'") - with pytest.raises(ModelContentPermissionError): - env.check(receipt) diff --git a/memhub/apps/core/tests/test_model_content_preview_races.py b/memhub/apps/core/tests/test_model_content_preview_races.py deleted file mode 100644 index 5868d16..0000000 --- a/memhub/apps/core/tests/test_model_content_preview_races.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Confirmation must tolerate append-only import without broadening consent.""" - -from concurrent.futures import ThreadPoolExecutor -from threading import Event - -import pytest - -import one_memory.model_content_permissions as content -from one_memory.model_content_permissions import ModelContentPermissionError -from tests.test_model_content_permissions import SOURCE, USER, Environment, evidence_value -from tests.test_model_content_permissions import env as env - - -def test_confirmation_validation_does_not_hold_the_only_writer( - env: Environment, monkeypatch: pytest.MonkeyPatch -) -> None: - snapshot = env.snapshot(future=True) - validating = Event() - release = Event() - original = env.permissions._validate_snapshot - - def paused_validation(connection, value): - result = original(connection, value) - validating.set() - assert release.wait(2) - return result - - monkeypatch.setattr(env.permissions, "_validate_snapshot", paused_validation) - with ThreadPoolExecutor(2) as workers: - confirmation = workers.submit( - env.permissions.grant, - snapshot, - user_confirmed=True, - expected_revision=0, - ) - assert validating.wait(1) - imported = workers.submit(env.evidence.put, evidence_value("during-long-validation")) - try: - assert imported.result(timeout=0.5).record.message_id == "during-long-validation" - finally: - release.set() - assert confirmation.result(timeout=2).snapshot == snapshot - - -def test_confirmation_rejects_evidence_changed_after_read_validation( - env: Environment, monkeypatch: pytest.MonkeyPatch -) -> None: - snapshot = env.snapshot() - validated = Event() - release = Event() - original = env.permissions._validate_snapshot - - def paused_validation(connection, value): - result = original(connection, value) - validated.set() - assert release.wait(2) - return result - - monkeypatch.setattr(env.permissions, "_validate_snapshot", paused_validation) - with ThreadPoolExecutor(1) as worker: - confirmation = worker.submit( - env.permissions.grant, - snapshot, - user_confirmed=True, - expected_revision=0, - ) - assert validated.wait(1) - env.evidence.put(evidence_value("selected", text="changed after validation")) - release.set() - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - confirmation.result(timeout=2) - assert env.permissions.receipt(user_id=USER) is None - - -@pytest.mark.parametrize("via_ids", [False, True]) -@pytest.mark.parametrize("source_id", [SOURCE, "cursor"]) -def test_issued_future_preview_survives_new_originals( - env: Environment, source_id: str, via_ids: bool -) -> None: - snapshot = ( - env.permissions.snapshot_from_ids( - user_id=USER, - source_ids=(SOURCE,), - profile=env.profile, - all_imported_from_selected_sources=True, - include_future_additions=True, - ) - if via_ids - else env.snapshot(future=True) - ) - added = env.evidence.put(evidence_value("during-confirmation", source_id=source_id)).record - receipt = env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True) - assert receipt.snapshot == snapshot - assert env.check(receipt) == receipt - if source_id == SOURCE: - assert env.check(receipt, records=(added,)) == receipt - else: - with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"): - env.check(receipt, records=(added,)) - - -def test_a_preview_does_not_authorize_omitted_preexisting_originals(env: Environment) -> None: - omitted = env.evidence.put(evidence_value("omitted-old")).record - snapshot = env.snapshot(future=True) - added = env.evidence.put(evidence_value("new-during-confirmation")).record - receipt = env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True) - assert env.check(receipt, records=(added,)) == receipt - with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"): - env.check(receipt, records=(omitted,)) - - -def test_forged_watermark_cannot_reuse_an_issued_preview(env: Environment) -> None: - env.evidence.put(evidence_value("omitted-old")) - snapshot = env.snapshot(future=True) - env.evidence.put(evidence_value("new-during-confirmation")) - forged = snapshot.model_copy(update={"future_after_sequence": 0}) - with pytest.raises(ModelContentPermissionError, match="content_scope_changed"): - env.permissions.grant(forged, expected_revision=0, user_confirmed=True) - assert env.permissions.receipt(user_id=USER) is None - - -def test_a_different_exact_selection_cannot_reuse_preview_issuance(env: Environment) -> None: - old = env.evidence.put(evidence_value("second-selected")).record - snapshot = env.snapshot(records=(env.record, old), future=True) - env.evidence.put(evidence_value("new-during-confirmation")) - changed = snapshot.model_copy(update={"evidence": (snapshot.evidence[0],)}) - with pytest.raises(ModelContentPermissionError, match="content_scope_changed"): - env.permissions.grant(changed, expected_revision=0, user_confirmed=True) - - -def test_changed_original_still_invalidates_issued_preview(env: Environment) -> None: - snapshot = env.snapshot(future=True) - env.evidence.put(evidence_value("selected", text="Revised original")) - env.evidence.put(evidence_value("new-during-confirmation")) - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True) - - -def test_restart_requires_repreview_but_does_not_destroy_existing_consent(env: Environment) -> None: - snapshot = env.snapshot(future=True) - env.evidence.put(evidence_value("new-during-confirmation")) - env.permissions = content.ModelContentPermissionStore(env.database) - with pytest.raises(ModelContentPermissionError, match="content_scope_changed"): - env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True) - current = env.snapshot(future=True) - receipt = env.permissions.grant(current, expected_revision=0, user_confirmed=True) - env.permissions = content.ModelContentPermissionStore(env.database) - assert env.check(receipt) == receipt - - -def test_issued_preview_is_bound_to_the_consent_revision(env: Environment) -> None: - snapshot = env.snapshot(future=True) - first = env.grant() - revoked = env.permissions.revoke( - user_id=USER, expected_revision=first.revision, user_confirmed=True - ) - env.evidence.put(evidence_value("new-after-revocation")) - with pytest.raises(ModelContentPermissionError, match="content_scope_changed"): - env.permissions.grant(snapshot, expected_revision=revoked.revision, user_confirmed=True) - - -def test_preview_issuance_expires_without_changing_accepted_scope( - env: Environment, monkeypatch: pytest.MonkeyPatch -) -> None: - clock = [1000.0] - monkeypatch.setattr(content, "monotonic", lambda: clock[0]) - snapshot = env.snapshot(future=True) - env.evidence.put(evidence_value("new-during-confirmation")) - clock[0] += content.PREVIEW_ISSUANCE_TTL_SECONDS + 1 - with pytest.raises(ModelContentPermissionError, match="content_scope_changed"): - env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True) - - -def test_preview_issuance_storage_is_bounded( - env: Environment, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr(content, "PREVIEW_ISSUANCE_LIMIT", 2) - first = env.snapshot(future=True) - env.evidence.put(evidence_value("second")) - env.snapshot(future=True) - env.evidence.put(evidence_value("third")) - latest = env.snapshot(future=True) - env.evidence.put(evidence_value("fourth")) - with pytest.raises(ModelContentPermissionError, match="content_scope_changed"): - env.permissions.grant(first, expected_revision=0, user_confirmed=True) - receipt = env.permissions.grant(latest, expected_revision=0, user_confirmed=True) - assert receipt.snapshot == latest diff --git a/memhub/apps/core/tests/test_model_content_routes.py b/memhub/apps/core/tests/test_model_content_routes.py deleted file mode 100644 index f6a4006..0000000 --- a/memhub/apps/core/tests/test_model_content_routes.py +++ /dev/null @@ -1,88 +0,0 @@ -import asyncio - -from fastapi import FastAPI -from httpx import ASGITransport, AsyncClient -from pydantic import SecretStr - -from one_memory.api import create_app -from one_memory.config import Settings -from one_memory.memory_server.config_state import ConfigStore -from one_memory.model_content_routes import model_content_router -from one_memory.secrets import InMemorySecretStore - - -def test_permission_route_reads_and_writes_only_config_final_state(tmp_path) -> None: - async def scenario() -> None: - backfills = 0 - - def start_backfill() -> None: - nonlocal backfills - backfills += 1 - - app = FastAPI() - app.state.config_store = ConfigStore(tmp_path) - app.state.start_memory_backfill = start_backfill - app.include_router(model_content_router([])) - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - ) as client: - assert (await client.get("/api/v1/model-content-permission")).json() == { - "allowed": False - } - enabled = await client.post( - "/api/v1/model-content-permission", - json={"allowed": True}, - ) - assert enabled.status_code == 200 - assert enabled.json() == {"allowed": True} - assert backfills == 1 - assert ConfigStore(tmp_path).model_content_allowed() - - disabled = await client.post( - "/api/v1/model-content-permission", - json={"allowed": False}, - ) - assert disabled.status_code == 200 - assert disabled.json() == {"allowed": False} - assert backfills == 1 - - legacy = await client.post( - "/api/v1/model-content-permission/preview", - json={}, - ) - assert legacy.status_code == 404 - - asyncio.run(scenario()) - - -def test_core_composition_starts_with_config_permission_and_durable_drain(tmp_path) -> None: - async def scenario() -> None: - app = create_app( - Settings( - _env_file=None, - data_dir=tmp_path, - source_home=tmp_path, - api_token=SecretStr("test-token"), - automatic_memory_processing=False, - live_sync_enabled=False, - ), - source_catalog={}, - secret_store=InMemorySecretStore(), - ) - async with ( - app.router.lifespan_context(app), - AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - headers={"Authorization": "Bearer test-token"}, - ) as client, - ): - response = await client.get("/api/v1/model-content-permission") - assert response.status_code == 200 - assert response.json() == {"allowed": False} - assert app.state.memory_write_drain_task.done() is False - assert app.state.processing_coordinator is None - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_model_content_scope_seals.py b/memhub/apps/core/tests/test_model_content_scope_seals.py deleted file mode 100644 index 06d45f7..0000000 --- a/memhub/apps/core/tests/test_model_content_scope_seals.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Indexed scope invalidation retains complete-snapshot semantics without rereading it.""" - -import sqlite3 -from unittest.mock import patch - -import pytest - -from one_memory.model_content_permissions import ModelContentPermissionError -from tests.test_model_content_bulk import many_records -from tests.test_model_content_permissions import env as env -from tests.test_model_content_permissions import evidence_value - - -def seal(env, count=6): - records = many_records(env, count) - receipt = env.permissions.grant( - env.snapshot(records=tuple(records), future=True), - expected_revision=0, - user_confirmed=True, - _track_scope=True, - ) - return records, receipt - - -def test_sealed_scope_only_hydrates_outbound_records(env): - records, receipt = seal(env, 1000) - with patch.object( - env.permissions, "_evidence_batch", wraps=env.permissions._evidence_batch - ) as batch: - assert env.check(receipt) == receipt - assert sum(len(call.args[1]) for call in batch.call_args_list) == 1 - added = env.evidence.put(evidence_value("after-grant")).record - with patch.object( - env.permissions, "_evidence_batch", wraps=env.permissions._evidence_batch - ) as batch: - assert env.check(receipt, records=(added,)) == receipt - assert sum(len(call.args[1]) for call in batch.call_args_list) == 1 - assert len(records) == 1000 - - -@pytest.mark.parametrize( - "mutation", ["body", "role", "owner", "time", "delete", "origin", "origin_delete", "origin_add"] -) -def test_unrequested_member_changes_invalidate_even_without_revision(env, mutation): - records, receipt = seal(env) - target = records[-1] - assert env.check(receipt) == receipt - with sqlite3.connect(env.database) as db: - if mutation == "origin_add": - db.execute( - "INSERT INTO evidence_origins" - "(evidence_id,origin,raw_meta_json,source_version,observed_at) " - "VALUES (?,'manual_import','{}',NULL,?)", - (target.evidence_id, target.imported_at.isoformat()), - ) - else: - sql = { - "body": "UPDATE evidence SET verbatim='edited without hash' WHERE evidence_id=?", - "role": "UPDATE evidence SET role='system' WHERE evidence_id=?", - "owner": "UPDATE evidence SET user_id='other' WHERE evidence_id=?", - "time": "UPDATE evidence SET created_at='2026-09-01T00:00:00Z' WHERE evidence_id=?", - "delete": "DELETE FROM evidence WHERE evidence_id=?", - "origin": "UPDATE evidence_origins SET raw_meta_json='{\"new\":true}' " - "WHERE evidence_id=?", - "origin_delete": "DELETE FROM evidence_origins WHERE evidence_id=?", - }[mutation] - db.execute(sql, (target.evidence_id,)) - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt) - - -@pytest.mark.parametrize("tracking", ["missing", "altered"]) -def test_missing_or_modified_trigger_uses_full_validation(env, tracking): - records, receipt = seal(env) - with sqlite3.connect(env.database) as db: - db.execute("DROP TRIGGER model_content_seal_evidence_update") - if tracking == "altered": - db.execute( - "CREATE TRIGGER model_content_seal_evidence_update " - "AFTER UPDATE ON evidence BEGIN SELECT 1; END" - ) - db.execute( - "UPDATE evidence SET verbatim='untracked edit' WHERE evidence_id=?", - (records[-1].evidence_id,), - ) - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt) - - -def test_legacy_unsealed_receipt_still_checks_full_scope(env): - records = many_records(env, 6) - receipt = env.permissions.grant( - env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True - ) - with sqlite3.connect(env.database) as db: - db.execute("DELETE FROM model_content_scope_seals") - db.execute("DELETE FROM model_content_scope_members") - with patch.object( - env.permissions, "_evidence_batch", wraps=env.permissions._evidence_batch - ) as batch: - assert env.check(receipt) == receipt - assert sum(len(call.args[1]) for call in batch.call_args_list) == 6 - - -def test_seal_never_substitutes_for_current_source_or_profile(env): - _, receipt = seal(env) - env.root.rename(env.root.with_name("old-root")) - env.root.mkdir() - with pytest.raises(ModelContentPermissionError, match="source_"): - env.check(receipt) - - -def test_corrupt_receipt_with_same_revision_cannot_reuse_seal(env): - _, receipt = seal(env) - with sqlite3.connect(env.database) as db: - db.execute( - "UPDATE model_content_permissions SET " - "snapshot_json=json_set(snapshot_json,'$.evidence[0].content_hash',?)", - ("e" * 64,), - ) - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt) diff --git a/memhub/apps/core/tests/test_model_content_snapshot_cost.py b/memhub/apps/core/tests/test_model_content_snapshot_cost.py deleted file mode 100644 index 811acf5..0000000 --- a/memhub/apps/core/tests/test_model_content_snapshot_cost.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Reuse derived fingerprints only after reading every raw row in this transaction.""" - -import sqlite3 -from concurrent.futures import ThreadPoolExecutor -from unittest.mock import patch - -import pytest - -import one_memory.model_content_permissions as content -from one_memory.database_access import close_main_databases -from one_memory.model_content_permissions import ModelContentPermissionError -from tests.test_model_content_bulk import many_records -from tests.test_model_content_permissions import USER -from tests.test_model_content_permissions import env as env - - -def test_warm_scope_reuses_derivation_but_reads_complete_evidence_and_origins(env, monkeypatch): - records = many_records(env, 40) - receipt = env.permissions.grant( - env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True - ) - connects, statements = [], [] - original_connect = sqlite3.connect - - def connect(*args, **kwargs): - db = original_connect(*args, **kwargs) - connects.append(db) - db.set_trace_callback(statements.append) - return db - - close_main_databases() # pooled connections would skip the patch - - monkeypatch.setattr(sqlite3, "connect", connect) - with patch.object( - content, "model_content_evidence_snapshot", wraps=content.model_content_evidence_snapshot - ) as derive: - assert env.check(receipt) == receipt - # A fresh connection per check, so each transaction is observed. - close_main_databases() - assert env.check(receipt) == receipt - assert derive.call_count == 2, "Only the actual supplied input needs fresh derivation" - assert len(connects) == 2 - assert sum("FROM evidence " in sql for sql in statements) == 2 - assert sum("FROM evidence_origins " in sql for sql in statements) == 2 - assert all("verbatim" in sql for sql in statements if "FROM evidence " in sql) - assert all("raw_meta_json" in sql for sql in statements if "FROM evidence_origins " in sql) - - -@pytest.mark.parametrize("mutation", ["body", "author", "time", "origin", "delete", "user"]) -def test_warm_cache_detects_unrequested_raw_mutations_without_hash_revision_changes(env, mutation): - records = many_records(env, 4) - receipt = env.permissions.grant( - env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True - ) - assert env.check(receipt) == receipt - sql = { - "body": "UPDATE evidence SET verbatim='changed raw text' WHERE evidence_id=?", - "author": "UPDATE evidence SET written_by='unknown' WHERE evidence_id=?", - "time": "UPDATE evidence SET created_at='2026-09-01T00:00:00Z' WHERE evidence_id=?", - "origin": ( - "UPDATE evidence_origins SET raw_meta_json='{\"changed\":true}' WHERE evidence_id=?" - ), - "delete": "DELETE FROM evidence WHERE evidence_id=?", - "user": "UPDATE evidence SET user_id='other' WHERE evidence_id=?", - }[mutation] - with sqlite3.connect(env.database) as db: - db.execute(sql, (records[-1].evidence_id,)) - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt) - - -def test_derived_cache_is_bounded_and_store_local(env, monkeypatch): - monkeypatch.setattr(content, "_EVIDENCE_SNAPSHOT_CACHE_LIMIT", 1, raising=False) - records = many_records(env, 4) - receipt = env.permissions.grant( - env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True - ) - assert env.check(receipt) == receipt - assert len(env.permissions._evidence_snapshot_cache) <= 1 - fresh = content.ModelContentPermissionStore(env.database) - assert len(fresh._evidence_snapshot_cache) == 0 - assert ( - fresh.check( - user_id=USER, - expected_revision=receipt.revision, - expected_revocation_revision=receipt.revocation_revision, - profile=env.profile, - evidence=(env.record,), - ) - == receipt - ) - assert len(fresh._evidence_snapshot_cache) <= 1 - - -def test_derived_cache_payload_limit_can_disable_retention(env, monkeypatch): - monkeypatch.setattr(content, "_EVIDENCE_SNAPSHOT_CACHE_BYTES", 1, raising=False) - receipt = env.grant() - assert env.check(receipt) == receipt - assert len(env.permissions._evidence_snapshot_cache) == 0 - - -def test_larger_realistic_receipt_fits_bounded_parse_cache(env): - from tests.test_model_content_parse_cost import ( - repeated_receipt_measurement, - synthetic_large_receipt, - ) - - synthetic_large_receipt(env, count=45_419) - result = repeated_receipt_measurement(env.permissions, repeats=2) - assert result["snapshot_parses"] == 1 - - -def test_large_grant_builds_a_valid_scope_seal_in_bounded_batches(env, monkeypatch): - records = many_records(env, 1001) - snapshot = env.snapshot(records=tuple(records)) - receipt = env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True) - - with sqlite3.connect(env.database) as db: - assert db.execute( - "SELECT count(*) FROM model_content_scope_members WHERE user_id=? AND revision=?", - (receipt.user_id, receipt.revision), - ).fetchone()[0] == len(records) - assert ( - db.execute( - "SELECT valid FROM model_content_scope_seals WHERE user_id=? AND revision=?", - (receipt.user_id, receipt.revision), - ).fetchone()[0] - == 1 - ) - - def must_not_rescan(*_args, **_kwargs): - raise AssertionError("a sealed large scope must not rescan every Evidence row") - - monkeypatch.setattr(env.permissions, "_validate_snapshot", must_not_rescan) - assert env.check(receipt, records=(records[0],)) == receipt - - -def test_cached_metadata_cannot_hide_corrupt_origin_json(env): - receipt = env.grant() - assert env.check(receipt) == receipt - with sqlite3.connect(env.database) as db: - db.execute("UPDATE evidence_origins SET raw_meta_json='not-json'") - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt) - - -@pytest.mark.parametrize("mutation", ["insert", "delete", "version", "kind"]) -def test_cached_scope_checks_entire_origin_row_membership(env, mutation): - records = many_records(env, 4) - receipt = env.permissions.grant( - env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True - ) - assert env.check(receipt) == receipt - other = records[-1] - with sqlite3.connect(env.database) as db: - if mutation == "insert": - db.execute( - "INSERT INTO evidence_origins " - "(evidence_id,origin,raw_meta_json,source_version,observed_at) " - "VALUES (?,'manual_import','{}',NULL,?)", - (other.evidence_id, other.imported_at.isoformat()), - ) - else: - sql = { - "delete": "DELETE FROM evidence_origins WHERE evidence_id=? AND origin='file'", - "version": ( - "UPDATE evidence_origins SET source_version='changed' " - "WHERE evidence_id=? AND origin='file'" - ), - "kind": ( - "UPDATE evidence_origins SET origin='unknown' " - "WHERE evidence_id=? AND origin='file'" - ), - }[mutation] - db.execute(sql, (other.evidence_id,)) - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt) - - -def test_old_transaction_cannot_poison_new_transaction_derivation(env, monkeypatch): - receipt = env.grant() - old_batch = env.permissions._evidence_batch - changed = False - - def write_while_old_snapshot_open(connection, identifiers): - nonlocal changed - if identifiers and not changed: - changed = True - with sqlite3.connect(env.database) as db: - db.execute("UPDATE evidence SET verbatim='changed without revision'") - # A new check is another caller, so it runs on a thread of its own. - with ThreadPoolExecutor(1) as other_caller: - later = other_caller.submit(env.check, receipt) - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - later.result() - yield from old_batch(connection, identifiers) - - with monkeypatch.context() as scoped: - scoped.setattr(env.permissions, "_evidence_batch", write_while_old_snapshot_open) - assert env.check(receipt) == receipt - assert changed - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt) - - -def test_cache_key_is_not_delimiter_ambiguous(env): - receipt = env.grant() - assert env.check(receipt) == receipt - with sqlite3.connect(env.database) as db: - db.execute("UPDATE evidence SET workspace='a,b',project='c'") - updated = env.evidence.get(env.record.evidence_id) - next_receipt = env.permissions.grant( - env.snapshot(records=(updated,)), expected_revision=1, user_confirmed=True - ) - assert env.check(next_receipt, records=(updated,)) == next_receipt - with sqlite3.connect(env.database) as db: - db.execute("UPDATE evidence SET workspace='a',project='b,c'") - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(next_receipt, records=(updated,)) diff --git a/memhub/apps/core/tests/test_model_policy_activation.py b/memhub/apps/core/tests/test_model_policy_activation.py deleted file mode 100644 index 3eff09e..0000000 --- a/memhub/apps/core/tests/test_model_policy_activation.py +++ /dev/null @@ -1,422 +0,0 @@ -import sqlite3 -import threading -from concurrent.futures import ThreadPoolExecutor -from uuid import uuid4 - -import pytest - -from one_memory import continuous_policy as policy -from one_memory.model_policy_activation import ModelPolicyActivation -from one_memory.model_profiles import ModelCapabilities, ModelProfileInput, ModelProfilePatch -from one_memory.secrets import SecretStoreUnavailableError -from tests.test_model_content_permissions import env as env # noqa: F401 - - -def request(env, **overrides): - values = dict( - operation_id=str(uuid4()), - profile_id=env.profile.profile_id, - profile_revision=env.profile.revision, - expected_policy_revision=0, - expected_legacy_revision=0, - backlog_mode="none", - user_confirmed=True, - ) - values.update(overrides) - return values - - -def service(env): - return ModelPolicyActivation(env.database, env.secrets) - - -def new_profile(env, *, cloud=False, key=None): - values = dict( - name="Other", - provider_kind="openai_compatible" if cloud else "ollama", - base_url="https://model.example/v1" if cloud else "http://127.0.0.1:11434", - model="other-model", - embedding_provider_kind="local_onnx", - embedding_model="fixture-local-vector", - embedding_dimensions=512, - ) - if key is not None: - values["api_key"] = key - profile = env.profiles.create(ModelProfileInput(**values), env.secrets) - env.profiles.record_capabilities( - profile.profile_id, - expected_revision=profile.revision, - capabilities=ModelCapabilities( - endpoint_reachable=True, model_available=True, embedding_available=True - ), - secrets=env.secrets, - ) - return profile - - -def test_current_is_read_only_without_policy_tables(env): - before = env.database.read_bytes() - assert service(env).current() is None - assert env.database.read_bytes() == before - with sqlite3.connect(env.database) as db: - assert not db.execute( - "SELECT 1 FROM sqlite_master WHERE name='continuous_policy_history'" - ).fetchone() - assert not db.execute( - "SELECT 1 FROM sqlite_master WHERE name='evidence_ingest_events'" - ).fetchone() - - -def test_switch_and_policy_commit_together_without_old_activate(env, monkeypatch): - other = new_profile(env) - monkeypatch.setattr(env.profiles, "activate", lambda *args: pytest.fail("old activate")) - result = service(env).enable(**request(env, profile_id=other.profile_id)) - assert result.created is True - assert result.policy.profile.profile_id == other.profile_id - assert env.profiles.active_identity() == (other.profile_id, 1) - assert service(env).current() == result.policy - assert env.permissions.receipt(user_id="local") is None - - -def test_policy_append_failure_rolls_back_active_and_initialization(env, monkeypatch): - other = new_profile(env) - monkeypatch.setattr( - policy, "_append", lambda *args: (_ for _ in ()).throw(RuntimeError("forced")) - ) - with pytest.raises(RuntimeError): - service(env).enable(**request(env, profile_id=other.profile_id)) - assert env.profiles.active_identity() == (env.profile.profile_id, 1) - assert service(env).current() is None - with sqlite3.connect(env.database) as db: - assert not db.execute( - "SELECT 1 FROM sqlite_master WHERE name='evidence_ingest_events'" - ).fetchone() - - -def test_duplicate_operation_returns_same_receipt_without_reactivation(env): - active = service(env) - args = request(env) - first = active.enable(**args) - second = active.enable(**args) - assert first.policy == second.policy - assert second.created is False - with sqlite3.connect(env.database) as db: - assert db.execute("SELECT COUNT(*) FROM continuous_policy_history").fetchone()[0] == 1 - other = new_profile(env) - env.profiles.activate(other.profile_id, env.secrets) - with pytest.raises(RuntimeError): - active.enable(**args) - assert env.profiles.active_identity() == (other.profile_id, 1) - - -def test_duplicate_operation_payload_and_superseded_revoke_rejected(env): - active = service(env) - args = request(env) - active.enable(**args) - with pytest.raises(RuntimeError, match="payload_changed"): - active.enable(**{**args, "backlog_mode": "include"}) - active.transition( - action="revoke", expected_policy_revision=1, operation_id=str(uuid4()), user_confirmed=True - ) - with pytest.raises(RuntimeError, match="superseded"): - active.enable(**args) - assert active.current().state == "revoked" - - -@pytest.mark.parametrize( - "change", ["revision", "capability", "version", "missing_key", "unreadable_key"] -) -def test_model_preconditions_fail_closed_and_preserve_old_active(env, change): - other = new_profile( - env, - cloud=change in {"missing_key", "unreadable_key"}, - key="test-only-key" if change == "unreadable_key" else None, - ) - if change == "revision": - env.profiles.update( - other.profile_id, ModelProfilePatch(expected_revision=1, model="changed"), env.secrets - ) - elif change in {"capability", "version"}: - with sqlite3.connect(env.database) as db: - if change == "capability": - db.execute( - "UPDATE model_profiles SET model_available=0 WHERE profile_id=?", - (other.profile_id,), - ) - else: - db.execute( - "UPDATE model_profile_versions SET model='tampered' WHERE profile_id=?", - (other.profile_id,), - ) - elif change == "unreadable_key": - env.secrets.delete(env.profiles.secret_ref(other.profile_id)) - with pytest.raises(RuntimeError): - service(env).enable(**request(env, profile_id=other.profile_id)) - assert env.profiles.active_identity() == (env.profile.profile_id, 1) - assert service(env).current() is None - - -def test_stale_legacy_revision_rejects_switch_and_old_revoked_grant_is_not_modified(env): - old = env.grant() - revoked = env.permissions.revoke( - user_id="local", expected_revision=old.revision, user_confirmed=True - ) - other = new_profile(env) - with pytest.raises(RuntimeError, match="policy_revision_changed"): - service(env).enable( - **request(env, profile_id=other.profile_id, expected_legacy_revision=old.revision) - ) - assert env.profiles.active_identity() == (env.profile.profile_id, 1) - result = service(env).enable( - **request(env, profile_id=other.profile_id, expected_legacy_revision=revoked.revision) - ) - assert result.policy.legacy_revision == revoked.revision - assert env.permissions.receipt(user_id="local") == revoked - - -def test_pause_resume_revoke_cas_and_resume_does_not_switch_model(env): - active = service(env) - active.enable(**request(env)) - op = str(uuid4()) - paused = active.transition( - action="pause", expected_policy_revision=1, operation_id=op, user_confirmed=True - ) - assert paused.policy.state == "paused" - assert ( - active.transition( - action="pause", expected_policy_revision=1, operation_id=op, user_confirmed=True - ).created - is False - ) - other = new_profile(env) - env.profiles.activate(other.profile_id, env.secrets) - with pytest.raises(RuntimeError): - active.transition( - action="resume", - expected_policy_revision=2, - operation_id=str(uuid4()), - user_confirmed=True, - ) - assert active.current().state == "paused" - active.transition( - action="revoke", expected_policy_revision=2, operation_id=str(uuid4()), user_confirmed=True - ) - with pytest.raises(RuntimeError, match="requires_new_enable"): - active.transition( - action="resume", - expected_policy_revision=3, - operation_id=str(uuid4()), - user_confirmed=True, - ) - - -def test_same_operation_concurrent_submission_commits_once(env): - args = request(env) - with ThreadPoolExecutor(max_workers=2) as pool: - results = list(pool.map(lambda _: service(env).enable(**args), range(2))) - assert sum(result.created for result in results) == 1 - assert results[0].policy == results[1].policy - - -def test_no_sources_can_enable_future_and_backlog_is_explicit(env): - with sqlite3.connect(env.database) as db: - db.execute("DELETE FROM evidence") - db.execute("DELETE FROM source_grants") - result = service(env).enable(**request(env)) - assert result.policy.after_seq == 0 - assert result.policy.backlog_events == () - assert result.policy.state == "enabled" - - -def test_pre_d57_none_policy_is_upgraded_once_to_compact_backlog(env): - activation = service(env) - old = activation.enable(**request(env)).policy - old = old.model_copy(update={"changed_at": "2026-10-06T12:00:00+00:00"}) - with sqlite3.connect(env.database) as db: - db.execute( - "UPDATE continuous_policy_history SET receipt_json=? WHERE user_id=? AND revision=?", - (old.model_dump_json(), old.user_id, old.revision), - ) - - upgraded = activation.upgrade_legacy_none_policy() - - assert upgraded is not None and upgraded.created - assert upgraded.policy.backlog_mode == "include" - assert upgraded.policy.automatic_backlog_upgrade is True - assert upgraded.policy.backlog_count == 1 - assert upgraded.policy.backlog_conversation_count == 1 - assert activation.upgrade_legacy_none_policy() is None - - -@pytest.mark.parametrize( - ("failure", "error_code"), - [ - ( - SecretStoreUnavailableError("Operating system secure storage read backoff"), - "secure_storage_unavailable", - ), - (RuntimeError("unexpected startup failure"), "legacy_backlog_upgrade_failed"), - ], -) -def test_legacy_policy_upgrade_defers_startup_failures(env, monkeypatch, failure, error_code): - activation = service(env) - old = activation.enable(**request(env)).policy.model_copy( - update={"changed_at": "2026-10-06T12:00:00+00:00"} - ) - with sqlite3.connect(env.database) as db: - db.execute( - "UPDATE continuous_policy_history SET receipt_json=? WHERE user_id=? AND revision=?", - (old.model_dump_json(), old.user_id, old.revision), - ) - - events = [] - monkeypatch.setattr(activation, "enable", lambda **_kwargs: (_ for _ in ()).throw(failure)) - monkeypatch.setattr( - "one_memory.model_policy_activation.diagnostic_event", - lambda **event: events.append(event), - ) - - assert activation.upgrade_legacy_none_policy() is None - assert len(events) == 1 - assert events[0]["level"] == "warn" - assert events[0]["event"] == "policy.legacy_backlog_upgrade_deferred" - assert events[0]["error_code"] == error_code - assert activation.current() == old - - -def test_post_d57_explicit_none_policy_is_not_upgraded(env): - activation = service(env) - current = activation.enable(**request(env)).policy - assert current.backlog_mode == "none" - assert activation.upgrade_legacy_none_policy() is None - assert activation.current() == current - - -@pytest.mark.parametrize( - "field,value", - [ - ("user_confirmed", False), - ("user_confirmed", 1), - ("backlog_mode", "all"), - ("profile_revision", True), - ("operation_id", "invalid"), - ], -) -def test_invalid_input_never_initializes_or_switches(env, field, value): - with pytest.raises((RuntimeError, ValueError)): - service(env).enable(**request(env, **{field: value})) - assert service(env).current() is None - - -def test_cloud_credential_is_checked_without_keychain_write(env, monkeypatch): - other = new_profile(env, cloud=True, key="fixture-key") - monkeypatch.setattr(env.secrets, "put", lambda *args: pytest.fail("no credential write")) - monkeypatch.setattr(env.secrets, "delete", lambda *args: pytest.fail("no credential delete")) - receipt = service(env).enable(**request(env, profile_id=other.profile_id)) - assert receipt.policy.profile.profile_id == other.profile_id - - -def test_profile_change_after_secret_preflight_does_not_commit_policy(env, monkeypatch): - other = new_profile(env, cloud=True, key="fixture-key") - original_has = env.secrets.has - - def changed(secret_ref): - answer = original_has(secret_ref) - with sqlite3.connect(env.database) as db: - db.execute( - "UPDATE model_profiles SET model_available=0 WHERE profile_id=?", - (other.profile_id,), - ) - return answer - - monkeypatch.setattr(env.secrets, "has", changed) - with pytest.raises(RuntimeError, match="probe_required"): - service(env).enable(**request(env, profile_id=other.profile_id)) - assert env.profiles.active_identity() == (env.profile.profile_id, 1) - assert service(env).current() is None - - -def test_same_operation_does_not_switch_or_accept_lost_probe_readiness(env): - active = service(env) - args = request(env) - active.enable(**args) - with sqlite3.connect(env.database) as db: - db.execute("UPDATE model_profiles SET embedding_available=0") - with pytest.raises(RuntimeError, match="probe_required"): - active.enable(**args) - assert active.current().revision == 1 - - -def test_two_different_operations_same_cas_only_one_commits(env): - requests = [request(env), request(env)] - - def submit(value): - try: - return service(env).enable(**value).created - except policy.ContinuousPolicyError: - return False - - with ThreadPoolExecutor(max_workers=2) as pool: - assert sum(pool.map(submit, requests)) == 1 - assert service(env).current().revision == 1 - - -def test_backlog_scan_does_not_hold_the_single_writer(env, monkeypatch): - active = service(env) - entered = threading.Event() - release = threading.Event() - original = policy.snapshot_backlog - - def paused_snapshot(db, *, user_id="local"): - snapshot = original(db, user_id=user_id) - entered.set() - assert release.wait(3) - return snapshot - - monkeypatch.setattr(policy, "snapshot_backlog", paused_snapshot) - with ThreadPoolExecutor(max_workers=1) as pool: - pending = pool.submit(active.enable, **request(env, backlog_mode="include")) - if not entered.wait(3): - release.set() - pending.result(timeout=3) - pytest.fail("backlog snapshot was not reached") - writer = sqlite3.connect(env.database, timeout=0.2, isolation_level=None) - try: - writer.execute("BEGIN IMMEDIATE") - writer.execute("ROLLBACK") - finally: - writer.close() - release.set() - assert pending.result(timeout=3).created is True - - -def test_activation_helper_is_transactional_and_restores_row_factory(env, monkeypatch): - from one_memory.model_content_permissions import ModelContentPermissionStore - from one_memory.model_profiles import ModelProfileStore - - other = new_profile(env) - snapshot = env.profiles.snapshot(other.profile_id, 1) - with sqlite3.connect(env.database) as db: - with pytest.raises(RuntimeError, match="caller_transaction_required"): - ModelProfileStore.activate_in_connection(db, snapshot, expected_secret_ref=None) - db.execute("BEGIN IMMEDIATE") - monkeypatch.setattr( - ModelContentPermissionStore, - "_validate_profile", - lambda *args: (_ for _ in ()).throw(RuntimeError("recipient rejected")), - ) - with pytest.raises(RuntimeError, match="recipient rejected"): - ModelProfileStore.activate_in_connection(db, snapshot, expected_secret_ref=None) - assert db.row_factory is None - assert ( - db.execute("SELECT profile_id FROM model_profiles WHERE is_active=1").fetchone()[0] - == env.profile.profile_id - ) - assert db.in_transaction - - -def test_missing_database_get_does_not_create_it(tmp_path, env): - database = tmp_path / "must-not-be-created.sqlite3" - with pytest.raises(sqlite3.Error): - ModelPolicyActivation(database, env.secrets).current() - assert not database.exists() diff --git a/memhub/apps/core/tests/test_model_policy_routes.py b/memhub/apps/core/tests/test_model_policy_routes.py deleted file mode 100644 index 2d171d5..0000000 --- a/memhub/apps/core/tests/test_model_policy_routes.py +++ /dev/null @@ -1,206 +0,0 @@ -import sqlite3 -from uuid import uuid4 - -import pytest -from fastapi import Depends, FastAPI, Header, HTTPException -from fastapi.testclient import TestClient - -from one_memory.model_policy_activation import ModelPolicyActivation -from one_memory.model_policy_routes import continuous_policy_router -from tests.test_model_content_permissions import env as env # noqa: F401 -from tests.test_model_policy_activation import request - -PREFIX = "/api/v1/continuous-processing-policy" -SAFE_KEYS = { - "schema_version", - "user_id", - "revision", - "epoch", - "state", - "after_seq", - "profile_id", - "profile_revision", - "legacy_revision", - "backlog_mode", - "backlog_count", - "operation_id", - "changed_at", -} - - -def client(env, *, enabled=False, callback=None): - app = FastAPI() - app.state.model_policy_activation = ModelPolicyActivation(env.database, env.secrets) - - def auth(authorization: str | None = Header(None)): - if authorization != "Bearer fixture": - raise HTTPException(401, "unauthorized") - - app.include_router( - continuous_policy_router([Depends(auth)], on_enabled=callback, enabled=enabled) - ) - return TestClient(app, headers={"Authorization": "Bearer fixture"}) - - -def test_get_does_not_initialize_and_capability_defaults_false(env): - with client(env) as http: - data = http.get(PREFIX).json() - assert data == { - "available": True, - "policy": None, - "legacy_revision": 0, - "capability": {"enable": False, "reason": "processing_guards_not_integrated"}, - "backlog_upgrade_notice": { - "pending": False, - "epoch": None, - "conversation_count": 0, - "profile_id": None, - }, - } - assert http.post(PREFIX + "/enable", json=request(env)).status_code == 409 - with sqlite3.connect(env.database) as db: - assert not db.execute( - "SELECT 1 FROM sqlite_master WHERE name='continuous_policy_history'" - ).fetchone() - - -@pytest.mark.parametrize("route", ["", "/enable", "/pause", "/resume", "/revoke"]) -def test_all_routes_require_auth(env, route): - with client(env, enabled=True) as http: - response = ( - http.get(PREFIX, headers={"Authorization": "bad"}) - if not route - else http.post(PREFIX + route, json=request(env), headers={"Authorization": "bad"}) - ) - assert response.status_code == 401 - - -def test_factory_refuses_no_auth_dependency(): - with pytest.raises(ValueError, match="authentication_required"): - continuous_policy_router([]) - - -@pytest.mark.parametrize( - "mutation", - [ - {"backlog_mode": None}, - {"user_confirmed": "true"}, - {"profile_revision": True}, - {"profile_revision": 1.0}, - {"operation_id": "not-uuid"}, - {"api_key": "forbidden"}, - ], -) -def test_request_validation_does_not_coerce_and_rejects_secret_fields(env, mutation): - with client(env, enabled=True) as http: - value = {**request(env), **mutation} - assert http.post(PREFIX + "/enable", json=value).status_code == 422 - assert http.get(PREFIX).json()["policy"] is None - - -def test_backlog_is_required_not_defaulted(env): - with client(env, enabled=True) as http: - value = request(env) - del value["backlog_mode"] - assert http.post(PREFIX + "/enable", json=value).status_code == 422 - - -def test_enable_and_transition_safe_shape_and_callback_once_after_commit(env): - calls = [] - - async def notified(receipt): - assert ModelPolicyActivation(env.database, env.secrets).current() == receipt - calls.append(receipt.revision) - - with client(env, enabled=True, callback=notified) as http: - value = request(env, backlog_mode="include") - first = http.post(PREFIX + "/enable", json=value) - assert first.status_code == 200, first.text - assert set(first.json()) == SAFE_KEYS - assert first.json()["backlog_count"] == 1 - assert "secret_ref" not in first.text and '"profile":' not in first.text - assert http.post(PREFIX + "/enable", json=value).json() == first.json() - assert calls == [1] - for revision, action in [(1, "pause"), (2, "resume"), (3, "revoke")]: - result = http.post( - PREFIX + "/" + action, - json={ - "operation_id": str(uuid4()), - "expected_policy_revision": revision, - "user_confirmed": True, - }, - ) - assert result.status_code == 200, result.text - assert set(result.json()) == SAFE_KEYS - assert calls == [1, 3] - assert http.get(PREFIX).json()["policy"]["state"] == "revoked" - - -def test_status_exposes_only_automatic_backlog_upgrade_notice(env): - activation = service = ModelPolicyActivation(env.database, env.secrets) - old = activation.enable(**request(env)).policy - old = old.model_copy(update={"changed_at": "2026-10-06T12:00:00+00:00"}) - with sqlite3.connect(env.database) as db: - db.execute( - "UPDATE continuous_policy_history SET receipt_json=? WHERE user_id=? AND revision=?", - (old.model_dump_json(), old.user_id, old.revision), - ) - upgraded = activation.upgrade_legacy_none_policy() - assert upgraded is not None - - app = FastAPI() - app.state.model_policy_activation = service - - def auth(): - return None - - app.include_router(continuous_policy_router([Depends(auth)], enabled=True)) - with TestClient(app) as http: - notice = http.get(PREFIX).json()["backlog_upgrade_notice"] - assert notice == { - "pending": True, - "epoch": upgraded.policy.epoch, - "conversation_count": 1, - "profile_id": env.profile.profile_id, - } - - -def test_notification_failure_does_not_rollback_or_repeat_enable(env): - calls = [] - - async def fail_notify(receipt): - calls.append(receipt.revision) - raise RuntimeError("private runtime failure") - - with client(env, enabled=True, callback=fail_notify) as http: - value = request(env) - assert http.post(PREFIX + "/enable", json=value).status_code == 200 - assert http.post(PREFIX + "/enable", json=value).status_code == 200 - assert calls == [1] - assert http.get(PREFIX).json()["policy"]["state"] == "enabled" - - -def test_gate_off_allows_revoke_but_not_resume(env): - ModelPolicyActivation(env.database, env.secrets).enable(**request(env)) - with client(env) as http: - value = { - "operation_id": str(uuid4()), - "expected_policy_revision": 1, - "user_confirmed": True, - } - assert http.post(PREFIX + "/pause", json=value).status_code == 200 - value.update(operation_id=str(uuid4()), expected_policy_revision=2) - assert http.post(PREFIX + "/resume", json=value).status_code == 409 - assert http.post(PREFIX + "/revoke", json=value).status_code == 200 - - -def test_store_failure_has_no_raw_errors_or_fake_empty_policy(env, monkeypatch): - monkeypatch.setattr( - ModelPolicyActivation, - "read_current", - lambda self: (_ for _ in ()).throw(sqlite3.OperationalError("secret raw error")), - ) - with client(env) as http: - result = http.get(PREFIX) - assert result.status_code == 503 - assert "secret" not in result.text and '"policy":null' not in result.text diff --git a/memhub/apps/core/tests/test_model_processing_permissions.py b/memhub/apps/core/tests/test_model_processing_permissions.py deleted file mode 100644 index 2ff914e..0000000 --- a/memhub/apps/core/tests/test_model_processing_permissions.py +++ /dev/null @@ -1,59 +0,0 @@ -from types import SimpleNamespace - -import pytest - -from one_memory.hy_memory_bridge import HyMemoryBridgeError -from one_memory.memory_server.config_state import ConfigStore -from one_memory.model_processing_permission_guard import ModelProcessingPermissionGuard -from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind - - -def snapshot(profile_id: str = "profile") -> ModelProfileSnapshot: - return ModelProfileSnapshot( - profile_id=profile_id, - revision=1, - provider_kind=ProviderKind.OLLAMA, - base_url="http://127.0.0.1:11434", - model="test-model", - embedding_model="test-embedding", - embedding_provider_kind=ProviderKind.OLLAMA, - embedding_base_url="http://127.0.0.1:11434", - timeout_seconds=30, - max_concurrency=1, - context_window=None, - has_api_key=False, - ) - - -def test_lease_reads_current_allowed_before_each_boundary(tmp_path) -> None: - config = ConfigStore(tmp_path) - profile = snapshot() - profiles = SimpleNamespace(active_identity=lambda: (profile.profile_id, profile.revision)) - guard = ModelProcessingPermissionGuard(config, SimpleNamespace(), profiles) - task = SimpleNamespace( - task_id="task", - user_id="local", - profile_snapshot=profile, - ) - - with pytest.raises(HyMemoryBridgeError, match="model_content_permission_required"): - guard.lease(task, (), None) - - config.set_model_content_allowed(True) - lease = guard.lease(task, (), None) - lease.recheck() - - config.set_model_content_allowed(False) - with pytest.raises(HyMemoryBridgeError, match="model_content_permission_required"): - lease.recheck() - - -def test_lease_refuses_a_task_profile_that_is_no_longer_active(tmp_path) -> None: - config = ConfigStore(tmp_path) - config.set_model_content_allowed(True) - profile = snapshot() - profiles = SimpleNamespace(active_identity=lambda: ("new-profile", 1)) - guard = ModelProcessingPermissionGuard(config, SimpleNamespace(), profiles) - - with pytest.raises(HyMemoryBridgeError, match="model_content_profile_changed"): - guard.check(profile) diff --git a/memhub/apps/core/tests/test_model_request_budget.py b/memhub/apps/core/tests/test_model_request_budget.py index 6f39c4f..ec0fcbd 100644 --- a/memhub/apps/core/tests/test_model_request_budget.py +++ b/memhub/apps/core/tests/test_model_request_budget.py @@ -96,44 +96,3 @@ def test_runtime_limit_is_opt_in_and_bounds_are_validated(): assert Settings(_env_file=None).model_request_limit is None with pytest.raises(ValueError): Settings(_env_file=None, model_request_limit=0) - - -def test_api_wires_optional_budget_to_the_real_permission_factory(tmp_path, monkeypatch): - import importlib - from contextlib import asynccontextmanager - from types import SimpleNamespace - - module = importlib.import_module("one_memory.api") - captured = [] - marker = object() - - def create(resolve, **kwargs): - captured.append(kwargs.get("http_transport")) - - @asynccontextmanager - async def enter(*args): - yield marker - - return enter - - async def scenario(): - app = SimpleNamespace( - state=SimpleNamespace( - model_request_budget=budget(tmp_path), - memory_processing_supervisor=SimpleNamespace(_bridge=lambda *args: None), - ) - ) - monkeypatch.setattr( - module, "import_module", lambda name: SimpleNamespace(permission_bridge_factory=create) - ) - lease = SimpleNamespace(records=()) - async with module._permission_context(app, lease, None, tmp_path) as result: - assert result is marker - assert isinstance(captured[0], BudgetedModelTransport) - await captured[0].aclose() - app.state.model_request_budget = None - async with module._permission_context(app, lease, None, tmp_path): - pass - assert captured[1] is None - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_model_request_budget_integration.py b/memhub/apps/core/tests/test_model_request_budget_integration.py deleted file mode 100644 index 09b8359..0000000 --- a/memhub/apps/core/tests/test_model_request_budget_integration.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Real HY leaf retries plus durable budget accounting; no network or provider.""" - -import asyncio -import sqlite3 -import threading -from contextlib import suppress - -import httpx -import pytest -from hy_memory.agent.llm_provider import LLMRetryError - -from one_memory.config import Settings -from one_memory.database_access import close_main_databases -from one_memory.hy_permission_transport import HyPermissionTransport, HyPermissionTransportError -from one_memory.model_request_budget import ( - BudgetedModelTransport, - ModelRequestBudget, - ModelRequestBudgetError, -) -from tests.test_hy_permission_transport import Wire, lease, native -from tests.test_model_processing_permissions import Harness - - -@pytest.fixture(autouse=True) -def forbid_network(monkeypatch): - def denied(*args, **kwargs): - pytest.fail("Real network is forbidden in budget integration tests") - - monkeypatch.setattr("socket.socket.connect", denied) - monkeypatch.setattr("socket.getaddrinfo", denied) - - -def budget(database, limit=2, body_limit=4096): - return ModelRequestBudget( - database, request_limit=limit, body_byte_limit=body_limit, output_token_limit=64 - ) - - -def reservations(database): - with sqlite3.connect(database) as db: - return db.execute( - "SELECT sequence,body_bytes,output_tokens,reserved_at " - "FROM model_request_budget_events ORDER BY sequence" - ).fetchall() - - -@pytest.mark.parametrize("failure", ["http-status", "connection"]) -def test_native_hy_retries_consume_durable_budget_and_latch_denial(tmp_path, failure): - database = tmp_path / "budget.sqlite3" - limits = budget(database) - - def fail(request, _number): - if failure == "connection": - raise httpx.ConnectError("synthetic connection failure", request=request) - return httpx.Response(503) - - wire = Wire(fail) - - async def scenario(): - permission = lease() - provider, _ = native(permission) - provider._llm_config.max_retries = 6 - with pytest.raises(HyPermissionTransportError, match="budget_exceeded"): - async with HyPermissionTransport( - permission, http_transport=BudgetedModelTransport(limits, inner=wire) - ) as transport: - transport.bind_provider(provider) - with pytest.raises(HyPermissionTransportError, match="budget_exceeded"): - await provider.complete("synthetic sensitive content", max_tokens=16) - assert len(wire.requests) == 2 - assert transport.denied_code == "model_content_transport_budget_exceeded" - assert len(reservations(database)) == 2 - for call in ( - lambda: provider.complete("try again", max_tokens=1), - lambda: provider.complete_messages( - [{"role": "user", "content": "again"}], max_tokens=1 - ), - transport.seal, - ): - with pytest.raises(HyPermissionTransportError, match="budget_exceeded"): - await call() - assert len(wire.requests) == 2 - - # A fresh budget object and a fresh lease still see the consumed rows. - fresh_permission = lease() - fresh_provider, _ = native(fresh_permission) - fresh_wire = Wire() - with pytest.raises(HyPermissionTransportError, match="budget_exceeded"): - async with HyPermissionTransport( - fresh_permission, - http_transport=BudgetedModelTransport(budget(database), inner=fresh_wire), - ) as transport: - transport.bind_provider(fresh_provider) - await fresh_provider.complete("new lease", max_tokens=16) - assert fresh_wire.requests == [] - - asyncio.run(scenario()) - assert len(reservations(database)) == 2 - assert wire.closed == 1 - persisted = database.read_bytes() - assert b"synthetic sensitive content" not in persisted - assert b"synthetic-secret-never-log" not in persisted - - -def test_parallel_native_leases_share_one_budget_before_sending(tmp_path): - database = tmp_path / "budget.sqlite3" - budgets = [budget(database, limit=3) for _ in range(8)] - wires = [Wire() for _ in budgets] - - async def attempt(limits, wire): - permission = lease() - provider, _ = native(permission) - try: - async with HyPermissionTransport( - permission, http_transport=BudgetedModelTransport(limits, inner=wire) - ) as transport: - transport.bind_provider(provider) - await provider.complete("parallel synthetic request", max_tokens=16) - return True - except HyPermissionTransportError as error: - assert error.code == "model_content_transport_budget_exceeded" - return False - - async def scenario(): - return await asyncio.gather( - *(attempt(limits, wire) for limits, wire in zip(budgets, wires, strict=True)) - ) - - assert sum(asyncio.run(scenario())) == 3 - assert sum(len(wire.requests) for wire in wires) == len(reservations(database)) == 3 - - -def test_cancelled_physical_attempt_is_not_refunded_on_reopen(tmp_path): - database = tmp_path / "budget.sqlite3" - - async def scenario(): - entered = asyncio.Event() - - async def wait_forever(request): - entered.set() - await asyncio.Event().wait() - - transport = BudgetedModelTransport( - budget(database, limit=1), inner=httpx.MockTransport(wait_forever) - ) - async with httpx.AsyncClient(transport=transport) as client: - request = asyncio.create_task( - client.post("https://synthetic.test", json={"max_tokens": 16}) - ) - await asyncio.wait_for(entered.wait(), 2) - assert len(reservations(database)) == 1 - request.cancel() - with suppress(asyncio.CancelledError): - await request - with pytest.raises(ModelRequestBudgetError, match="exhausted"): - budget(database, limit=1).reserve(b'{"max_tokens":16}') - - asyncio.run(scenario()) - assert len(reservations(database)) == 1 - - -@pytest.mark.parametrize("violation", ["output", "body", "remote-embedding"]) -def test_payload_budget_denial_is_terminal_before_any_physical_send( - tmp_path, violation, monkeypatch -): - database = tmp_path / "budget.sqlite3" - wire = Wire() - monkeypatch.setattr("hy_memory.core.embed_service._truncate_for_embedding", lambda text: text) - - async def scenario(): - permission = lease() - provider, embedder = native(permission) - limits = budget(database, body_limit=100 if violation == "body" else 4096) - with pytest.raises(HyPermissionTransportError, match="budget_exceeded"): - async with HyPermissionTransport( - permission, http_transport=BudgetedModelTransport(limits, inner=wire) - ) as transport: - transport.bind_provider(provider) - transport.bind_embed_service(embedder) - with pytest.raises(HyPermissionTransportError, match="budget_exceeded"): - if violation == "remote-embedding": - await embedder.embed("synthetic embedding", use_cache=False) - else: - await provider.complete( - "synthetic body" * 20, - max_tokens=65 if violation == "output" else 16, - ) - with pytest.raises(HyPermissionTransportError, match="budget_exceeded"): - await provider.complete("smaller", max_tokens=1) - assert wire.requests == [] and reservations(database) == [] - - asyncio.run(scenario()) - - -def test_budget_store_failure_cannot_send_without_a_reservation(tmp_path, monkeypatch): - database = tmp_path / "budget.sqlite3" - limits = budget(database) - wire = Wire() - - def fail_to_open(*args, **kwargs): - raise sqlite3.OperationalError("synthetic inaccessible budget store") - - async def scenario(): - permission = lease() - provider, _ = native(permission) - with pytest.raises(LLMRetryError, match="hy_memory_model_request_failed"): - async with HyPermissionTransport( - permission, http_transport=BudgetedModelTransport(limits, inner=wire) - ) as transport: - transport.bind_provider(provider) - with monkeypatch.context() as scoped: - # Pooled connections would outlive the patch; start cold. - close_main_databases() - scoped.setattr(sqlite3, "connect", fail_to_open) - await provider.complete("must not send", max_tokens=16) - assert wire.requests == [] - - asyncio.run(scenario()) - assert reservations(database) == [] - - -def test_lease_rejection_precedes_budget_reservation(tmp_path): - database = tmp_path / "budget.sqlite3" - limits = budget(database) - wire = Wire() - - async def scenario(): - permission = lease() - provider, _ = native(permission) - with pytest.raises(HyPermissionTransportError, match="lease_denied"): - async with HyPermissionTransport( - permission, http_transport=BudgetedModelTransport(limits, inner=wire) - ) as transport: - transport.bind_provider(provider) - permission.revoked = True - await provider.complete("revoked request", max_tokens=16) - assert wire.requests == [] - - asyncio.run(scenario()) - assert reservations(database) == [] - - -@pytest.mark.parametrize("limit", [None, 2]) -def test_actual_core_startup_creates_budget_only_when_opted_in(tmp_path, monkeypatch, limit): - from one_memory.api import create_app - from one_memory.secrets import InMemorySecretStore - - monkeypatch.setattr("one_memory.model_presets._preset_data", lambda: None) - settings = Settings( - _env_file=None, - data_dir=tmp_path / "data", - source_home=tmp_path / "home", - automatic_memory_processing=False, - imports_enabled=False, - live_sync_enabled=False, - live_sync_watch_enabled=False, - model_request_limit=limit, - ) - app = create_app(settings, source_catalog={}, secret_store=InMemorySecretStore()) - - async def scenario(): - async with app.router.lifespan_context(app): - assert (app.state.model_request_budget is None) is (limit is None) - with sqlite3.connect(settings.data_dir / "one-memory.sqlite3") as db: - table = db.execute( - "SELECT name FROM sqlite_master WHERE name='model_request_budget_events'" - ).fetchone() - assert (table is None) is (limit is None) - assert db.execute("SELECT COUNT(*) FROM model_profiles").fetchone()[0] == 0 - assert db.execute("SELECT COUNT(*) FROM memory_tasks").fetchone()[0] == 0 - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("stop_mode", ["cancel", "cancel-shutdown"]) -def test_offthread_dispatch_drains_real_positive_check_without_late_queue( - tmp_path, monkeypatch, stop_mode -): - h = Harness(tmp_path) - h.grant() - supervisor = h.supervisor() - prepared = supervisor.prepare_conversation(h.record) - assert len(prepared) == 1 - real_guard = supervisor._guard_task - entered, release, exited = (threading.Event() for _ in range(3)) - checks = [] - - async def scenario(): - loop_thread = threading.get_ident() - - def checked(task): - assert threading.get_ident() != loop_thread - allowed = real_guard(task) - checks.append(allowed) - assert allowed - entered.set() - assert release.wait(4) - exited.set() - return allowed - - monkeypatch.setattr(supervisor, "_guard_task", checked) - pending = asyncio.create_task(supervisor.resume_off_thread()) - stopping = None - try: - assert await asyncio.to_thread(entered.wait, 3) - stopping = ( - asyncio.create_task(supervisor.shutdown()) - if stop_mode == "cancel-shutdown" - else pending - ) - await asyncio.sleep(0.02) - stopping.cancel() - await asyncio.sleep(0.02) - stopping.cancel() - await asyncio.sleep(0.02) - assert not pending.done() and not stopping.done() - assert len(supervisor._dispatch_checks) == 1 - finally: - release.set() - await asyncio.gather(*({pending, stopping} - {None}), return_exceptions=True) - await supervisor.shutdown() - assert checks == [True] and exited.is_set() - assert supervisor._dispatch_checks == set() - assert len(supervisor._queue) == 0 and supervisor._workers == {} - assert h.factory_calls == [] and h.bridge.calls == [] - assert h.tasks.list()[0].attempts == 0 - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_model_scope_lifecycle.py b/memhub/apps/core/tests/test_model_scope_lifecycle.py deleted file mode 100644 index add6770..0000000 --- a/memhub/apps/core/tests/test_model_scope_lifecycle.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Bound derived/temporary data without changing consent or original history.""" - -import sqlite3 -from concurrent.futures import ThreadPoolExecutor -from threading import Barrier -from unittest.mock import patch - -import pytest - -import one_memory.model_content_permissions as content -from one_memory.model_content_permissions import ModelContentPermissionError -from tests.test_model_content_permissions import USER, evidence_value -from tests.test_model_content_permissions import env as env - - -def rows(env, table): - with sqlite3.connect(env.database) as db: - return db.execute(f"SELECT * FROM {table} ORDER BY 1,2").fetchall() - - -def tracked_grant(env, revision): - return env.permissions.grant( - env.snapshot(), expected_revision=revision, user_confirmed=True, _track_scope=True - ) - - -def test_regrant_retires_only_old_derived_index_and_keeps_history(env): - receipts = [tracked_grant(env, revision) for revision in range(3)] - assert [(r[0], r[1]) for r in rows(env, "model_content_scope_seals")] == [(USER, 3)] - assert [(r[0], r[1]) for r in rows(env, "model_content_scope_members")] == [(USER, 3)] - for receipt in receipts: - assert env.permissions.receipt(user_id=USER, revision=receipt.revision) == receipt - with pytest.raises(ModelContentPermissionError, match="permission_changed"): - env.check(receipts[0]) - with patch.object( - env.permissions, "_evidence_batch", wraps=env.permissions._evidence_batch - ) as batch: - assert env.check(receipts[-1]) == receipts[-1] - assert sum(len(call.args[1]) for call in batch.call_args_list) == 1 - assert len(rows(env, "evidence")) == 1 - - -def test_revoke_retires_derived_index_without_deleting_receipts_or_other_users(env): - receipt = tracked_grant(env, 0) - with sqlite3.connect(env.database) as db: - db.execute( - "INSERT INTO model_content_scope_seals " - "SELECT 'other',revision,snapshot_hash,schema_version,valid " - "FROM model_content_scope_seals" - ) - db.execute( - "INSERT INTO model_content_scope_members " - "SELECT 'other',revision,evidence_id FROM model_content_scope_members" - ) - before = {table: rows(env, table) for table in ("evidence", "source_grants")} - revoked = env.permissions.revoke(user_id=USER, expected_revision=1, user_confirmed=True) - assert [r[0] for r in rows(env, "model_content_scope_seals")] == ["other"] - assert [r[0] for r in rows(env, "model_content_scope_members")] == ["other"] - assert env.permissions.receipt(user_id=USER, revision=1) == receipt - assert env.permissions.receipt(user_id=USER) == revoked - assert before == {table: rows(env, table) for table in before} - with pytest.raises(ModelContentPermissionError, match="permission_revoked"): - env.check(receipt) - - -def test_failed_replacement_rolls_back_index_cleanup_and_receipt(env, monkeypatch): - receipt = tracked_grant(env, 0) - tables = ( - "model_content_permissions", - "model_content_scope_seals", - "model_content_scope_members", - ) - before = {table: rows(env, table) for table in tables} - - def fail_record(*_args, **_kwargs): - raise sqlite3.OperationalError("controlled write failure") - - monkeypatch.setattr(content.model_content_seals, "record", fail_record) - with pytest.raises(ModelContentPermissionError, match="permission_store_unavailable"): - tracked_grant(env, 1) - assert before == {table: rows(env, table) for table in tables} - assert env.check(receipt) == receipt - - -def test_unsealed_replacement_does_not_inherit_prior_seal(env): - tracked_grant(env, 0) - receipt = env.permissions.grant(env.snapshot(), expected_revision=1, user_confirmed=True) - with sqlite3.connect(env.database) as db: - db.execute("DELETE FROM model_content_scope_seals") - db.execute("DELETE FROM model_content_scope_members") - assert rows(env, "model_content_scope_seals") == [] - assert rows(env, "model_content_scope_members") == [] - with sqlite3.connect(env.database) as db: - db.execute("UPDATE evidence SET verbatim='changed without revision'") - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt) - - -def test_preview_byte_budget_evicts_earliest_without_changing_survivor(env, monkeypatch): - snapshot = env.snapshot(future=True) - size = len(snapshot.model_dump_json().encode()) - monkeypatch.setattr(content, "_RETAINED_PREVIEW_BYTES", size * 2, raising=False) - previews = [env.permissions.retain_preview(snapshot) for _ in range(3)] - saved = rows(env, "model_content_previews") - assert len(saved) == 2 - assert {r[0] for r in saved} == {p["preview_id"] for p in previews[1:]} - assert sum(len(r[4].encode()) for r in saved) <= size * 2 - with pytest.raises(ModelContentPermissionError, match="preview_expired"): - env.permissions.grant_preview( - previews[0]["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True - ) - receipt = env.permissions.grant_preview( - previews[-1]["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True - ) - assert receipt.snapshot == snapshot and env.check(receipt) == receipt - - -def test_single_oversized_preview_does_not_delete_valid_previous_preview(env, monkeypatch): - snapshot = env.snapshot() - first = env.permissions.retain_preview(snapshot) - before = rows(env, "model_content_previews") - monkeypatch.setattr(content, "_RETAINED_PREVIEW_BYTES", 1, raising=False) - with pytest.raises(ModelContentPermissionError, match="content_scope_too_large"): - env.permissions.retain_preview(snapshot) - assert rows(env, "model_content_previews") == before - assert first["preview_id"] == before[0][0] - assert env.permissions.receipt(user_id=USER) is None - - -def test_preview_count_limit_and_expiry_keep_no_authority(env): - snapshot = env.snapshot() - first = env.permissions.retain_preview(snapshot) - with sqlite3.connect(env.database) as db: - db.execute("UPDATE model_content_previews SET expires_at='2000-01-01T00:00:00+00:00'") - previews = [env.permissions.retain_preview(snapshot) for _ in range(17)] - assert len(rows(env, "model_content_previews")) == 16 - for preview in (first, previews[0]): - with pytest.raises(ModelContentPermissionError, match="preview_expired"): - env.permissions.grant_preview( - preview["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True - ) - assert env.permissions.receipt(user_id=USER) is None - assert len(rows(env, "evidence")) == 1 - - -def test_preview_eviction_rolls_back_when_new_insert_fails(env, monkeypatch): - snapshot = env.snapshot() - monkeypatch.setattr( - content, "_RETAINED_PREVIEW_BYTES", len(snapshot.model_dump_json().encode()) - ) - first = env.permissions.retain_preview(snapshot) - with sqlite3.connect(env.database) as db: - db.execute( - "CREATE TRIGGER fail_preview_insert BEFORE INSERT ON model_content_previews " - "BEGIN SELECT RAISE(ABORT,'controlled preview failure'); END" - ) - before = rows(env, "model_content_previews") - with pytest.raises(ModelContentPermissionError, match="permission_store_unavailable"): - env.permissions.retain_preview(snapshot) - assert rows(env, "model_content_previews") == before - receipt = env.permissions.grant_preview( - first["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True - ) - assert env.check(receipt) == receipt - - -def test_concurrent_previews_respect_shared_byte_budget(env, monkeypatch): - snapshot = env.snapshot(future=True) - size = len(snapshot.model_dump_json().encode()) - monkeypatch.setattr(content, "_RETAINED_PREVIEW_BYTES", 2 * size) - ready = Barrier(8) - - def retain(_index): - ready.wait(timeout=5) - return env.permissions.retain_preview(snapshot) - - with ThreadPoolExecutor(max_workers=8) as pool: - returned = list(pool.map(retain, range(8))) - saved = rows(env, "model_content_previews") - assert len(saved) == 2 - assert sum(len(row[4].encode()) for row in saved) == 2 * size - assert {row[0] for row in saved} <= {preview["preview_id"] for preview in returned} - assert all(row[4] == snapshot.model_dump_json() for row in saved) - assert env.permissions.receipt(user_id=USER) is None - - -def test_regrant_cleanup_keeps_current_unrequested_member_invalidation(env): - other = env.evidence.put(evidence_value("unrequested")).record - for revision in range(3): - receipt = env.permissions.grant( - env.snapshot(records=(env.record, other)), - expected_revision=revision, - user_confirmed=True, - _track_scope=True, - ) - assert len(rows(env, "model_content_scope_seals")) == 1 - assert len(rows(env, "model_content_scope_members")) == 2 - assert env.check(receipt) == receipt - with sqlite3.connect(env.database) as db: - db.execute( - "UPDATE evidence SET verbatim='changed but same revision/hash' WHERE evidence_id=?", - (other.evidence_id,), - ) - with pytest.raises(ModelContentPermissionError, match="evidence_changed"): - env.check(receipt) diff --git a/memhub/apps/core/tests/test_policy_api_wiring.py b/memhub/apps/core/tests/test_policy_api_wiring.py index 1ca04a3..176e711 100644 --- a/memhub/apps/core/tests/test_policy_api_wiring.py +++ b/memhub/apps/core/tests/test_policy_api_wiring.py @@ -11,11 +11,10 @@ from one_memory.memory_server.durable_queue import DurableWriteQueue from one_memory.memory_server.unified_entry import UnifiedWriteIntake -def _application(tmp_path, *, allowed: bool, sync_enabled: bool): +def _application(tmp_path, *, sync_enabled: bool): database = tmp_path / "one-memory.sqlite3" evidence = EvidenceStore(database) config = ConfigStore(tmp_path) - config.set_model_content_allowed(allowed) config.set_sync("cursor", sync_enabled) queue = DurableWriteQueue(database, evidence=evidence) return SimpleNamespace( @@ -59,18 +58,8 @@ def _put_complete_turn(app) -> tuple[str, str]: return tuple(values) -def test_global_model_permission_off_does_not_backfill(tmp_path) -> None: - app = _application(tmp_path, allowed=False, sync_enabled=True) - _put_complete_turn(app) - - asyncio.run(_run_backfill(app)) - - with sqlite3.connect(app.state.evidence_store.database_path) as connection: - assert connection.execute("SELECT COUNT(*) FROM memory_write_jobs").fetchone()[0] == 0 - - def test_backfill_puts_history_on_write_jobs_without_scheduling_memory_tasks(tmp_path) -> None: - app = _application(tmp_path, allowed=True, sync_enabled=True) + app = _application(tmp_path, sync_enabled=True) evidence_ids = _put_complete_turn(app) asyncio.run(_run_backfill(app)) @@ -93,7 +82,7 @@ def test_backfill_puts_history_on_write_jobs_without_scheduling_memory_tasks(tmp def test_source_pause_skips_history_until_the_source_is_reenabled(tmp_path) -> None: - app = _application(tmp_path, allowed=True, sync_enabled=False) + app = _application(tmp_path, sync_enabled=False) _put_complete_turn(app) asyncio.run(_run_backfill(app)) diff --git a/memhub/apps/core/tests/test_policy_operation_lookup.py b/memhub/apps/core/tests/test_policy_operation_lookup.py deleted file mode 100644 index 40710bc..0000000 --- a/memhub/apps/core/tests/test_policy_operation_lookup.py +++ /dev/null @@ -1,248 +0,0 @@ -import json -import sqlite3 -from contextlib import closing, contextmanager -from uuid import uuid4 - -import pytest -from fastapi import Depends, FastAPI, Header, HTTPException -from fastapi.testclient import TestClient - -from one_memory.model_policy_activation import ModelPolicyActivation -from one_memory.model_policy_routes import continuous_policy_router, safe_policy -from tests.test_model_content_permissions import env as env # noqa: F401 -from tests.test_model_policy_activation import request - -ROOT = "/api/v1/continuous-processing-policy/operations/" - - -def client(env, callback=None): - app = FastAPI() - app.state.model_policy_activation = ModelPolicyActivation(env.database, env.secrets) - - def auth(authorization: str | None = Header(None)): - if authorization != "Bearer fixture": - raise HTTPException(401, "unauthorized") - - app.include_router(continuous_policy_router([Depends(auth)], on_enabled=callback)) - return TestClient(app, headers={"Authorization": "Bearer fixture"}) - - -def activate(env): - return ModelPolicyActivation(env.database, env.secrets).enable(**request(env)).policy - - -def database_dump(database): - # WAL checkpoints can change the main file bytes on closing a read handle. - # Compare actual schema/data instead, keeping all rows rather than just counts. - with closing(sqlite3.connect(database)) as db: - return tuple(db.iterdump()) - - -def test_missing_table_returns_not_found_without_initializing_or_reading_secrets(env, monkeypatch): - def forbidden(*args): - pytest.fail("lookup must not read or mutate secrets") - - for name in ("has", "resolve", "put", "delete"): - monkeypatch.setattr(env.secrets, name, forbidden) - before = database_dump(env.database) - with client(env) as http: - result = http.get(ROOT + str(uuid4())) - assert result.status_code == 200, result.text - assert result.json() == {"state": "not_found", "receipt": None, "current_revision": 0} - assert database_dump(env.database) == before - with sqlite3.connect(env.database) as db: - assert not db.execute( - "SELECT 1 FROM sqlite_master WHERE name='continuous_policy_history'" - ).fetchone() - - -def test_current_operation_uses_safe_receipt_without_callback_or_writes(env, monkeypatch): - receipt = activate(env) - - async def forbidden_callback(_receipt): - pytest.fail("GET cannot invoke activation callback") - - monkeypatch.setattr(env.secrets, "has", lambda *args: pytest.fail("no credential read")) - before = database_dump(env.database) - with client(env, forbidden_callback) as http: - result = http.get(ROOT + receipt.operation_id) - assert result.status_code == 200, result.text - assert result.json() == { - "state": "current", - "receipt": safe_policy(receipt).model_dump(), - "current_revision": 1, - } - assert not {"profile", "secret_ref", "backlog_events"} & result.json()["receipt"].keys() - assert database_dump(env.database) == before - - -def test_superseded_operation_preserves_historical_receipt_not_current_activation(env): - first = activate(env) - latest = ( - ModelPolicyActivation(env.database, env.secrets) - .transition( - operation_id=str(uuid4()), - expected_policy_revision=1, - action="revoke", - user_confirmed=True, - ) - .policy - ) - with client(env) as http: - historical = http.get(ROOT + first.operation_id) - assert historical.status_code == 200, historical.text - assert historical.json() == { - "state": "superseded", - "receipt": safe_policy(first).model_dump(), - "current_revision": latest.revision, - } - result = http.get(ROOT + latest.operation_id).json() - assert result["state"] == "current" and result["receipt"]["state"] == "revoked" - assert http.get(ROOT + str(uuid4())).json() == { - "state": "not_found", - "receipt": None, - "current_revision": latest.revision, - } - assert ModelPolicyActivation(env.database, env.secrets).current() == latest - - -@pytest.mark.parametrize( - "value", - [ - "invalid", - "a" * 32, - "ABCDEFAB-CDEF-ABCD-EFAB-CDEFABCDEFAB", - "{abcdefab-cdef-abcd-efab-cdefabcdefab}", - ], -) -def test_noncanonical_uuid_is_422(env, value): - with client(env) as http: - result = http.get(ROOT + value) - assert result.status_code == 422, result.text - - -def test_lookup_requires_authentication(env): - receipt = activate(env) - with client(env) as http: - result = http.get(ROOT + receipt.operation_id, headers={"Authorization": "bad"}) - assert result.status_code == 401 - assert receipt.profile.profile_id not in result.text - - -def test_other_user_operation_is_not_disclosed(env): - own = activate(env) - other_op = str(uuid4()) - with sqlite3.connect(env.database) as db: - data = {**own.model_dump(mode="json"), "user_id": "other", "operation_id": other_op} - db.execute( - "INSERT INTO continuous_policy_history VALUES(?,?,?,?,?)", - ("other", 1, other_op, "a" * 64, json.dumps(data)), - ) - with client(env) as http: - result = http.get(ROOT + other_op) - assert result.status_code == 200 - assert result.json() == {"state": "not_found", "receipt": None, "current_revision": 1} - - -@pytest.mark.parametrize( - "field,value", - [ - ("revision", 99), - ("revision", "1"), - ("operation_id", "different"), - ("user_id", "other"), - ("secret_ref", "must-not-leak"), - ], -) -def test_row_receipt_mismatch_or_extra_fields_returns_safe_503(env, field, value): - receipt = activate(env) - with sqlite3.connect(env.database) as db: - data = {**receipt.model_dump(mode="json"), field: value} - db.execute("UPDATE continuous_policy_history SET receipt_json=?", (json.dumps(data),)) - with client(env) as http: - result = http.get(ROOT + receipt.operation_id) - assert result.status_code == 503, result.text - assert "must-not-leak" not in result.text - assert "receipt" not in result.json() - - -def test_corrupt_current_row_cannot_make_history_or_missing_operation_look_safe(env): - first = activate(env) - ModelPolicyActivation(env.database, env.secrets).transition( - operation_id=str(uuid4()), expected_policy_revision=1, action="pause", user_confirmed=True - ) - with sqlite3.connect(env.database) as db: - db.execute("UPDATE continuous_policy_history SET receipt_json='broken' WHERE revision=2") - with client(env) as http: - assert http.get(ROOT + first.operation_id).status_code == 503 - assert http.get(ROOT + str(uuid4())).status_code == 503 - - -def test_sql_failure_is_503_not_not_found(env): - activate(env) - with sqlite3.connect(env.database) as db: - db.execute("ALTER TABLE continuous_policy_history RENAME COLUMN receipt_json TO broken") - with client(env) as http: - result = http.get(ROOT + str(uuid4())) - assert result.status_code == 503 - assert "broken" not in result.text - - -def test_missing_database_is_503_and_is_not_created(env): - missing = env.database.parent / "missing-policy.sqlite3" - with client(env) as http: - http.app.state.model_policy_activation = ModelPolicyActivation(missing, env.secrets) - response = http.get(ROOT + str(uuid4())) - assert response.status_code == 503 - assert not missing.exists() - - -def test_lookup_connection_rejects_writes_and_preserves_database(env, monkeypatch): - receipt = activate(env) - service = ModelPolicyActivation(env.database, env.secrets) - exists = service._exists - rejected = [] - - def check_read_only(db): - assert db.in_transaction - with pytest.raises(sqlite3.OperationalError, match="readonly"): - db.execute("DELETE FROM continuous_policy_history") - rejected.append(True) - return exists(db) - - monkeypatch.setattr(service, "_exists", check_read_only) - assert service.lookup_operation(receipt.operation_id).receipt == receipt - assert rejected == [True] - assert ModelPolicyActivation(env.database, env.secrets).current() == receipt - - -def test_lookup_reads_operation_and_current_in_the_same_snapshot(env, monkeypatch): - receipt = activate(env) - service = ModelPolicyActivation(env.database, env.secrets) - connect = service._connect - writes = [] - - @contextmanager - def observing_connect(*, write=False): - assert not write - - def trace(sql): - if "AND operation_id=" in sql and not writes: - writes.append("concurrent pause") - ModelPolicyActivation(env.database, env.secrets).transition( - operation_id=str(uuid4()), - expected_policy_revision=1, - action="pause", - user_confirmed=True, - ) - - with connect() as db: - db.set_trace_callback(trace) - yield db - - monkeypatch.setattr(service, "_connect", observing_connect) - result = service.lookup_operation(receipt.operation_id) - assert writes == ["concurrent pause"] - assert result.state == "current" and result.current_revision == 1 - assert result.receipt == receipt - assert ModelPolicyActivation(env.database, env.secrets).current().revision == 2 diff --git a/memhub/apps/core/tests/test_processing_coordinator.py b/memhub/apps/core/tests/test_processing_coordinator.py deleted file mode 100644 index 050863a..0000000 --- a/memhub/apps/core/tests/test_processing_coordinator.py +++ /dev/null @@ -1,54 +0,0 @@ -import asyncio -from types import SimpleNamespace - -from one_memory.memory_server.config_state import ConfigStore -from one_memory.processing_coordinator import ProcessingCoordinator - - -class Tasks: - def __init__(self) -> None: - self.value = {} - - def counts(self): - return dict(self.value) - - -def test_monitor_is_retained_and_observes_config_plus_tasks(tmp_path) -> None: - async def scenario() -> None: - config = ConfigStore(tmp_path) - tasks = Tasks() - resumed = 0 - - async def resume_allowed() -> None: - nonlocal resumed - resumed += 1 - - supervisor = SimpleNamespace(tasks=tasks, resume_allowed=resume_allowed) - coordinator = ProcessingCoordinator(config, supervisor) - coordinator.POLL_SECONDS = 0.01 - coordinator.RESCAN_SECONDS = 60 - try: - await coordinator.recover() - await asyncio.sleep(0.03) - assert coordinator.status()["monitoring"] is True - assert resumed == 0 - - config.set_model_content_allowed(True) - await asyncio.sleep(0.03) - assert resumed == 1 - - tasks.value = {"pending": 1} - await asyncio.sleep(0.03) - assert resumed == 2 - - config.set_model_content_allowed(False) - tasks.value = {"pending": 2} - await asyncio.sleep(0.03) - assert resumed == 2 - assert coordinator.status()["allowed"] is False - finally: - await coordinator.shutdown() - - assert coordinator.status()["monitoring"] is False - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_processing_merged_context.py b/memhub/apps/core/tests/test_processing_merged_context.py deleted file mode 100644 index f232e85..0000000 --- a/memhub/apps/core/tests/test_processing_merged_context.py +++ /dev/null @@ -1,74 +0,0 @@ -import asyncio -from uuid import uuid4 - -import pytest - -from one_memory.memory_governance import HyLink, MemoryDraft, MemoryKind -from one_memory.memory_tasks import MemoryTaskOutput, MemoryTaskStatus, evidence_ref -from tests.person_door import as_person -from tests.test_continuous_processing_bridge import enable -from tests.test_model_processing_permissions import h as h # noqa: F401 - - -@pytest.mark.parametrize("merged_support", [False, True]) -def test_new_chunk_omits_known_memory_with_changed_support_but_keeps_original_history( - h, merged_support -): - enable(h) - old = h.put("completed") - supervisor = h.supervisor() - h.bridge.fail = False - - async def scenario(): - try: - first = supervisor._enqueue_chunk((old,), h.profile) - assert ( - await supervisor._create_runner().run(first.task_id) - ).status == MemoryTaskStatus.COMPLETED - memory = as_person(h.governance).create( - MemoryDraft( - user_id="local", - kind=MemoryKind.CHAT, - content="Synthetic shared preference", - evidence_ids=(old.evidence_id,), - scopes=("all-apps",), - ), - request_id=str(uuid4()), - ) - h.tasks.save_output( - MemoryTaskOutput( - task_id=first.task_id, - memory_id=memory.memory_id, - memory_revision=memory.revision, - hy_memory_id="synthetic-known-memory", - owner="user", - layer="L2_FACT", - engine_version="controlled-test", - support_refs=(evidence_ref(old),), - ) - ) - outside = h.put("other-conversation", conversation="another") - if merged_support: - h.governance.write_from_hy( - HyLink(memory_id=memory.memory_id, evidence_id=outside.evidence_id), - user_id="local", - derivation=h.governance.hy_derivation((outside.evidence_id,)), - ) - changed = h.governance.get(memory.memory_id, user_id="local") - assert changed.revision == memory.revision - assert set(changed.evidence_ids) == {old.evidence_id, outside.evidence_id} - fresh = h.put("next") - next_task = supervisor._enqueue_chunk((fresh,), h.profile) - result = await supervisor._create_runner().run(next_task.task_id) - assert result.status == MemoryTaskStatus.COMPLETED - context = h.leases[-1].source_context - assert context.current == (fresh,) - assert context.history == (old,) - assert len(context.known_memories) == (0 if merged_support else 1) - assert outside.evidence_id not in { - record.evidence_id for record in h.leases[-1].records - } - finally: - await supervisor.shutdown() - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_recall_fusion_live.py b/memhub/apps/core/tests/test_recall_fusion_live.py index b63b3e6..8abb525 100644 --- a/memhub/apps/core/tests/test_recall_fusion_live.py +++ b/memhub/apps/core/tests/test_recall_fusion_live.py @@ -36,7 +36,7 @@ from one_memory.api import create_app from one_memory.config import Settings from one_memory.recall import _terms from one_memory.secrets import KeyringSecretStore -from tests.source_consent import confirm_model_content, connection_request +from tests.source_consent import connection_request PROFILE_DB = os.environ.get("ONE_MEMORY_MODEL_TEST_DB") pytestmark = [ @@ -127,7 +127,6 @@ def test_both_lanes_answer_through_one_real_hy_instance(tmp_path, monkeypatch): linked.raise_for_status() job = await app.state.import_supervisor.wait(linked.json()["jobs"][0]["job_id"]) assert job.stored == 1 and job.errors == 0, job - await confirm_model_content(client, source_ids=(receipt["source_id"],)) processor = app.state.memory_processing_supervisor await asyncio.gather(*tuple(processor._debounces.values()), return_exceptions=True) await asyncio.wait_for(asyncio.gather(*tuple(processor._workers.values())), timeout=300) diff --git a/memhub/apps/core/tests/test_schema_ownership_gate.py b/memhub/apps/core/tests/test_schema_ownership_gate.py index ff3f791..e88b79c 100644 --- a/memhub/apps/core/tests/test_schema_ownership_gate.py +++ b/memhub/apps/core/tests/test_schema_ownership_gate.py @@ -88,10 +88,6 @@ KNOWN_INLINE_DDL = frozenset( # migration 29, and `test_call_event_ddl_matches_the_store_that_compares_it` # pins the two together. "call_event_store.py", - # Direct store construction predates the migration runner in narrow - # tests and repair tools. The canonical table also lives in migration - # 48; this mirrored bootstrap is pinned until those callers migrate. - "model_content_permissions.py", # Deliberate exception, not an oversight (D93 A). These two tables are # a derived cache of a decision the read path already makes, and they # must NOT go through a migration: `schema_gate` derives the build's diff --git a/memhub/apps/core/tests/test_settings_policy_lifecycle.py b/memhub/apps/core/tests/test_settings_policy_lifecycle.py deleted file mode 100644 index 5255494..0000000 --- a/memhub/apps/core/tests/test_settings_policy_lifecycle.py +++ /dev/null @@ -1,385 +0,0 @@ -"""Settings data-plane lifecycle: real temporary Core, fake probe/HY, no UI or package.""" - -import asyncio -import sqlite3 -from contextlib import closing -from uuid import uuid4 - -import httpx -import pytest - -from one_memory.continuous_permission_bridge import ContinuousPermissionReceipt -from one_memory.hy_memory_bridge import HyMemoryBridgeError -from one_memory.memory_tasks import MemoryTaskStatus -from one_memory.model_probe import ModelProber -from tests.test_continuous_processing_integration import API_ROOT -from tests.test_continuous_processing_integration import core as core # noqa: F401 - - -async def save(core, name="model-a", *, probe=True): - response = await core.client.post( - "/api/v1/model-profiles", - json={ - "name": name, - "provider_kind": "openai_compatible", - "base_url": f"https://{name}.invalid/v1", - "model": name, - "api_key": "synthetic-key-not-a-credential", - "embedding_provider_kind": "openai_compatible", - "embedding_base_url": f"https://{name}.invalid/v1", - "embedding_model": "integration-vector", - "disable_thinking": True, - "max_concurrency": 1, - }, - ) - assert response.status_code == 200, response.text - profile = response.json() - assert profile["is_active"] is False - return await probe_profile(core, profile) if probe else profile - - -async def probe_profile(core, profile): - # Extend the fixture's synthetic model list for two genuinely distinct models; - # all other probe stages keep the existing controlled boundary and payload checks. - def respond(request): - if request.url.path.endswith("/models"): - core.probe_requests.append((request.url.path, "")) - return httpx.Response(200, json={"data": [{"id": profile["model"]}]}) - return core.probe_transport(request) - - core.app.state.model_prober = ModelProber( - store=core.app.state.model_profile_store, - secrets=core.secrets, - transport=httpx.MockTransport(respond), - ) - result = await core.client.post(f"/api/v1/model-profiles/{profile['profile_id']}/probe") - assert result.status_code == 200, result.text - assert result.json()["input_profile_revision"] == profile["revision"] - profiles = await core.client.get("/api/v1/model-profiles") - assert profiles.status_code == 200 - measured = next(p for p in profiles.json() if p["profile_id"] == profile["profile_id"]) - assert measured["revision"] == result.json()["profile_revision"] - assert measured["embedding_dimensions"] == 3 - assert all( - measured["capabilities"][key] - for key in ("endpoint_reachable", "model_available", "embedding_available") - ) - return measured - - -async def current(core): - response = await core.client.get(API_ROOT) - assert response.status_code == 200, response.text - return response.json() - - -async def enable(core, profile, **overrides): - before = await current(core) - body = { - "operation_id": str(uuid4()), - "profile_id": profile["profile_id"], - "profile_revision": profile["revision"], - "expected_policy_revision": before["policy"]["revision"] if before["policy"] else 0, - "expected_legacy_revision": before["legacy_revision"], - "backlog_mode": "none", - "user_confirmed": True, - **overrides, - } - return await core.client.post(API_ROOT + "/enable", json=body), body - - -async def enabled(core, profile, **overrides): - response, body = await enable(core, profile, **overrides) - assert response.status_code == 200, response.text - value = response.json() - # One DB read snapshot observes policy and active model together. - with closing(sqlite3.connect(core.database)) as db: - db.execute("BEGIN") - active = db.execute("SELECT profile_id,revision FROM model_profiles WHERE is_active=1") - assert active.fetchall() == [(profile["profile_id"], profile["revision"])] - saved = db.execute( - "SELECT receipt_json FROM continuous_policy_history WHERE user_id='local' " - "ORDER BY revision DESC LIMIT 1" - ).fetchone() - assert saved is not None - assert core.policy_current().profile.profile_id == value["profile_id"] - assert value["profile_id"] == profile["profile_id"] - assert value["profile_revision"] == profile["revision"] - return value, body - - -def guard(core): - result = core.app.state.memory_processing_supervisor._permission_guard - assert result is not None - return result - - -def check_current(core, record): - authority = guard(core).current("local") - assert isinstance(authority, ContinuousPermissionReceipt), "Must never fall back to legacy" - guard(core).check(authority, authority.policy.profile, (record,)) - return authority - - -async def assert_processed_by(core, record, profile): - tasks = await core.run_prepared(record) - assert tasks and all(task.status == MemoryTaskStatus.COMPLETED for task in tasks) - assert all( - (task.profile_id, task.profile_revision) == (profile["profile_id"], profile["revision"]) - for task in tasks - ) - assert core.bridge.requests and record.evidence_id in core.bridge.requests[-1].evidence_ids - assert core.bridge.leases[-1].profile.profile_id == profile["profile_id"] - await core.assert_governed(record) - - -async def legacy_grant(core, profile): - active = await core.client.post(f"/api/v1/model-profiles/{profile['profile_id']}/activate") - assert active.status_code == 200, active.text - preview = await core.client.post( - "/api/v1/model-content-permission/preview", - json={ - "profile_id": profile["profile_id"], - "profile_revision": profile["revision"], - "source_ids": ["cursor"], - "all_imported_from_selected_sources": True, - "include_future_additions": True, - }, - ) - assert preview.status_code == 200, preview.text - result = await core.client.post( - "/api/v1/model-content-permission", - json={"snapshot": preview.json(), "expected_revision": 0, "user_confirmed": True}, - ) - assert result.status_code == 200, result.text - return core.app.state.model_content_permissions.receipt(user_id="local") - - -def test_no_onboarding_or_legacy_permission_required_for_save_probe_enable(core): - async def scenario(): - async with core.running(): - onboarding = (await core.client.get("/api/v1/onboarding")).json() - assert onboarding["status"] != "completed" - profile = await save(core) - assert profile["revision"] == 2 # probe measured width from unspecified to 3 - assert (await current(core))["policy"] is None - assert not core.bridge.requests and not core.app.state.memory_task_store.list() - await enabled(core, profile) - assert (await core.client.get("/api/v1/onboarding")).json() == onboarding - assert core.app.state.model_content_permissions.receipt(user_id="local") is None - core.grant_source() - record = core.put_complete("settings-first", conversation="settings-first") - check_current(core, record) - await assert_processed_by(core, record, profile) - - asyncio.run(scenario()) - - -def test_saved_unprobed_draft_does_not_activate_or_create_policy(core): - async def scenario(): - async with core.running(): - core.grant_source() - record = core.put("draft-only") - profile = await save(core, probe=False) - assert not profile["is_active"] - assert core.app.state.model_profile_store.active_identity() is None - assert (await current(core))["policy"] is None - with pytest.raises(HyMemoryBridgeError, match="permission_required"): - await asyncio.to_thread( - core.app.state.memory_processing_supervisor.prepare_conversation, record - ) - assert not core.probe_requests and not core.bridge.requests - assert core.app.state.memory_task_store.list() == [] - - asyncio.run(scenario()) - - -def test_second_saved_probed_profile_does_not_replace_enabled_model(core): - async def scenario(): - async with core.running(): - first = await save(core) - original, _ = await enabled(core, first) - second = await save(core, "model-b") - assert second["profile_id"] != first["profile_id"] - assert (await current(core))["policy"] == original - assert core.app.state.model_profile_store.active_identity() == ( - first["profile_id"], - first["revision"], - ) - assert not core.bridge.requests - core.grant_source() - record = core.put_complete("after-second-draft", conversation="second-draft") - await assert_processed_by(core, record, first) - - asyncio.run(scenario()) - - -def test_switch_distinct_profiles_atomically_binds_new_policy_and_rejects_old_authority(core): - async def scenario(): - async with core.running(): - first = await save(core) - old, old_body = await enabled(core, first) - core.grant_source() - before = core.put_complete("before-switch", conversation="old-model") - old_authority = check_current(core, before) - second = await save(core, "model-b") - new, body = await enabled(core, second) - assert new["revision"] == old["revision"] + 1 and new["epoch"] != old["epoch"] - with pytest.raises(HyMemoryBridgeError): - guard(core).check(old_authority, old_authority.policy.profile, (before,)) - repeat = await core.client.post(API_ROOT + "/enable", json=body) - assert repeat.status_code == 200 and repeat.json() == new - assert (await core.client.post(API_ROOT + "/enable", json=old_body)).status_code == 409 - fresh = core.put_complete("after-switch", conversation="new-model") - await assert_processed_by(core, fresh, second) - assert all(before.evidence_id not in req.evidence_ids for req in core.bridge.requests) - - asyncio.run(scenario()) - - -def test_failed_policy_cas_leaves_previous_profile_and_policy_unchanged(core): - async def scenario(): - async with core.running(): - first = await save(core) - original, _ = await enabled(core, first) - second = await save(core, "model-b") - rejected, _ = await enable(core, second, expected_policy_revision=0) - assert rejected.status_code == 409, rejected.text - assert (await current(core))["policy"] == original - assert core.app.state.model_profile_store.active_identity() == ( - first["profile_id"], - first["revision"], - ) - assert not core.bridge.requests - - asyncio.run(scenario()) - - -def test_unprobed_new_model_cannot_replace_a_working_policy(core): - async def scenario(): - async with core.running(): - first = await save(core) - original, _ = await enabled(core, first) - second = await save(core, "model-b", probe=False) - rejected, _ = await enable(core, second) - assert rejected.status_code == 409 and "probe_required" in rejected.text - assert (await current(core))["policy"] == original - assert core.app.state.model_profile_store.active_identity() == ( - first["profile_id"], - first["revision"], - ) - assert not core.bridge.requests - - asyncio.run(scenario()) - - -def test_active_profile_edit_invalidates_guard_until_explicit_reenable(core): - async def scenario(): - async with core.running(): - first = await save(core) - original, _ = await enabled(core, first) - core.grant_source() - patch = await core.client.patch( - f"/api/v1/model-profiles/{first['profile_id']}", - json={"expected_revision": first["revision"], "model": "edited-model"}, - ) - assert patch.status_code == 200, patch.text - edited = patch.json() - assert not edited["is_active"] and edited["revision"] == first["revision"] + 1 - blocked = core.put_complete("after-edit", conversation="edited") - with pytest.raises(HyMemoryBridgeError): - check_current(core, blocked) - edited = await probe_profile(core, edited) - assert (await current(core))["policy"] == original - with pytest.raises(HyMemoryBridgeError): - check_current(core, blocked) - assert not core.bridge.requests - renewed, _ = await enabled(core, edited, backlog_mode="include") - assert renewed["revision"] == original["revision"] + 1 - await core.wait_completed(timeout=4) - await core.assert_governed(blocked) - assert all(lease.profile.model == "edited-model" for lease in core.bridge.leases) - - asyncio.run(scenario()) - - -def test_revoke_then_new_enable_changes_epoch_without_restoring_revoked_period(core): - async def scenario(): - async with core.running(): - first = await save(core) - original, _ = await enabled(core, first) - revoked = await core.client.post( - API_ROOT + "/revoke", - json={ - "operation_id": str(uuid4()), - "expected_policy_revision": original["revision"], - "user_confirmed": True, - }, - ) - assert revoked.status_code == 200, revoked.text - denied = await core.client.post( - API_ROOT + "/resume", - json={ - "operation_id": str(uuid4()), - "expected_policy_revision": revoked.json()["revision"], - "user_confirmed": True, - }, - ) - assert denied.status_code == 409 and "requires_new_enable" in denied.text - core.grant_source() - excluded = core.put("during-revoke", conversation="revoked-period") - with pytest.raises(HyMemoryBridgeError): - check_current(core, excluded) - second = await save(core, "model-b") - renewed, _ = await enabled(core, second) - assert renewed["epoch"] != original["epoch"] - with pytest.raises(HyMemoryBridgeError, match="outside_policy"): - check_current(core, excluded) - fresh = core.put_complete("after-reenable", conversation="renewed") - await assert_processed_by(core, fresh, second) - assert all(excluded.evidence_id not in req.evidence_ids for req in core.bridge.requests) - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("change", ["delete", "clear_key"]) -def test_unavailable_policy_profile_never_falls_back_to_real_legacy_grant(core, change): - async def scenario(): - async with core.running(): - core.grant_source() - first = await save(core) - legacy = await legacy_grant(core, first) - original, _ = await enabled(core, first) - assert original["legacy_revision"] == legacy.revision - initial = core.put("positive-authority", conversation="before-unavailable") - authority = check_current(core, initial) - patch = await core.client.patch( - f"/api/v1/model-profiles/{first['profile_id']}", - json={"expected_revision": first["revision"], "clear_api_key": True}, - ) - assert patch.status_code == 200 and not patch.json()["is_active"] - if change == "delete": - deleted = await core.client.delete(f"/api/v1/model-profiles/{first['profile_id']}") - assert deleted.status_code == 204, deleted.text - assert ( - await core.client.post(f"/api/v1/model-profiles/{first['profile_id']}/probe") - ).status_code == 404 - else: - missing = await core.client.post( - f"/api/v1/model-profiles/{first['profile_id']}/probe" - ) - assert missing.status_code == 200 - assert missing.json()["stages"][0]["code"] == "missing_api_key" - assert core.app.state.model_content_permissions.receipt(user_id="local") == legacy - assert core.policy_current().epoch == original["epoch"] - next_record = core.put("after-unavailable", conversation="after-unavailable") - with pytest.raises(HyMemoryBridgeError): - guard(core).check(authority, authority.policy.profile, (next_record,)) - with pytest.raises(HyMemoryBridgeError): - await asyncio.to_thread( - core.app.state.memory_processing_supervisor.prepare_conversation, next_record - ) - assert not core.bridge.requests - assert core.app.state.memory_governance_store.list(user_id="local") == [] - - asyncio.run(scenario()) diff --git a/memhub/apps/core/tests/test_simplified_model_permission_migrations.py b/memhub/apps/core/tests/test_simplified_model_permission_migrations.py deleted file mode 100644 index 75d1868..0000000 --- a/memhub/apps/core/tests/test_simplified_model_permission_migrations.py +++ /dev/null @@ -1,31 +0,0 @@ -import asyncio -import sqlite3 - -from one_memory.memory_server.migration_runner import SqliteMigrationStore, migrate -from one_memory.memory_server.migrations import MIGRATIONS - - -def test_fresh_schema_omits_removed_permission_state_machines(tmp_path) -> None: - path = tmp_path / "state.sqlite3" - asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS)) - - removed = { - "continuous_policy_history", - "processing_requests", - "model_content_scope_seals", - "model_content_scope_members", - "memory_task_model_permissions", - "memory_task_continuous_permissions", - "model_content_permissions", - "model_content_evidence_additions", - "model_content_previews", - "model_content_authority_state", - } - with sqlite3.connect(path) as connection: - tables = { - row[0] - for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'") - } - - assert removed.isdisjoint(tables) - assert "memory_tasks" in tables diff --git a/memhub/apps/core/tests/test_subagent_sessions.py b/memhub/apps/core/tests/test_subagent_sessions.py deleted file mode 100644 index f7924f8..0000000 --- a/memhub/apps/core/tests/test_subagent_sessions.py +++ /dev/null @@ -1,213 +0,0 @@ -"""D98: a sub agent's transcript is its own conversation, not part of the main one. - -Claude Code writes sidechain records into the same jsonl as the main session. -Mixing them in gives HY one long conversation whose questions are not the -person's, and dropping them loses the sub agent's work. Each sidechain has to -become a conversation of its own; the main one keeps only its own records. -""" - -import asyncio -import json - -from one_memory.imports.sources.claude_code import create_claude_code_adapter -from tests.test_primary_source_adapters import evidence, scan -from tests.test_tool_evidence_hy_turns import _grant - - -def test_a_sidechain_record_gets_its_own_conversation(tmp_path) -> None: - root = tmp_path / "claude" - (root / "project").mkdir(parents=True) - transcript = root / "project" / "session.jsonl" - - def line(**overrides: object) -> dict[str, object]: - record: dict[str, object] = { - "type": "user", - "sessionId": "session", - "cwd": "/private/work/acme", - "timestamp": "2026-09-01T00:00:00Z", - "message": {"content": [{"type": "text", "text": "一句话"}]}, - } - record.update(overrides) - return record - - transcript.write_text( - "\n".join( - json.dumps(record, ensure_ascii=False) - for record in ( - line(uuid="m1"), - line(uuid="m2", type="assistant"), - # Real sidechain records carry the agent they belong to; the - # fixture has to, or the parser cannot group them. - line(uuid="s1", isSidechain=True, agentId="agent-a"), - line(uuid="s2", type="assistant", isSidechain=True, agentId="agent-a"), - line(uuid="m3", type="assistant"), - ) - ) - + "\n", - encoding="utf-8", - ) - - records = evidence(asyncio.run(scan(create_claude_code_adapter(_grant("claude-code", root))))) - by_message = {item.evidence.message_id: item.evidence for item in records} - - main_ids = {"m1", "m2", "m3"} - sub_ids = {"s1", "s2"} - assert by_message.keys() >= main_ids | sub_ids, sorted(by_message) - - main_conversations = {by_message[message_id].conversation_id for message_id in main_ids} - sub_conversations = {by_message[message_id].conversation_id for message_id in sub_ids} - assert len(main_conversations) == 1 - assert len(sub_conversations) == 1 - # Separate, so HY never sees the sub agent's turns as part of the person's. - assert main_conversations.isdisjoint(sub_conversations) - - -def test_the_switch_off_skips_sub_agent_transcripts(tmp_path, monkeypatch) -> None: - """`ONE_MEMORY_IMPORT_SUBAGENT_TRACE=0`: the sub agent's trace is not imported. - - The privacy-preserving option — the sub-agent trace need not be sent at all. Off - means the whole transcript is skipped at the scan, not dropped later. - """ - monkeypatch.setenv("ONE_MEMORY_IMPORT_SUBAGENT_TRACE", "0") - root = tmp_path / "claude" - (root / "project").mkdir(parents=True) - (root / "project" / "subagents").mkdir() - - def write(path, records): - path.write_text( - "\n".join(json.dumps(record, ensure_ascii=False) for record in records) + "\n", - encoding="utf-8", - ) - - write( - root / "project" / "session.jsonl", - [ - { - "type": "user", - "sessionId": "session", - "uuid": "m1", - "cwd": "/private/work", - "timestamp": "2026-09-01T00:00:00Z", - "message": {"content": [{"type": "text", "text": "本人问的"}]}, - }, - { - "type": "user", - "sessionId": "session", - "uuid": "s1", - "isSidechain": True, - "message": {"content": [{"type": "text", "text": "子 agent 的"}]}, - }, - ], - ) - write( - root / "project" / "subagents" / "child.jsonl", - [ - { - "type": "user", - "sessionId": "child", - "uuid": "child-u1", - "message": {"content": [{"type": "text", "text": "委派任务"}]}, - } - ], - ) - - records = evidence(asyncio.run(scan(create_claude_code_adapter(_grant("claude-code", root))))) - ids = {item.evidence.message_id for item in records} - - assert "m1" in ids, "the main transcript must still import" - assert "s1" not in ids, "a sidechain record was imported with the switch off" - assert "child-u1" not in ids, "a subagents/ transcript was imported with the switch off" - - -def test_two_sub_agents_do_not_share_a_conversation(tmp_path) -> None: - """Each delegation is its own thread. - - One main session can delegate more than once. A single shared suffix would - splice every sub agent into one conversation and hand HY a transcript that - never happened — which is worse than the old behaviour of dropping them. - """ - root = tmp_path / "claude" - (root / "project").mkdir(parents=True) - transcript = root / "project" / "session.jsonl" - - def line(**overrides: object) -> dict[str, object]: - record: dict[str, object] = { - "type": "user", - "sessionId": "session", - "cwd": "/private/work", - "timestamp": "2026-09-01T00:00:00Z", - "message": {"content": [{"type": "text", "text": "一句话"}]}, - } - record.update(overrides) - return record - - transcript.write_text( - "\n".join( - json.dumps(record, ensure_ascii=False) - for record in ( - line(uuid="m1"), - line(uuid="a1-1", isSidechain=True, agentId="agent-a"), - line(uuid="b1-1", isSidechain=True, agentId="agent-b"), - line(uuid="a1-2", type="assistant", isSidechain=True, agentId="agent-a"), - line(uuid="m2", type="assistant"), - ) - ) - + "\n", - encoding="utf-8", - ) - - records = evidence(asyncio.run(scan(create_claude_code_adapter(_grant("claude-code", root))))) - by_message = {item.evidence.message_id: item.evidence for item in records} - - assert by_message["m1"].conversation_id == by_message["m2"].conversation_id - main = by_message["m1"].conversation_id - agent_a = {by_message["a1-1"].conversation_id, by_message["a1-2"].conversation_id} - agent_b = {by_message["b1-1"].conversation_id} - - # One thread per delegation, and neither is the main one. - assert len(agent_a) == 1 - assert agent_a != agent_b - assert main not in agent_a | agent_b - - -def test_sidechain_records_without_an_agent_share_one_conversation(tmp_path) -> None: - """Known limit, pinned: no `agentId` means one `{session}:subagent` thread. - - Two delegations that both lack the field would be spliced together. No - real transcript omits it, so this is not guessed at from `parentUuid` - chains — it is left as the one conversation and recorded here, so a change - in behaviour has to say why. - """ - root = tmp_path / "claude" - (root / "project").mkdir(parents=True) - transcript = root / "project" / "session.jsonl" - - def line(**overrides: object) -> dict[str, object]: - record: dict[str, object] = { - "type": "user", - "sessionId": "session", - "cwd": "/private/work", - "timestamp": "2026-09-01T00:00:00Z", - "message": {"content": [{"type": "text", "text": "一句话"}]}, - } - record.update(overrides) - return record - - transcript.write_text( - "\n".join( - json.dumps(record, ensure_ascii=False) - for record in ( - line(uuid="m1"), - line(uuid="s1", isSidechain=True), - line(uuid="s2", type="assistant", isSidechain=True), - ) - ) - + "\n", - encoding="utf-8", - ) - - records = evidence(asyncio.run(scan(create_claude_code_adapter(_grant("claude-code", root))))) - by_message = {item.evidence.message_id: item.evidence for item in records} - - assert by_message["s1"].conversation_id == by_message["s2"].conversation_id - assert by_message["s1"].conversation_id != by_message["m1"].conversation_id diff --git a/memhub/apps/core/tests/test_subagent_turns.py b/memhub/apps/core/tests/test_subagent_turns.py deleted file mode 100644 index 22890cf..0000000 --- a/memhub/apps/core/tests/test_subagent_turns.py +++ /dev/null @@ -1,116 +0,0 @@ -"""D98: a sub agent's turn is ordinary user-assistant data. - -The host parser marks a sub agent's question as written by the agent, and the -turn assembly then kept only `user` + `PERSON` — so Claude Code sent just the -first question of a transcript, and a turn holding only a sub agent's question -was dropped whole. - -The `role` field cannot say who wrote a message, so filtering on the author -threw away real turns. Author stays: it labels the display and decides owner. -It no longer decides whether a message is sent. -""" - -import asyncio - -from one_memory.imports.models import WrittenBy -from tests.test_hy_turn_input import ( - FakeBridge, - _runner, - _setup, - _task, -) -from tests.test_memory_processing import _put - - -def test_a_sub_agent_question_reaches_hy_with_its_answer(tmp_path) -> None: - """Acceptance case 1: the sub agent's Q and A are both sent.""" - stores = _setup(tmp_path) - evidence, _profiles, tasks, _governance, _secrets, profile = stores - bridge = FakeBridge() - - person = _put( - evidence, - message_id="u0", - role="user", - written_by=WrittenBy.PERSON, - content="帮我看下这个仓库的测试为什么挂了", - ) - sub_question = _put( - evidence, - message_id="u1", - role="user", - written_by=WrittenBy.AGENT, - content="读取 tests/test_import.py 并找出失败原因", - ) - sub_answer = _put( - evidence, - message_id="a1", - role="assistant", - written_by=WrittenBy.AGENT, - content="失败原因是 fixture 没清理", - ) - - asyncio.run(_runner(tmp_path, stores, bridge).run(_task(tasks, profile, person).task_id)) - - request = bridge.requests[0] - roles = [turn.role for turn in request.turns] - contents = [turn.content for turn in request.turns] - assert roles == ["user", "user", "assistant"], roles - assert any("读取 tests/test_import.py" in content for content in contents) - assert sub_question.evidence_id in request.evidence_ids - assert sub_answer.evidence_id in request.evidence_ids - - -def test_a_turn_of_only_a_sub_agent_question_is_not_dropped(tmp_path) -> None: - """Acceptance case 2: other hosts lost the whole turn here.""" - stores = _setup(tmp_path) - evidence, _profiles, tasks, _governance, _secrets, profile = stores - bridge = FakeBridge() - - sub_question = _put( - evidence, - message_id="u0", - role="user", - written_by=WrittenBy.AGENT, - content="这条是子 agent 的追问", - ) - _put( - evidence, - message_id="a0", - role="assistant", - written_by=WrittenBy.AGENT, - content="这条是它的回答", - ) - - asyncio.run(_runner(tmp_path, stores, bridge).run(_task(tasks, profile, sub_question).task_id)) - - assert bridge.requests, "a turn with no question by the person was dropped" - assert [turn.role for turn in bridge.requests[0].turns] == ["user", "assistant"] - - -def test_a_turn_by_the_person_is_unchanged(tmp_path) -> None: - """Acceptance case 3: regression — the ordinary case sends the same.""" - stores = _setup(tmp_path) - evidence, _profiles, tasks, _governance, _secrets, profile = stores - bridge = FakeBridge() - - person = _put( - evidence, - message_id="u0", - role="user", - written_by=WrittenBy.PERSON, - content="我以后都用中文记", - ) - _put( - evidence, - message_id="a0", - role="assistant", - written_by=WrittenBy.AGENT, - content="好的,记下了", - ) - - asyncio.run(_runner(tmp_path, stores, bridge).run(_task(tasks, profile, person).task_id)) - - request = bridge.requests[0] - assert [turn.role for turn in request.turns] == ["user", "assistant"] - assert [turn.content for turn in request.turns] == ["我以后都用中文记", "好的,记下了"] diff --git a/memhub/apps/core/tests/test_tool_evidence_hy_turns.py b/memhub/apps/core/tests/test_tool_evidence_hy_turns.py deleted file mode 100644 index 2a165d8..0000000 --- a/memhub/apps/core/tests/test_tool_evidence_hy_turns.py +++ /dev/null @@ -1,586 +0,0 @@ -"""Every adapter's tool records reach HY inside the turn they happened in. - -Each case starts from the source's own on-disk shape, imports it through -`ImportRunner`, and reads back what the processing path hands HY. Adapter -tests stop at the evidence row and turn tests start from hand-built rows; -this is the seam between them: a call filed under the wrong conversation, -dropped from a mixed block, or ordered by something other than when it -happened would pass both and still reach HY in the wrong turn. -""" - -from __future__ import annotations - -import asyncio -import json -import sqlite3 -from pathlib import Path - -from one_memory.imports.access import AuthorizedSourceRoot -from one_memory.imports.adapters import SourceAdapter -from one_memory.imports.jobs import ImportJobStore, ImportStatus -from one_memory.imports.runner import ImportRunner -from one_memory.imports.sources.claude_code import create_claude_code_adapter -from one_memory.imports.sources.cursor_unified import CursorUnifiedSourceAdapter -from one_memory.imports.sources.deepseek_harness import create_deepseek_harness_adapter -from one_memory.imports.sources.hermes import create_hermes_adapter -from one_memory.imports.sources.openclaw import ( - OPENCLAW_DESCRIPTOR, - OpenClawSourceAdapter, -) -from one_memory.imports.sources.opencode import create_opencode_adapter -from one_memory.imports.sources.pi import create_pi_adapter -from one_memory.imports.sources.qwen_code import create_qwen_code_adapter -from one_memory.memory_processing import MemoryTaskRunner, _conversation_chunks -from tests.test_hy_turn_input import _supervisor -from tests.test_memory_processing import FakeBridge, _runner, _setup - -SECRET = "sk-abcdefghijklmnopqrstu" - -#: (role, a marker the content must contain), in the order HY must see them. -Expected = list[tuple[str, str]] - -FIRST_TURN: Expected = [ - ("user", "第一个问题"), - ("tool", "pytest --token"), - ("tool", "3 failed"), - ("assistant", "第一个结论"), -] -SECOND_TURN: Expected = [ - ("user", "第二个问题"), - ("tool", "git status"), - ("tool", "working tree clean"), - ("assistant", "第二个结论"), -] -FIRST_ONLY = ("pytest --token", "3 failed", "第一个") -SECOND_ONLY = ("git status", "working tree clean", "第二个") - - -def _grant(source_id: str, root: Path) -> AuthorizedSourceRoot: - return AuthorizedSourceRoot.grant( - source_id=source_id, root=root, user_confirmed=True, home=root.parent / "other-home" - ) - - -def _write_jsonl(path: Path, records: list[dict[str, object]]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("".join(f"{json.dumps(r, ensure_ascii=False)}\n" for r in records), "utf-8") - - -def _import(tmp_path: Path, adapter: SourceAdapter, source_id: str): - stores = _setup(tmp_path) - jobs = ImportJobStore(tmp_path / "one-memory.sqlite3") - job = jobs.create(user_id="local", source_id=source_id) - done = asyncio.run( - ImportRunner(evidence_store=stores[0], job_store=jobs).run( - adapter=adapter, job_id=job.job_id - ) - ) - assert done.status is ImportStatus.COMPLETED - return stores - - -def _requests_to_hy(tmp_path: Path, adapter: SourceAdapter, source_id: str) -> list[Expected]: - """What the processing path sends HY for every imported conversation, call by call.""" - stores = _import(tmp_path, adapter, source_id) - evidence = stores[0] - supervisor = _supervisor(tmp_path, stores) - bridge = FakeBridge(outputs=()) - runner: MemoryTaskRunner = _runner(tmp_path, stores, bridge) - for seed_id in evidence.list_conversation_seed_ids(): - seed = evidence.get(seed_id) - assert seed is not None - for task in supervisor.prepare_conversation(seed): - asyncio.run(runner.run(task.task_id)) - return [[(turn.role, turn.content) for turn in request.turns] for request in bridge.requests] - - -def _assert_turns(sent: list[Expected], *expected: Expected) -> None: - assert len(sent) == len(expected), sent - for turns, wanted in zip(sent, expected, strict=True): - assert [role for role, _ in turns] == [role for role, _ in wanted], turns - for (_, content), (_, marker) in zip(turns, wanted, strict=True): - assert marker in content, (marker, turns) - assert all(SECRET not in content for _, content in turns) - - -def _assert_two_turns(sent: list[Expected]) -> None: - _assert_turns(sent, FIRST_TURN, SECOND_TURN) - first, second = ("\n".join(content for _, content in turns) for turns in sent) - assert not any(marker in first for marker in SECOND_ONLY) - assert not any(marker in second for marker in FIRST_ONLY) - - -def test_claude_code_tool_calls_reach_hy_inside_their_turn(tmp_path: Path) -> None: - root = tmp_path / "claude" - at = iter(f"2026-09-20T10:00:{second:02d}.000Z" for second in range(60)) - - def line(uuid: str, kind: str, content: object) -> dict[str, object]: - return { - "type": kind, - "uuid": uuid, - "sessionId": "s", - "cwd": "/private/work/acme", - "timestamp": next(at), - "message": {"role": kind, "content": content}, - } - - def call(uuid: str, command: str, *, saying: str | None = None) -> dict[str, object]: - block = {"type": "tool_use", "id": uuid, "name": "Bash", "input": {"command": command}} - said = [{"type": "text", "text": saying}] if saying else [] - return line(uuid, "assistant", [*said, block]) - - def result(uuid: str, text: str) -> dict[str, object]: - block = {"type": "tool_result", "tool_use_id": uuid, "content": text} - return line(uuid, "user", [block]) - - _write_jsonl( - root / "project" / "s.jsonl", - [ - line("u1", "user", "第一个问题:跑测试"), - call("c1", f"pytest --token {SECRET}"), - result("r1", "3 failed"), - line("a1", "assistant", [{"type": "text", "text": "第一个结论"}]), - line("u2", "user", "第二个问题:看状态"), - # Said something and called a tool in one record. - call("c2", "git status", saying="先看一下。"), - result("r2", "working tree clean"), - line("a2", "assistant", [{"type": "text", "text": "第二个结论"}]), - ], - ) - - sent = _requests_to_hy( - tmp_path, create_claude_code_adapter(_grant("claude-code", root)), "claude-code" - ) - - second = [("user", "第二个问题"), ("assistant", "先看一下"), *SECOND_TURN[1:]] - _assert_turns(sent, FIRST_TURN, second) - assert sent[0][1][1].startswith("[Bash] ") - first, later = ("\n".join(content for _, content in turns) for turns in sent) - assert not any(marker in first for marker in SECOND_ONLY) - assert not any(marker in later for marker in FIRST_ONLY) - - -def test_cursor_transcript_tool_calls_reach_hy_inside_their_turn(tmp_path: Path) -> None: - """Cursor transcripts carry no timestamps; file order is the only order there is. - - An assistant message routinely says something and calls a tool in the same - content list; the call must not be lost because the text was kept. - """ - root = tmp_path / ".cursor" - - def user(text: str) -> dict[str, object]: - block = {"type": "text", "text": f"{text}"} - return {"role": "user", "message": {"content": [block]}} - - def assistant(*blocks: dict[str, object]) -> dict[str, object]: - return {"role": "assistant", "message": {"content": list(blocks)}} - - def call(command: str) -> dict[str, object]: - return {"type": "tool_use", "name": "Shell", "input": {"command": command}} - - def result(text: str) -> dict[str, object]: - block = {"type": "tool_result", "tool_use_id": "t", "content": text} - return {"role": "user", "message": {"content": [block]}} - - _write_jsonl( - root / "projects" / "workspace" / "agent-transcripts" / "chat" / "chat.jsonl", - [ - user("第一个问题:跑测试"), - assistant(call(f"pytest --token {SECRET}")), - result("3 failed"), - assistant({"type": "text", "text": "第一个结论"}), - user("第二个问题:看状态"), - assistant({"type": "text", "text": "先看一下。"}, call("git status")), - result("working tree clean"), - assistant({"type": "text", "text": "第二个结论"}), - ], - ) - - sent = _requests_to_hy( - tmp_path, CursorUnifiedSourceAdapter(grant=_grant("cursor", root)), "cursor" - ) - - second = [("user", "第二个问题"), ("assistant", "先看一下"), *SECOND_TURN[1:]] - _assert_turns(sent, FIRST_TURN, second) - - -def test_pi_tool_calls_reach_hy_inside_their_turn(tmp_path: Path) -> None: - root = tmp_path / "pi" - root.mkdir() - at = iter(f"2026-09-20T10:00:{second:02d}.000Z" for second in range(60)) - - def message(entry_id: str, role: str, content: object, **extra: object) -> dict[str, object]: - body = {"role": role, "content": content, **extra} - return {"type": "message", "id": entry_id, "timestamp": next(at), "message": body} - - def call(call_id: str, command: str) -> dict[str, object]: - return { - "type": "toolCall", - "id": call_id, - "name": "bash", - "arguments": {"command": command}, - } - - def result(entry_id: str, call_id: str, text: str) -> dict[str, object]: - return message( - entry_id, - "toolResult", - [{"type": "text", "text": text}], - toolCallId=call_id, - toolName="bash", - ) - - _write_jsonl( - root / "session-t.jsonl", - [ - {"type": "session", "id": "session-t", "cwd": "/Users/example/work/atlas"}, - message("u1", "user", [{"type": "text", "text": "第一个问题:跑测试"}]), - message("c1", "assistant", [call("k1", f"pytest --token {SECRET}")]), - result("r1", "k1", "3 failed"), - message("a1", "assistant", [{"type": "text", "text": "第一个结论"}]), - message("u2", "user", [{"type": "text", "text": "第二个问题:看状态"}]), - message( - "c2", - "assistant", - [{"type": "text", "text": "先看一下。"}, call("k2", "git status")], - ), - result("r2", "k2", "working tree clean"), - message("a2", "assistant", [{"type": "text", "text": "第二个结论"}]), - ], - ) - - sent = _requests_to_hy(tmp_path, create_pi_adapter(_grant("pi", root)), "pi") - - second = [("user", "第二个问题"), ("assistant", "先看一下"), *SECOND_TURN[1:]] - _assert_turns(sent, FIRST_TURN, second) - - -def test_qwen_code_tool_calls_reach_hy_inside_their_turn(tmp_path: Path) -> None: - root = tmp_path / "qwen-code" - session = "a1b2c3d4-0000-4000-8000-000000000003" - at = iter(f"2026-09-20T10:00:{second:02d}.000Z" for second in range(60)) - base = {"sessionId": session, "cwd": "/Users/example/work/atlas"} - - def user(uuid: str, text: str) -> dict[str, object]: - return {**base, "uuid": uuid, "timestamp": next(at), "type": "user", "text": text} - - def model(uuid: str, *parts: dict[str, object]) -> dict[str, object]: - return { - **base, - "uuid": uuid, - "timestamp": next(at), - "type": "assistant", - "provenance": "assistant_output", - "message": {"role": "model", "parts": list(parts)}, - } - - def call(command: str) -> dict[str, object]: - return {"functionCall": {"name": "run_shell_command", "args": {"command": command}}} - - def result(uuid: str, text: str) -> dict[str, object]: - response = {"name": "run_shell_command", "response": {"output": text}} - return { - **base, - "uuid": uuid, - "timestamp": next(at), - "type": "tool_result", - "provenance": "tool_result", - "message": {"role": "user", "parts": [{"functionResponse": response}]}, - } - - _write_jsonl( - root / "-Users-example-work-atlas" / "chats" / f"{session}.jsonl", - [ - user("u1", "第一个问题:跑测试"), - model("c1", call(f"pytest --token {SECRET}")), - result("r1", "3 failed"), - model("a1", {"text": "第一个结论"}), - user("u2", "第二个问题:看状态"), - model("c2", {"text": "先看一下。"}, call("git status")), - result("r2", "working tree clean"), - model("a2", {"text": "第二个结论"}), - ], - ) - - sent = _requests_to_hy( - tmp_path, create_qwen_code_adapter(_grant("qwen-code", root)), "qwen-code" - ) - - second = [("user", "第二个问题"), ("assistant", "先看一下"), *SECOND_TURN[1:]] - _assert_turns(sent, FIRST_TURN, second) - - -def test_deepseek_harness_tool_calls_reach_hy_inside_their_turn(tmp_path: Path) -> None: - root = tmp_path / "dsh" - seq = iter(range(1, 100)) - - def event(kind: str, data: dict[str, object]) -> dict[str, object]: - number = next(seq) - return { - "type": kind, - "seq": number, - "time": f"2026-09-20T10:00:{number:02d}Z", - "data": data, - } - - def user(message_id: str, text: str) -> dict[str, object]: - content = [{"type": "text", "text": text}] - body = {"id": message_id, "role": "user", "source": {"kind": "user"}, "content": content} - return event("user/message", body) - - def assistant(message_id: str, *blocks: dict[str, object]) -> dict[str, object]: - body = {"id": message_id, "role": "assistant", "content": list(blocks)} - return event("assistant/message", {"message": body}) - - def call(call_id: str, command: str) -> list[dict[str, object]]: - arguments = json.dumps({"command": command}) - block = {"type": "tool-call", "id": call_id, "name": "shell", "arguments": arguments} - # Upstream stores the call twice: inside the assistant message and as its own event. - return [ - assistant(f"m-{call_id}", block), - event("tool/call", {"callId": call_id, "name": "shell", "arguments": arguments}), - ] - - def result(call_id: str, text: str) -> dict[str, object]: - block = { - "type": "tool-result", - "toolCallId": call_id, - "content": [{"type": "text", "text": text}], - "isError": False, - } - body = { - "id": f"result-{call_id}", - "role": "user", - "source": {"kind": "tool", "callId": call_id}, - "content": [block], - } - return event("tool/result", {"message": body}) - - _write_jsonl( - root / "run-t" / "session.jsonl", - [ - {"type": "session", "id": "run-t", "cwd": "/private/project"}, - user("u1", "第一个问题:跑测试"), - *call("k1", f"pytest --token {SECRET}"), - result("k1", "3 failed"), - assistant("a1", {"type": "text", "text": "第一个结论"}), - user("u2", "第二个问题:看状态"), - *call("k2", "git status"), - result("k2", "working tree clean"), - assistant("a2", {"type": "text", "text": "第二个结论"}), - ], - ) - - sent = _requests_to_hy( - tmp_path, - create_deepseek_harness_adapter(_grant("deepseek-harness", root)), - "deepseek-harness", - ) - - _assert_two_turns(sent) - - -def test_hermes_tool_calls_reach_hy_inside_their_turn(tmp_path: Path) -> None: - """Hermes keeps the call on the assistant row (`tool_calls`) and the result as a `tool` row. - - `timestamp` is whole seconds here, so rows written in the same second tie; - the row id is the order Hermes wrote them in. - """ - root = tmp_path / "hermes" - root.mkdir() - connection = sqlite3.connect(root / "state.db") - connection.executescript( - """ - CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, cwd TEXT); - CREATE TABLE messages ( - id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, - tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, reasoning TEXT, - timestamp INTEGER, active INTEGER - ); - INSERT INTO sessions VALUES ('s', 'cli', '/private/project'); - """ - ) - - def calls(command: str) -> str: - arguments = json.dumps({"command": command}) - return json.dumps([{"id": "c", "function": {"name": "terminal", "arguments": arguments}}]) - - connection.executemany( - "INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - [ - (1, "s", "user", "第一个问题:跑测试", None, None, None, None, 1000, 1), - (2, "s", "assistant", "", None, calls(f"pytest --token {SECRET}"), None, None, 1001, 1), - (3, "s", "tool", "3 failed", "c", None, "terminal", None, 1001, 1), - (4, "s", "assistant", "第一个结论", None, None, None, None, 1001, 1), - (5, "s", "user", "第二个问题:看状态", None, None, None, None, 1002, 1), - (6, "s", "assistant", "", None, calls("git status"), None, None, 1003, 1), - (7, "s", "tool", "working tree clean", "c", None, "terminal", None, 1003, 1), - (8, "s", "assistant", "第二个结论", None, None, None, None, 1003, 1), - ], - ) - connection.commit() - connection.close() - - sent = _requests_to_hy(tmp_path, create_hermes_adapter(_grant("hermes", root)), "hermes") - - _assert_two_turns(sent) - assert sent[0][1][1].startswith("[terminal] ") - - -def _assembled_for_hy(tmp_path: Path, adapter: SourceAdapter, source_id: str) -> list[Expected]: - """The turns HY would get for each imported conversation, assembled the processing way. - - For sources whose `user` rows are not attributed to the person, the - processing path does not schedule the conversation at all, so the - assembly is read directly. - """ - evidence = _import(tmp_path, adapter, source_id)[0] - with sqlite3.connect(tmp_path / "one-memory.sqlite3") as db: - members = [ - row[0] - for row in db.execute("SELECT MIN(evidence_id) FROM evidence GROUP BY conversation_id") - ] - requests: list[Expected] = [] - for member in members: - for chunk in _conversation_chunks(evidence.list_conversation(member)): - request = MemoryTaskRunner._request(_FakeTask, chunk) - requests.append([(turn.role, turn.content) for turn in request.turns]) - return requests - - -class _FakeTask: - request_id = "assembly-only" - - -def _assert_in_source_order(sent: list[Expected], expected: Expected) -> None: - assert len(sent) == 1, sent - _assert_turns(sent, expected) - - -def test_openclaw_tool_rows_reach_hy_in_the_order_they_happened(tmp_path: Path) -> None: - """`created_at` is whole seconds; a call and its result share one. - - Row order is what breaks the tie, not the hash of an id. - """ - root = tmp_path / "openclaw" - root.mkdir() - connection = sqlite3.connect(root / "conversations.db") - connection.executescript( - """ - CREATE TABLE conversations (id TEXT PRIMARY KEY, workspace_path TEXT, git_root TEXT); - CREATE TABLE messages ( - id TEXT PRIMARY KEY, conversation_id TEXT, role TEXT, content TEXT, created_at TEXT - ); - INSERT INTO conversations VALUES ('c', '/private/project', NULL); - """ - ) - connection.executemany( - "INSERT INTO messages VALUES (?, ?, ?, ?, ?)", - [ - ("m1", "c", "user", "第一个问题:跑测试", "2026-09-20T10:00:00Z"), - ("m2", "c", "tool", f"pytest --token {SECRET}", "2026-09-20T10:00:01Z"), - ("m3", "c", "tool", "3 failed", "2026-09-20T10:00:01Z"), - ("m4", "c", "assistant", "第一个结论", "2026-09-20T10:00:01Z"), - ("m5", "c", "user", "第二个问题:看状态", "2026-09-20T10:00:02Z"), - ("m6", "c", "tool", "git status", "2026-09-20T10:00:03Z"), - ("m7", "c", "tool", "working tree clean", "2026-09-20T10:00:03Z"), - ("m8", "c", "assistant", "第二个结论", "2026-09-20T10:00:03Z"), - ], - ) - connection.commit() - connection.close() - - # Reader-shape coverage only. The public factory is paused; the negative - # control lives in test_c1_openclaw_paused.py. - adapter = OpenClawSourceAdapter(OPENCLAW_DESCRIPTOR, _grant("openclaw", root)) - sent = _assembled_for_hy(tmp_path, adapter, "openclaw") - - # D98: a `user` row reaches HY whoever the parser says wrote it, so the - # questions are here now — what must hold is that everything is still in - # the order it happened. - _assert_in_source_order(sent, FIRST_TURN + SECOND_TURN) - - -def test_opencode_tool_parts_reach_hy_in_the_order_they_happened(tmp_path: Path) -> None: - """A message's tool parts carry the message's own time, so they tie with its text. - - OpenCode keeps one assistant message across several steps: text, a tool - call, more text. The call must stay between the two. - """ - root = tmp_path / "opencode" - root.mkdir() - connection = sqlite3.connect(root / "opencode.db") - connection.executescript( - """ - CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT); - CREATE TABLE message ( - id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT - ); - CREATE TABLE part ( - id TEXT PRIMARY KEY, message_id TEXT, time_created INTEGER, data TEXT - ); - INSERT INTO session VALUES ('s', '/private/project'); - """ - ) - at = iter(range(1_758_000_000_000, 1_758_000_100_000, 1000)) - - def message(message_id: str, role: str, *parts: dict[str, object]) -> None: - connection.execute( - "INSERT INTO message VALUES (?, 's', ?, ?)", - (message_id, next(at), json.dumps({"role": role})), - ) - for index, data in enumerate(parts): - connection.execute( - "INSERT INTO part VALUES (?, ?, ?, ?)", - (f"{message_id}-p{index}", message_id, next(at), json.dumps(data)), - ) - - def tool(command: str, output: str) -> dict[str, object]: - state = {"status": "completed", "input": {"command": command}, "output": output} - return {"type": "tool", "tool": "bash", "callID": command, "state": state} - - message("u1", "user", {"type": "text", "text": "第一个问题:跑测试"}) - message( - "a1", - "assistant", - tool(f"pytest --token {SECRET}", "3 failed"), - {"type": "text", "text": "第一个结论"}, - ) - message("u2", "user", {"type": "text", "text": "第二个问题:看状态"}) - message( - "a2", - "assistant", - {"type": "text", "text": "先看一下。"}, - tool("git status", "working tree clean"), - {"type": "text", "text": "第二个结论"}, - ) - connection.commit() - connection.close() - - sent = _assembled_for_hy( - tmp_path, create_opencode_adapter(_grant("opencode", root)), "opencode" - ) - - # One tool part is one row: OpenCode keeps a call and its output together. - # D98: the questions are here now too — a `user` row reaches HY whoever - # the parser says wrote it — and everything stays in the order it happened. - _assert_in_source_order( - sent, - [ - ("user", "第一个问题:跑测试"), - ("tool", "[bash] 3 failed"), - ("assistant", "第一个结论"), - ("user", "第二个问题:看状态"), - ("assistant", "先看一下。"), - ("tool", "[bash] working tree clean"), - ("assistant", "第二个结论"), - ], - ) - - -def test_source_positions_compare_as_numbers_and_leave_the_file_key_alone() -> None: - from one_memory.imports.sources.common import sortable_ordinal - - assert sortable_ordinal("f0a9:byte:900") < sortable_ordinal("f0a9:byte:1000") - assert sortable_ordinal("f0a9:row:12:1") < sortable_ordinal("f0a9:row:12:10") - assert sortable_ordinal("f0a9:byte:900").startswith("f0a9:byte:") diff --git a/memhub/apps/web/src/App.import-navigation.test.tsx b/memhub/apps/web/src/App.import-navigation.test.tsx index 21db28a..3f4d120 100644 --- a/memhub/apps/web/src/App.import-navigation.test.tsx +++ b/memhub/apps/web/src/App.import-navigation.test.tsx @@ -117,7 +117,6 @@ vi.mock("./MatterWorkspace", () => ({ MatterWorkspace: () => null })); vi.mock("./HealthPanel", () => ({ HealthPanel: () => null })); vi.mock("./FirstUseGuide", () => ({ FirstUseGuide: () => null })); vi.mock("./OnboardingFlow", () => ({ OnboardingFlow: () => null })); -vi.mock("./ModelContentConsent", () => ({ ModelContentConsent: () => null })); beforeEach(() => { vi.clearAllMocks(); diff --git a/memhub/apps/web/src/App.r6.test.tsx b/memhub/apps/web/src/App.r6.test.tsx index 65435c4..cc33510 100644 --- a/memhub/apps/web/src/App.r6.test.tsx +++ b/memhub/apps/web/src/App.r6.test.tsx @@ -42,7 +42,6 @@ vi.mock("./MatterWorkspace", () => ({ MatterWorkspace: () => null })); vi.mock("./HealthPanel", () => ({ HealthPanel: () => null })); vi.mock("./FirstUseGuide", () => ({ FirstUseGuide: () => null })); vi.mock("./OnboardingFlow", () => ({ OnboardingFlow: () => null })); -vi.mock("./ModelContentConsent", () => ({ ModelContentConsent: () => null })); beforeEach(() => { window.history.replaceState({}, "", "/"); diff --git a/memhub/apps/web/src/App.setup.test.tsx b/memhub/apps/web/src/App.setup.test.tsx index 82b5810..9c6f1c8 100644 --- a/memhub/apps/web/src/App.setup.test.tsx +++ b/memhub/apps/web/src/App.setup.test.tsx @@ -31,14 +31,12 @@ const { getOnboarding, sources, modelProps, mutations } = vi.hoisted(() => ({ modelProps: vi.fn(), mutations: { activateModelProfile: vi.fn(), - setModelContentPermission: vi.fn(), }, })); vi.mock("./api", () => ({ getOnboarding, getModelProfiles: vi.fn().mockResolvedValue([]), - getModelContentPermission: vi.fn().mockResolvedValue({ allowed: false }), getSources: vi.fn().mockResolvedValue(sources), ...mutations, })); @@ -46,17 +44,8 @@ vi.mock("./use-import-sources", () => ({ useImportSources: () => ({ sources, jobs: [], loading: false, health: null }), })); vi.mock("./MemorySpace", () => ({ - MemorySpace: ({ - onReviewProcessing, - onConfigureModel, - }: { - onReviewProcessing?(): void; - onConfigureModel(): void; - }) => ( + MemorySpace: ({ onConfigureModel }: { onConfigureModel(): void }) => (
- @@ -79,24 +68,6 @@ vi.mock("./ModelSettings", () => ({ return ; }, })); -vi.mock("./ModelContentConsent", () => ({ - ModelContentConsent: ({ - onClose, - onConfigureModel, - }: { - onClose(): void; - onConfigureModel?(): void; - }) => ( -
- - -
- ), -})); vi.mock("./OnboardingFlow", () => ({ OnboardingFlow: ({ onFinish }: { onFinish(target: string): void }) => (
@@ -127,27 +98,11 @@ beforeEach(() => { }); afterEach(cleanup); -function noPermissionWrites() { +function noModelWrites() { for (const mutation of Object.values(mutations)) expect(mutation).not.toHaveBeenCalled(); } -it("opens model-content permission over the memory workbench", async () => { - window.history.replaceState({}, "", "/?view=memory"); - render(); - await userEvent.click( - await screen.findByRole("button", { name: "打开模型内容权限" }), - ); - expect(screen.getByRole("dialog", { name: "模型内容权限" })).toBeTruthy(); - expect(window.location.search).toBe("?view=memory"); - expect(screen.queryByText("应用列表")).toBeNull(); - await userEvent.click(screen.getByRole("button", { name: "关闭" })); - expect(screen.queryByRole("dialog", { name: "模型内容权限" })).toBeNull(); - expect(getOnboarding).toHaveBeenCalledTimes(1); - expect(screen.getByRole("region", { name: "测试工作台" })).toBeTruthy(); - noPermissionWrites(); -}); - it("enters D47 settings without a tour or mandatory second scope-confirmation flow", async () => { getOnboarding.mockResolvedValue({ status: "new" }); render(); @@ -164,13 +119,12 @@ it("enters D47 settings without a tour or mandatory second scope-confirmation fl expect( screen.queryByRole("button", { name: "继续:确认整理范围" }), ).toBeNull(); - expect(screen.queryByRole("dialog", { name: "模型内容权限" })).toBeNull(); - noPermissionWrites(); + noModelWrites(); expect(screen.queryByRole("button", { name: "管理已有授权" })).toBeNull(); expect(screen.getByRole("button", { name: "模型" })).toBeTruthy(); expect(screen.getByRole("button", { name: "连接其他应用" })).toBeTruthy(); expect(screen.queryByRole("button", { name: "帮助与诊断" })).toBeNull(); - noPermissionWrites(); + noModelWrites(); }); it("enters the workbench without an automatic tour or removed help section", async () => { @@ -187,7 +141,7 @@ it("enters the workbench without an automatic tour or removed help section", asy await userEvent.click(within(nav).getByRole("button", { name: "设置" })); expect(screen.queryByRole("button", { name: "帮助与诊断" })).toBeNull(); expect(screen.queryByRole("dialog", { name: "功能教学" })).toBeNull(); - noPermissionWrites(); + noModelWrites(); }); it("does not resume a pending tour over a settings deep link", async () => { @@ -202,14 +156,13 @@ it("does not resume a pending tour over a settings deep link", async () => { await waitFor(() => expect(modelProps).toHaveBeenCalled()); expect(modelProps.mock.calls.at(-1)?.[0].compactSingle).toBe(true); expect(screen.queryByRole("dialog", { name: "功能教学" })).toBeNull(); - expect(screen.queryByRole("dialog", { name: "模型内容权限" })).toBeNull(); expect(window.location.search).toBe("?view=settings"); // Still pending: nothing read or rewrote it. expect( JSON.parse(localStorage.getItem("one-memory.learning.s1-3") ?? "null") ?.state, ).toBe("pending"); - noPermissionWrites(); + noModelWrites(); }); it("keeps the applications default while explicit model configuration remains reachable", async () => { @@ -224,7 +177,7 @@ it("keeps the applications default while explicit model configuration remains re expect(window.location.search).toBe("?view=settings"); expect(modelProps.mock.calls.at(-1)?.[0].compactSingle).toBe(true); expect(screen.queryByRole("dialog")).toBeNull(); - noPermissionWrites(); + noModelWrites(); }); it("keeps an open model draft while switching settings groups", async () => { @@ -243,5 +196,5 @@ it("keeps an open model draft while switching settings groups", async () => { "value", "unsaved draft", ); - noPermissionWrites(); + noModelWrites(); }); diff --git a/memhub/apps/web/src/App.startup-recovery.test.tsx b/memhub/apps/web/src/App.startup-recovery.test.tsx index aecd529..ea99197 100644 --- a/memhub/apps/web/src/App.startup-recovery.test.tsx +++ b/memhub/apps/web/src/App.startup-recovery.test.tsx @@ -48,7 +48,6 @@ vi.mock("./ImportProgressStrip", () => ({ ImportProgressStrip: () => null })); vi.mock("./MatterWorkspace", () => ({ MatterWorkspace: () => null })); vi.mock("./HealthPanel", () => ({ HealthPanel: () => null })); vi.mock("./FirstUseGuide", () => ({ FirstUseGuide: () => null })); -vi.mock("./ModelContentConsent", () => ({ ModelContentConsent: () => null })); function deferred() { let resolve!: (state: { status: string }) => void; diff --git a/memhub/apps/web/src/App.tsx b/memhub/apps/web/src/App.tsx index 05df5c1..fd53128 100644 --- a/memhub/apps/web/src/App.tsx +++ b/memhub/apps/web/src/App.tsx @@ -30,7 +30,6 @@ import { HealthPanel } from "./HealthPanel"; import { ImportProgressStrip } from "./ImportProgressStrip"; import { MatterWorkspace } from "./MatterWorkspace"; import { MemorySpace } from "./MemorySpace"; -import { ModelContentConsent } from "./ModelContentConsent"; import type { MemoryArrival } from "./memory-arrival-state"; import { OnboardingFlow } from "./OnboardingFlow"; import { R6Button, R6LocalStorage } from "./R6Controls"; @@ -72,7 +71,6 @@ function AppContent() { const startupFlight = useRef | null>(null); const startupEpoch = useRef(0); const startupAlive = useRef(false); - const [processingScopeOpen, setProcessingScopeOpen] = useState(false); const [detailSourceId, setDetailSourceId] = useState(null); const [memoryArrival, setMemoryArrival] = useState< MemoryArrival | undefined @@ -212,11 +210,6 @@ function AppContent() { setView(next); }; - const openProcessingScope = () => { - if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; - setProcessingScopeOpen(true); - }; - if (!onboarding || imports.loading) { return (
@@ -351,7 +344,6 @@ function AppContent() { onRefresh={imports.refresh} arrival={memoryArrival} onConfigureModel={() => navigate("settings")} - onReviewProcessing={openProcessingScope} onJobAction={imports.changeJob} showSourceLink={displayedView !== "settings"} showProgressDetails={displayedView !== "settings"} @@ -378,7 +370,6 @@ function AppContent() { onFocusDialogueConsumed={() => setFocusDialogue(false)} onConnect={() => navigate("sources")} onConfigureModel={() => navigate("settings")} - onReviewProcessing={openProcessingScope} onArrivalChange={setMemoryArrival} />
@@ -419,7 +410,6 @@ function AppContent() { navigate("memory"); }} onCalls={() => navigate("calls")} - onSettings={() => navigate("settings")} /> )} @@ -469,15 +459,6 @@ function AppContent() {
- {processingScopeOpen && ( - setProcessingScopeOpen(false)} - onConfigureModel={() => { - setProcessingScopeOpen(false); - navigate("settings"); - }} - /> - )} ); } diff --git a/memhub/apps/web/src/ApplicationsHome.test.tsx b/memhub/apps/web/src/ApplicationsHome.test.tsx index fb0efc0..9b630be 100644 --- a/memhub/apps/web/src/ApplicationsHome.test.tsx +++ b/memhub/apps/web/src/ApplicationsHome.test.tsx @@ -19,21 +19,14 @@ import { saveConnectionPreferences } from "./connection-preferences"; import { resetConnectionReconcile } from "./connection-reconcile"; import { queryClient } from "./query-client"; -const { - summary, - processing, - permission, - history, - liveSync, - recentConversations, -} = vi.hoisted(() => ({ - summary: vi.fn(), - processing: vi.fn(), - permission: vi.fn(), - history: vi.fn(), - liveSync: vi.fn(), - recentConversations: vi.fn(), -})); +const { summary, processing, history, liveSync, recentConversations } = + vi.hoisted(() => ({ + summary: vi.fn(), + processing: vi.fn(), + history: vi.fn(), + liveSync: vi.fn(), + recentConversations: vi.fn(), + })); vi.mock("./call-history-client", async (original) => ({ ...(await original()), getCallHistory: history, @@ -42,7 +35,6 @@ vi.mock("./api", () => ({ getHomeSummary: summary, getLiveSyncHealth: liveSync, getMemoryProcessing: processing, - getModelContentPermission: permission, getRecentConversations: recentConversations, getSources: vi.fn().mockResolvedValue([]), })); @@ -82,7 +74,6 @@ beforeEach(() => { observed_at: "2026-09-19T00:00:00Z", }); processing.mockReset().mockResolvedValue({ running: 0, pending: 0 }); - permission.mockReset().mockResolvedValue({ allowed: true }); liveSync.mockReset().mockResolvedValue([]); recentConversations.mockReset().mockResolvedValue({ items: [], @@ -545,26 +536,6 @@ it.each([ }, ); -it("shows disabled model-content permission and opens settings", async () => { - permission.mockResolvedValue({ allowed: false }); - const onSettings = vi.fn(); - - render( - {}} - onMemories={() => {}} - onSettings={onSettings} - />, - ); - - expect((await screen.findAllByText("模型读取已关闭")).length).toBeGreaterThan( - 0, - ); - expect(screen.getByText("新的对话不会再整理成记忆")).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "打开模型内容权限" })); - expect(onSettings).toHaveBeenCalledOnce(); -}); afterEach(() => { cleanup(); resetConnectionReconcile(); diff --git a/memhub/apps/web/src/ApplicationsHome.tsx b/memhub/apps/web/src/ApplicationsHome.tsx index 3042aa2..aa892b5 100644 --- a/memhub/apps/web/src/ApplicationsHome.tsx +++ b/memhub/apps/web/src/ApplicationsHome.tsx @@ -12,7 +12,6 @@ import { getHomeSummary, getLiveSyncHealth, getMemoryProcessing, - getModelContentPermission, getRecentConversations, } from "./api"; import { AppMark } from "./app-mark"; @@ -169,7 +168,6 @@ export function ApplicationsHome({ imports, onManage, onMemories, - onSettings, onCalls, active = true, }: { @@ -178,7 +176,7 @@ export function ApplicationsHome({ onMemories(memoryId?: string): void; /** 保留兼容旧调用方;“查看会话”入口已按产品要求下线。 */ onConversations?(): void; - onSettings(): void; + onSettings?(): void; onCalls?(): void; active?: boolean; }) { @@ -198,9 +196,6 @@ export function ApplicationsHome({ const [processing, setProcessing] = useState( null, ); - const [modelContentAllowed, setModelContentAllowed] = useState< - boolean | null - >(null); const [syncHealth, setSyncHealth] = useState([]); const [failed, setFailed] = useState(false); const [recentCall, setRecentCall] = useState(null); @@ -554,10 +549,9 @@ export function ApplicationsHome({ async function refresh() { if (busy || document.hidden) return; busy = true; - const [home, state, permission, sync, recent] = await Promise.allSettled([ + const [home, state, sync, recent] = await Promise.allSettled([ getHomeSummary(), getMemoryProcessing(), - getModelContentPermission(), getLiveSyncHealth(), getRecentConversations({ limit: 1, recentActivity: true }), ]); @@ -572,9 +566,6 @@ export function ApplicationsHome({ setFailed(true); } if (state.status === "fulfilled") setProcessing(state.value); - if (permission.status === "fulfilled") - setModelContentAllowed(permission.value.allowed); - else setModelContentAllowed(null); if (sync.status === "fulfilled") setSyncHealth(sync.value); if (recent.status === "fulfilled") { setLatestDialogue(recent.value.items[0] ?? null); @@ -660,22 +651,19 @@ export function ApplicationsHome({ const apps = imports.sources.filter( (s) => s.authorized || s.detected === true || s.location_state === "found", ); - const stateLabel = - modelContentAllowed === false - ? "模型读取已关闭" - : processing?.running - ? "整理中" - : processing?.blocked_stages?.some((stage) => stage.count > 0) - ? "排队处理中" - : processing?.waiting_for_model - ? "等待模型" - : processing?.failed - ? "需要处理" - : processing?.pending - ? "准备中" - : processing - ? "一切就绪" - : "待核对"; + const stateLabel = processing?.running + ? "整理中" + : processing?.blocked_stages?.some((stage) => stage.count > 0) + ? "排队处理中" + : processing?.waiting_for_model + ? "等待模型" + : processing?.failed + ? "需要处理" + : processing?.pending + ? "准备中" + : processing + ? "一切就绪" + : "待核对"; const latestClock = latest ? clockParts(latest.updated_at) : null; const time = latestClock?.clock ?? (latest ? "时间未知" : null); const checked = visible ? clockParts(visible.observed_at) : null; @@ -848,19 +836,12 @@ export function ApplicationsHome({

沉淀状态

{stateLabel} - {modelContentAllowed === false - ? "新的对话不会再整理成记忆" - : processing?.running - ? "新的对话正在整理" - : processing?.pending - ? "等待队列开始处理" - : "以当前处理状态为准"} + {processing?.running + ? "新的对话正在整理" + : processing?.pending + ? "等待队列开始处理" + : "以当前处理状态为准"} - {modelContentAllowed === false ? ( - onSettings()}> - 打开模型内容权限 - - ) : null}

最近更新

diff --git a/memhub/apps/web/src/ConversationSearch.test.tsx b/memhub/apps/web/src/ConversationSearch.test.tsx index fad82b8..fc6cfed 100644 --- a/memhub/apps/web/src/ConversationSearch.test.tsx +++ b/memhub/apps/web/src/ConversationSearch.test.tsx @@ -393,11 +393,9 @@ it("点开一条结果就地展开它的原文", async () => { expect(getConversation).toHaveBeenCalledWith("evidence-1"); }); -it("explains both model configuration and content consent before generating memory", () => { +it("explains model configuration before generating memory", () => { render(); - expect( - screen.getByText(/设置模型并打开模型内容权限后,才会开始生成记忆/), - ).toBeTruthy(); + expect(screen.getByText(/设置模型后,才会开始生成记忆/)).toBeTruthy(); expect(screen.queryByText(/配好生成模型之后/)).toBeNull(); }); diff --git a/memhub/apps/web/src/ConversationSearch.tsx b/memhub/apps/web/src/ConversationSearch.tsx index 991b350..b70c426 100644 --- a/memhub/apps/web/src/ConversationSearch.tsx +++ b/memhub/apps/web/src/ConversationSearch.tsx @@ -299,7 +299,7 @@ export function ConversationSearch({

- 这里搜的是你导入的原始对话,不是记忆。设置模型并打开模型内容权限后,才会开始生成记忆。 + 这里搜的是你导入的原始对话,不是记忆。设置模型后,才会开始生成记忆。

{!props.capability.saveAndEnable && ( diff --git a/memhub/apps/web/src/R6Settings.test.tsx b/memhub/apps/web/src/R6Settings.test.tsx index 2a86620..839f81d 100644 --- a/memhub/apps/web/src/R6Settings.test.tsx +++ b/memhub/apps/web/src/R6Settings.test.tsx @@ -5,10 +5,6 @@ import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; import { R6Settings } from "./R6Settings"; -const permission = vi.hoisted(() => ({ - update: vi.fn(), -})); - vi.mock("./api", () => ({ getModelProfiles: vi.fn().mockResolvedValue([ { @@ -34,36 +30,11 @@ vi.mock("./runtime-state", () => ({ vi.mock("./diagnostics-export", () => ({ exportDiagnosticsFile: vi.fn(), })); -vi.mock("./use-model-content-permission", () => ({ - useModelContentPermission: () => ({ - allowed: false, - reading: false, - writing: false, - error: null, - refresh: vi.fn(), - update: permission.update, - }), -})); - afterEach(() => { cleanup(); vi.clearAllMocks(); }); -it("renders one global model-content permission switch", async () => { - const user = userEvent.setup(); - render(); - - const toggle = await screen.findByRole("switch", { - name: "允许当前配置模型读取本地会话内容", - }); - expect(toggle.getAttribute("aria-checked")).toBe("false"); - - await user.click(toggle); - expect(permission.update).toHaveBeenCalledWith(true); - expect(screen.queryByText(/模型处理范围|后续新增/)).toBeNull(); -}); - it("hides diagnostics by default and exposes it only when configured", async () => { const user = userEvent.setup(); const view = render(); diff --git a/memhub/apps/web/src/R6Settings.tsx b/memhub/apps/web/src/R6Settings.tsx index b19bbfc..68c5530 100644 --- a/memhub/apps/web/src/R6Settings.tsx +++ b/memhub/apps/web/src/R6Settings.tsx @@ -18,7 +18,6 @@ import { setLoginPreference, useLoginPreference, } from "./runtime-state"; -import { useModelContentPermission } from "./use-model-content-permission"; import "./r6-settings.css"; type Group = "model" | "connections" | "help"; @@ -69,9 +68,6 @@ export function R6Settings({ const quitPanel = useRef(null); const quitOpener = useRef(null); const currentModel = profiles?.find((profile) => profile.is_active) ?? null; - const contentPermission = useModelContentPermission( - active && group === "model", - ); useLayoutEffect(() => { if (!active) return; @@ -279,32 +275,6 @@ export function R6Settings({ 请等待当前操作结束后重试;现有配置和原文不会被改动。

)} - {currentModel && ( -
-

模型内容权限

-

- 允许当前配置模型读取已导入和之后同步到本机的会话内容。 - 已生成记忆的搜索不受此开关影响。 -

- - void contentPermission.update(allowed) - } - /> - {contentPermission.error && ( -

- {contentPermission.error} -

- )} -
- )}
{ ]); }); - it("discloses that ask may use model processing, and lets it be left out", async () => { + it("discloses that ask may use the configured model, and lets it be left out", async () => { const api = setup(); await open(); - expect(screen.getByText(/可能使用另行获准的模型处理/)).toBeTruthy(); + expect(screen.getByText(/可能使用已配置的模型处理/)).toBeTruthy(); await userEvent.click(check("记忆问答(可选)")); await confirm(); expect(api.set.mock.calls[0][1].allowedTools).toEqual([ diff --git a/memhub/apps/web/src/RecallPermissionControl.tsx b/memhub/apps/web/src/RecallPermissionControl.tsx index 2bc1734..f25e8e0 100644 --- a/memhub/apps/web/src/RecallPermissionControl.tsx +++ b/memhub/apps/web/src/RecallPermissionControl.tsx @@ -892,7 +892,7 @@ function PermissionDialog({ {toolNames[tool]} {tool === "om_ask" && ( - 可能使用另行获准的模型处理。本次不会授予模型内容权限。 + 可能使用已配置的模型处理。本次只授予此应用的记忆读取范围。 )} @@ -1068,7 +1068,8 @@ function PermissionDialog({ )} {snapshot.action === "grant" && (

- 本次不会安装连接、导入历史、开启持续同步或授权模型处理。授权不代表应用已实际调用或采用记忆。 + 本次不会安装连接、导入历史或开启持续同步,也不改变内置 SDK + 的本地记忆处理。授权不代表应用已实际调用或采用记忆。

)} {pending && ( diff --git a/memhub/apps/web/src/SourcesView.test.tsx b/memhub/apps/web/src/SourcesView.test.tsx index a0f0b51..e657bfe 100644 --- a/memhub/apps/web/src/SourcesView.test.tsx +++ b/memhub/apps/web/src/SourcesView.test.tsx @@ -4,7 +4,6 @@ import type { ConnectSourcesResponse, ImportJob } from "@one-memory/contracts"; import { act, cleanup, - fireEvent, render, screen, waitFor, @@ -13,22 +12,11 @@ import { import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { ModelContentConsent } from "./ModelContentConsent"; import { SourcesView } from "./SourcesView"; import type { useImportSources } from "./use-import-sources"; afterEach(cleanup); -vi.mock("./ModelContentConsent", () => ({ - ModelContentConsent: vi.fn(({ onClose }: { onClose(): void }) => ( -
- -
- )), -})); - describe("SourcesView", () => { afterEach(() => { cleanup(); @@ -1467,43 +1455,6 @@ it("Thaw and Freeze use the one Harness processing switch", async () => { ); }); -it("opens the global model-content permission only on demand", async () => { - const imports = syncImports(); - const view = render(); - expect(screen.queryByRole("dialog", { name: "模型整理测试" })).toBeNull(); - await userEvent.click(screen.getByRole("button", { name: "管理模型权限" })); - expect(screen.getByRole("dialog", { name: "模型整理测试" })).toBeTruthy(); - expect( - screen.getByRole("button", { name: "管理模型权限" }) - .disabled, - ).toBe(false); - expect(ModelContentConsent).toHaveBeenCalled(); - expect( - screen.getByRole("checkbox", { name: "处理 WorkBuddy" }) - .disabled, - ).toBe(false); - const next = { - ...imports, - sources: imports.sources.map((source) => ({ - ...source, - imported_messages: 8, - })), - }; - view.rerender(); - await userEvent.click(screen.getByRole("button", { name: "关闭模型授权" })); - expect(screen.queryByRole("dialog")).toBeNull(); - expect(imports.connectSelected).not.toHaveBeenCalled(); - expect(imports.setSync).not.toHaveBeenCalled(); -}); - -it("COLD-14..16: model consent does not nest inside history import", async () => { - render(); - await userEvent.click(screen.getByRole("button", { name: "导入历史包" })); - fireEvent.click(screen.getByRole("button", { name: "管理模型权限" })); - expect(screen.getAllByRole("dialog")).toHaveLength(1); - expect(screen.getByRole("dialog", { name: "导入历史包" })).toBeTruthy(); -}); - it("S1.3: an incomplete permission receipt cannot activate global sync", () => { const imports = syncImports(); render( diff --git a/memhub/apps/web/src/SourcesView.tsx b/memhub/apps/web/src/SourcesView.tsx index 499fb07..33e1c01 100644 --- a/memhub/apps/web/src/SourcesView.tsx +++ b/memhub/apps/web/src/SourcesView.tsx @@ -24,7 +24,6 @@ import { uninstallHost, useRecallHosts, } from "./host-state"; -import { ModelContentConsent } from "./ModelContentConsent"; import { RecallPermissionControl } from "./RecallPermissionControl"; import { fullGrantSelection, selectionIsValid } from "./recall-grant"; import { LINK_WORDING, SKILL_WORDING } from "./recall-host-wording"; @@ -87,7 +86,6 @@ export function SourcesView({ imports }: { imports: ImportState }) { string | null >(null); const [directoryBusy, setDirectoryBusy] = useState(null); - const [modelConsentOpen, setModelConsentOpen] = useState(false); const [consentPhase, setConsentPhase] = useState< "general" | "environment_override" | null >(null); @@ -474,9 +472,6 @@ export function SourcesView({ imports }: { imports: ImportState }) { return (
- {modelConsentOpen && ( - setModelConsentOpen(false)} /> - )}

应用

@@ -509,32 +504,6 @@ export function SourcesView({ imports }: { imports: ImportState }) { onRefresh={imports.refresh} /> -
-
-

模型内容权限

-

用一个全局开关控制当前模型能否读取本地会话内容。

-
- -
-
{ - it("accepts a valid model profile list and permission", async () => { - fake.get - .mockResolvedValueOnce({ data: [modelProfile] }) - .mockResolvedValueOnce({ data: { allowed: false } }); + it("accepts a valid model profile list", async () => { + fake.get.mockResolvedValueOnce({ data: [modelProfile] }); await expect(getModelProfiles()).resolves.toEqual([modelProfile]); - await expect(getModelContentPermission()).resolves.toEqual({ - allowed: false, - }); }); it("rejects a malformed profile before settings renders it", async () => { @@ -94,27 +87,6 @@ describe("model read transport validation", () => { }); await expect(getModelProfiles()).rejects.toThrow("model_profiles_invalid"); }); - - it("rejects a malformed model-content permission", async () => { - fake.get.mockResolvedValue({ - data: { allowed: "yes" }, - }); - await expect(getModelContentPermission()).rejects.toThrow( - "model_content_permission_invalid", - ); - }); - - it("posts only the desired global permission", async () => { - fake.post.mockResolvedValue({ data: { allowed: true } }); - await expect(setModelContentPermission(true)).resolves.toEqual({ - allowed: true, - }); - expect(fake.post).toHaveBeenCalledWith( - "/api/v1/model-content-permission", - { allowed: true }, - undefined, - ); - }); }); describe("live sync health transport validation", () => { diff --git a/memhub/apps/web/src/api.ts b/memhub/apps/web/src/api.ts index 3f8d53f..5d85eed 100644 --- a/memhub/apps/web/src/api.ts +++ b/memhub/apps/web/src/api.ts @@ -36,7 +36,6 @@ import { type MemoryNoteReceipt, type MemoryOrigin, type MemoryProcessingSummary, - type ModelContentPermission, type ModelCreationOperation, type ModelProbeOperationReceipt, type ModelProbeResult, @@ -703,44 +702,6 @@ export async function getModelProfiles(): Promise { return profiles as ModelProfile[]; } -export async function getModelContentPermission( - signal?: AbortSignal, -): Promise { - const response = await client.get( - "/api/v1/model-content-permission", - { signal }, - ); - const permission = parseModelContentPermission(response.data); - if (permission === null) throw new Error("model_content_permission_invalid"); - return permission; -} - -export async function setModelContentPermission( - allowed: boolean, - traceId?: string, -): Promise { - const response = await client.post( - "/api/v1/model-content-permission", - { allowed }, - traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined, - ); - const permission = parseModelContentPermission(response.data); - if (permission === null || permission.allowed !== allowed) - throw new Error("model_content_permission_invalid"); - return permission; -} - -export function parseModelContentPermission( - value: unknown, -): ModelContentPermission | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const permission = value as Record; - return Object.keys(permission).length === 1 && - typeof permission.allowed === "boolean" - ? { allowed: permission.allowed } - : null; -} - export async function getModelCreationOperation( operationId: string, ): Promise { diff --git a/memhub/apps/web/src/host-state.integration.test.tsx b/memhub/apps/web/src/host-state.integration.test.tsx index f57b0b4..8efce7f 100644 --- a/memhub/apps/web/src/host-state.integration.test.tsx +++ b/memhub/apps/web/src/host-state.integration.test.tsx @@ -34,8 +34,6 @@ vi.mock("./api", () => ({ getRecentConversations: recent, getSources: vi.fn().mockResolvedValue([]), getModelProfiles: vi.fn().mockResolvedValue([]), - getModelContentPermission: vi.fn().mockResolvedValue({ allowed: false }), - setModelContentPermission: vi.fn(), setSourceSync: vi.fn(), })); vi.mock("./ModelSettings", () => ({ ModelSettings: () => null })); diff --git a/memhub/apps/web/src/index-sync-notice.ts b/memhub/apps/web/src/index-sync-notice.ts index 1a996ca..f1bea4c 100644 --- a/memhub/apps/web/src/index-sync-notice.ts +++ b/memhub/apps/web/src/index-sync-notice.ts @@ -43,12 +43,11 @@ export function indexSyncPresentation( if ( reason === "context_changed" || reason === "memory_revision_conflict" || - reason === "model_content_permission_changed" || reason === "model_content_profile_changed" ) { return { attemptedLine, - explanation: "这条记忆或授权版本已经变化,先刷新核对再决定下一步。", + explanation: "这条记忆或模型版本已经变化,先刷新核对再决定下一步。", action: "refresh", }; } diff --git a/memhub/apps/web/src/memory-arrival-state.test.ts b/memhub/apps/web/src/memory-arrival-state.test.ts index 1485cfb..401bfba 100644 --- a/memhub/apps/web/src/memory-arrival-state.test.ts +++ b/memhub/apps/web/src/memory-arrival-state.test.ts @@ -69,33 +69,7 @@ it.each([ }, "model-required", ], - [ - "consent block", - { - blocked_stages: [ - { - kind: "extract", - reason: "model_content_permission_required", - count: 1, - }, - ], - }, - "consent-required", - ], ["failure", { failed: 1 }, "failed"], - [ - "revoked consent block", - { - blocked_stages: [ - { - kind: "extract", - reason: "model_content_permission_revoked", - count: 210, - }, - ], - }, - "consent-required", - ], [ "unknown block", { blocked_stages: [{ kind: "extract", reason: "unknown", count: 1 }] }, @@ -152,7 +126,6 @@ it("does not promise continued processing for stale or queued-only state", () => it.each([ ["model_profile_not_active", "model-required"], - ["model_content_permission_required", "consent-required"], ["hy_memory_model_authentication_failed", "model-required"], ["hy_memory_model_payment_required", "model-required"], ] as const)( @@ -279,13 +252,7 @@ it.each([ ).toBe("processing"); }); -it.each([ - "checking", - "unavailable", - "stopped", - "model-required", - "consent-required", -] as const)( +it.each(["checking", "unavailable", "stopped", "model-required"] as const)( "explicit parent %s remains authoritative over a running count", (phase) => { expect( @@ -316,28 +283,3 @@ it("raw import alone has an import title, without a generated-memory promise", ( expect(result.phase).toBe("importing"); expect(memoryArrivalTitle(result)).toBe("正在导入对话"); }); - -it("reports the global model-content permission block", () => { - const result = resolveMemoryArrival({ - ...base, - arrival: { - importing: false, - importedMessages: 12, - importedConversations: 3, - phase: "idle", - }, - processing: { - ...processing, - blocked_stages: [ - { - kind: "extract", - reason: "model_content_permission_required", - count: 2, - }, - ], - }, - }); - expect(result.phase).toBe("consent-required"); - expect(memoryArrivalTitle(result)).toBe("等待允许模型读取本地会话内容"); - expect(result.blockedStages).toHaveLength(1); -}); diff --git a/memhub/apps/web/src/memory-arrival-state.ts b/memhub/apps/web/src/memory-arrival-state.ts index 68f42eb..082d492 100644 --- a/memhub/apps/web/src/memory-arrival-state.ts +++ b/memhub/apps/web/src/memory-arrival-state.ts @@ -9,7 +9,6 @@ export type MemoryArrivalPhase = | "checking" | "importing" | "model-required" - | "consent-required" | "processing" | "failed" | "idle" @@ -31,11 +30,6 @@ export interface MemoryArrival { memoryCount?: number; } -const permissionReasons = new Set([ - "model_content_permission_required", - "model_content_permission_revoked", -]); - export function resolveMemoryArrival({ arrival, processing, @@ -65,13 +59,9 @@ export function resolveMemoryArrival({ let phase: MemoryArrivalPhase = arrival?.phase ?? "idle"; if ( arrival && - [ - "checking", - "unavailable", - "stopped", - "model-required", - "consent-required", - ].includes(arrival.phase) + ["checking", "unavailable", "stopped", "model-required"].includes( + arrival.phase, + ) ) { return blocks.length ? { ...arrival, blockedStages: blocks } : arrival; } @@ -86,8 +76,6 @@ export function resolveMemoryArrival({ phase = "processing"; } else if ((processing?.waiting_for_model ?? 0) > 0 || modelBlock(blocks)) { phase = "model-required"; - } else if (blocks.some((block) => permissionReasons.has(block.reason))) { - phase = "consent-required"; } else if ((processing?.failed ?? 0) > 0 && !onlyRetries(blocks)) { // "Failed" is a prompt to go and look. Work the pipeline will pick up on // its own pass is not that, and asking the reader to check a failure @@ -135,8 +123,6 @@ export function memoryArrivalTitle(arrival: MemoryArrival): string { return modelBlock(arrival.blockedStages ?? []) ? "待继续" : "尚未设置整理模型"; - case "consent-required": - return "等待允许模型读取本地会话内容"; case "failed": case "stopped": return "待继续"; diff --git a/memhub/apps/web/src/memory-arrival.test.tsx b/memhub/apps/web/src/memory-arrival.test.tsx index e0560b4..dd968d6 100644 --- a/memhub/apps/web/src/memory-arrival.test.tsx +++ b/memhub/apps/web/src/memory-arrival.test.tsx @@ -64,7 +64,6 @@ it.each([ ["checking", "进度待更新", false], ["importing", "正在导入对话", true], ["model-required", "尚未设置整理模型", false], - ["consent-required", "等待允许模型读取本地会话内容", false], ["processing", "正在沉淀记忆", true], ["failed", "待继续", false], ["idle", "待继续", false], @@ -87,22 +86,6 @@ it.each([ }, ); -it("uses existing settings navigation for consent when no separate callback is supplied", async () => { - const settings = vi.fn(); - render( - , - ); - expect(settings).not.toHaveBeenCalled(); - await userEvent.click( - screen.getByRole("button", { name: "打开模型内容权限" }), - ); - expect(settings).toHaveBeenCalledOnce(); -}); - it("offers explicit connection only for the truly empty idle state", async () => { const connect = vi.fn(); const view = render( diff --git a/memhub/apps/web/src/memory-arrival.tsx b/memhub/apps/web/src/memory-arrival.tsx index b381476..e0a4497 100644 --- a/memhub/apps/web/src/memory-arrival.tsx +++ b/memhub/apps/web/src/memory-arrival.tsx @@ -1,10 +1,4 @@ -import { - FileText, - Link2, - LoaderCircle, - Settings2, - ShieldCheck, -} from "lucide-react"; +import { FileText, Link2, LoaderCircle, Settings2 } from "lucide-react"; import { type MemoryArrival, memoryArrivalTitle, @@ -22,7 +16,6 @@ export function MemoryArrivalPanel({ hasMemories = false, suspended = false, onViewHistory, - onReviewProcessing, onConfigureModel, onConnect, showDetails = true, @@ -31,7 +24,6 @@ export function MemoryArrivalPanel({ hasMemories?: boolean; suspended?: boolean; onViewHistory?(): void; - onReviewProcessing?(): void; onConfigureModel(): void; onConnect(): void; showDetails?: boolean; @@ -115,20 +107,11 @@ export function MemoryArrivalPanel({ 设置记忆整理 )} - {arrival.phase === "consent-required" && ( - - )} {["failed", "unavailable", "stopped"].includes(arrival.phase) && (